diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 969c60c378c..e16777f4f2c 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -810,6 +810,7 @@ export function listSessionsFromStore(params: { const model = resolvedModel.model ?? DEFAULT_MODEL; return { key, + spawnedBy: entry?.spawnedBy, entry, kind: classifySessionKey(key, entry), label: entry?.label, diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts index 711a1997f22..80873b0000c 100644 --- a/src/gateway/session-utils.types.ts +++ b/src/gateway/session-utils.types.ts @@ -15,6 +15,7 @@ export type GatewaySessionsDefaults = { export type GatewaySessionRow = { key: string; + spawnedBy?: string; kind: "direct" | "group" | "global" | "unknown"; label?: string; displayName?: string; diff --git a/ui/src/ui/chat/slash-command-executor.node.test.ts b/ui/src/ui/chat/slash-command-executor.node.test.ts index 2e47b79aa43..2b8aa9d5f86 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -3,11 +3,13 @@ import type { GatewayBrowserClient } from "../gateway.ts"; import type { GatewaySessionRow } from "../types.ts"; import { executeSlashCommand } from "./slash-command-executor.ts"; -function row(key: string): GatewaySessionRow { +function row(key: string, overrides?: Partial): GatewaySessionRow { return { key, + spawnedBy: overrides?.spawnedBy, kind: "direct", updatedAt: null, + ...overrides, }; } @@ -18,8 +20,11 @@ describe("executeSlashCommand /kill", () => { return { sessions: [ row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:parent:subagent:child"), + row("agent:main:subagent:one", { spawnedBy: "main" }), + row("agent:main:subagent:parent", { spawnedBy: "main" }), + row("agent:main:subagent:parent:subagent:child", { + spawnedBy: "agent:main:subagent:parent", + }), row("agent:other:main"), ], }; @@ -37,12 +42,15 @@ describe("executeSlashCommand /kill", () => { "all", ); - expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(result.content).toBe("Aborted 3 sub-agent sessions."); expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { sessionKey: "agent:main:subagent:one", }); expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:parent", + }); + expect(request).toHaveBeenNthCalledWith(4, "chat.abort", { sessionKey: "agent:main:subagent:parent:subagent:child", }); }); @@ -52,9 +60,9 @@ describe("executeSlashCommand /kill", () => { if (method === "sessions.list") { return { sessions: [ - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }), ], }; } @@ -86,9 +94,11 @@ describe("executeSlashCommand /kill", () => { if (method === "sessions.list") { return { sessions: [ - row("agent:main:subagent:parent"), - row("agent:main:subagent:parent:subagent:child"), - row("agent:main:subagent:sibling"), + row("agent:main:subagent:parent", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:parent:subagent:child", { + spawnedBy: "agent:main:subagent:parent", + }), + row("agent:main:subagent:sibling", { spawnedBy: "agent:main:main" }), ], }; } @@ -116,7 +126,10 @@ describe("executeSlashCommand /kill", () => { const request = vi.fn(async (method: string, _payload?: unknown) => { if (method === "sessions.list") { return { - sessions: [row("agent:main:subagent:one"), row("agent:main:subagent:two")], + sessions: [ + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + ], }; } if (method === "chat.abort") { @@ -148,9 +161,9 @@ describe("executeSlashCommand /kill", () => { return { sessions: [ row("main"), - row("agent:main:subagent:one"), - row("agent:main:subagent:two"), - row("agent:other:subagent:three"), + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + row("agent:other:subagent:three", { spawnedBy: "agent:other:main" }), ], }; } @@ -176,4 +189,128 @@ describe("executeSlashCommand /kill", () => { sessionKey: "agent:main:subagent:two", }); }); + + it("does not abort unrelated same-agent subagents from another root session", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:mine", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:mine:subagent:child", { + spawnedBy: "agent:main:subagent:mine", + }), + row("agent:main:subagent:other-root", { + spawnedBy: "agent:main:discord:dm:alice", + }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "kill", + "all", + ); + + expect(result.content).toBe("Aborted 2 sub-agent sessions."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "chat.abort", { + sessionKey: "agent:main:subagent:mine", + }); + expect(request).toHaveBeenNthCalledWith(3, "chat.abort", { + sessionKey: "agent:main:subagent:mine:subagent:child", + }); + }); +}); + +describe("executeSlashCommand directives", () => { + it("reports the current thinking level for bare /think", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { + modelProvider: "openai", + model: "gpt-4.1-mini", + }), + ], + }; + } + if (method === "models.list") { + return { + models: [{ id: "gpt-4.1-mini", provider: "openai", reasoning: true }], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "", + ); + + expect(result.content).toBe( + "Current thinking level: low.\nOptions: off, minimal, low, medium, high, adaptive.", + ); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + expect(request).toHaveBeenNthCalledWith(2, "models.list", {}); + }); + + it("accepts minimal and xhigh thinking levels", async () => { + const request = vi.fn().mockResolvedValueOnce({ ok: true }).mockResolvedValueOnce({ ok: true }); + + const minimal = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "minimal", + ); + const xhigh = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "think", + "xhigh", + ); + + expect(minimal.content).toBe("Thinking level set to **minimal**."); + expect(xhigh.content).toBe("Thinking level set to **xhigh**."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.patch", { + key: "agent:main:main", + thinkingLevel: "minimal", + }); + expect(request).toHaveBeenNthCalledWith(2, "sessions.patch", { + key: "agent:main:main", + thinkingLevel: "xhigh", + }); + }); + + it("reports the current verbose level for bare /verbose", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [row("agent:main:main", { verboseLevel: "full" })], + }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "verbose", + "", + ); + + expect(result.content).toBe("Current verbose level: full.\nOptions: on, full, off."); + expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); + }); }); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index ce2a43a8f41..51db61a0ba2 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -4,7 +4,14 @@ */ import type { ModelCatalogEntry } from "../../../../src/agents/model-catalog.js"; +import { resolveThinkingDefault } from "../../../../src/agents/model-selection.js"; +import { + formatThinkingLevels, + normalizeThinkLevel, + normalizeVerboseLevel, +} from "../../../../src/auto-reply/thinking.js"; import type { HealthSummary } from "../../../../src/commands/health.js"; +import type { OpenClawConfig } from "../../../../src/config/config.js"; import { DEFAULT_AGENT_ID, DEFAULT_MAIN_KEY, @@ -166,18 +173,31 @@ async function executeThink( sessionKey: string, args: string, ): Promise { - const valid = ["off", "low", "medium", "high"]; - const level = args.trim().toLowerCase(); - - if (!level) { - return { - content: `Usage: \`/think <${valid.join("|")}>\``, - }; + const rawLevel = args.trim(); + if (!rawLevel) { + try { + const { session, models } = await loadThinkingCommandState(client, sessionKey); + return { + content: formatDirectiveOptions( + `Current thinking level: ${resolveCurrentThinkingLevel(session, models)}.`, + formatThinkingLevels(session?.modelProvider, session?.model), + ), + }; + } catch (err) { + return { content: `Failed to get thinking level: ${String(err)}` }; + } } - if (!valid.includes(level)) { - return { - content: `Invalid thinking level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, - }; + + const level = normalizeThinkLevel(rawLevel); + if (!level) { + try { + const session = await loadCurrentSession(client, sessionKey); + return { + content: `Unrecognized thinking level "${rawLevel}". Valid levels: ${formatThinkingLevels(session?.modelProvider, session?.model)}.`, + }; + } catch (err) { + return { content: `Failed to validate thinking level: ${String(err)}` }; + } } try { @@ -196,17 +216,25 @@ async function executeVerbose( sessionKey: string, args: string, ): Promise { - const valid = ["on", "off", "full"]; - const level = args.trim().toLowerCase(); + const rawLevel = args.trim(); + if (!rawLevel) { + try { + const session = await loadCurrentSession(client, sessionKey); + return { + content: formatDirectiveOptions( + `Current verbose level: ${normalizeVerboseLevel(session?.verboseLevel) ?? "off"}.`, + "on, full, off", + ), + }; + } catch (err) { + return { content: `Failed to get verbose level: ${String(err)}` }; + } + } + const level = normalizeVerboseLevel(rawLevel); if (!level) { return { - content: `Usage: \`/verbose <${valid.join("|")}>\``, - }; - } - if (!valid.includes(level)) { - return { - content: `Invalid verbose level \`${level}\`. Choose: ${valid.map((v) => `\`${v}\``).join(", ")}`, + content: `Unrecognized verbose level "${rawLevel}". Valid levels: off, on, full.`, }; } @@ -354,6 +382,7 @@ function resolveKillTargets( const currentAgentId = currentParsed?.agentId ?? (normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined); + const sessionIndex = buildSessionIndex(sessions); for (const session of sessions) { const key = session?.key?.trim(); if (!key || !isSubagentSessionKey(key)) { @@ -364,6 +393,7 @@ function resolveKillTargets( const belongsToCurrentSession = isWithinCurrentSessionSubtree( normalizedKey, normalizedCurrentSessionKey, + sessionIndex, currentAgentId, parsed?.agentId, ); @@ -384,19 +414,125 @@ function resolveKillTargets( function isWithinCurrentSessionSubtree( candidateSessionKey: string, currentSessionKey: string, + sessionIndex: Map, currentAgentId: string | undefined, candidateAgentId: string | undefined, ): boolean { if (!currentAgentId || candidateAgentId !== currentAgentId) { return false; } - if (!isSubagentSessionKey(currentSessionKey)) { - return true; + + const currentAliases = resolveEquivalentSessionKeys(currentSessionKey, currentAgentId); + const seen = new Set(); + let parentSessionKey = normalizeSessionKey(sessionIndex.get(candidateSessionKey)?.spawnedBy); + while (parentSessionKey && !seen.has(parentSessionKey)) { + if (currentAliases.has(parentSessionKey)) { + return true; + } + seen.add(parentSessionKey); + parentSessionKey = normalizeSessionKey(sessionIndex.get(parentSessionKey)?.spawnedBy); } - return ( - candidateSessionKey === currentSessionKey || - candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`) - ); + + // Older gateways may not include spawnedBy on session rows yet; keep prefix + // matching for nested subagent sessions as a compatibility fallback. + return isSubagentSessionKey(currentSessionKey) + ? candidateSessionKey.startsWith(`${currentSessionKey}:subagent:`) + : false; +} + +function buildSessionIndex(sessions: GatewaySessionRow[]): Map { + const index = new Map(); + for (const session of sessions) { + const normalizedKey = normalizeSessionKey(session?.key); + if (!normalizedKey) { + continue; + } + index.set(normalizedKey, session); + } + return index; +} + +function normalizeSessionKey(key?: string | null): string | undefined { + const normalized = key?.trim().toLowerCase(); + return normalized || undefined; +} + +function resolveEquivalentSessionKeys( + currentSessionKey: string, + currentAgentId: string | undefined, +): Set { + const keys = new Set([currentSessionKey]); + if (currentAgentId === DEFAULT_AGENT_ID) { + const canonicalDefaultMain = `agent:${DEFAULT_AGENT_ID}:main`; + if (currentSessionKey === DEFAULT_MAIN_KEY) { + keys.add(canonicalDefaultMain); + } else if (currentSessionKey === canonicalDefaultMain) { + keys.add(DEFAULT_MAIN_KEY); + } + } + return keys; +} + +function formatDirectiveOptions(text: string, options: string): string { + return `${text}\nOptions: ${options}.`; +} + +async function loadCurrentSession( + client: GatewayBrowserClient, + sessionKey: string, +): Promise { + const sessions = await client.request("sessions.list", {}); + const normalizedSessionKey = normalizeSessionKey(sessionKey); + const currentAgentId = + parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ?? + (normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined); + const aliases = normalizedSessionKey + ? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId) + : new Set(); + return sessions?.sessions?.find((session: GatewaySessionRow) => { + const key = normalizeSessionKey(session.key); + return key ? aliases.has(key) : false; + }); +} + +async function loadThinkingCommandState(client: GatewayBrowserClient, sessionKey: string) { + const [sessions, models] = await Promise.all([ + client.request("sessions.list", {}), + client.request<{ models: ModelCatalogEntry[] }>("models.list", {}), + ]); + const normalizedSessionKey = normalizeSessionKey(sessionKey); + const currentAgentId = + parseAgentSessionKey(normalizedSessionKey ?? "")?.agentId ?? + (normalizedSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined); + const aliases = normalizedSessionKey + ? resolveEquivalentSessionKeys(normalizedSessionKey, currentAgentId) + : new Set(); + return { + session: sessions?.sessions?.find((session: GatewaySessionRow) => { + const key = normalizeSessionKey(session.key); + return key ? aliases.has(key) : false; + }), + models: models?.models ?? [], + }; +} + +function resolveCurrentThinkingLevel( + session: GatewaySessionRow | undefined, + models: ModelCatalogEntry[], +): string { + const persisted = normalizeThinkLevel(session?.thinkingLevel); + if (persisted) { + return persisted; + } + if (!session?.modelProvider || !session.model) { + return "off"; + } + return resolveThinkingDefault({ + cfg: {} as OpenClawConfig, + provider: session.modelProvider, + model: session.model, + catalog: models, + }); } function fmtTokens(n: number): string { diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts new file mode 100644 index 00000000000..cb07109df9f --- /dev/null +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { parseSlashCommand } from "./slash-commands.ts"; + +describe("parseSlashCommand", () => { + it("parses commands with an optional colon separator", () => { + expect(parseSlashCommand("/think: high")).toMatchObject({ + command: { name: "think" }, + args: "high", + }); + expect(parseSlashCommand("/think:high")).toMatchObject({ + command: { name: "think" }, + args: "high", + }); + expect(parseSlashCommand("/help:")).toMatchObject({ + command: { name: "help" }, + args: "", + }); + }); + + it("still parses space-delimited commands", () => { + expect(parseSlashCommand("/verbose full")).toMatchObject({ + command: { name: "verbose" }, + args: "full", + }); + }); +}); diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index 7fc4fd6a7c0..27acd90025e 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -77,7 +77,7 @@ export const SLASH_COMMANDS: SlashCommandDef[] = [ icon: "brain", category: "model", executeLocal: true, - argOptions: ["off", "low", "medium", "high"], + argOptions: ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive"], }, { name: "verbose", @@ -192,7 +192,7 @@ export type ParsedSlashCommand = { /** * Parse a message as a slash command. Returns null if it doesn't match. - * Supports `/command` and `/command args...`. + * Supports `/command`, `/command args...`, and `/command: args...`. */ export function parseSlashCommand(text: string): ParsedSlashCommand | null { const trimmed = text.trim(); @@ -200,9 +200,14 @@ export function parseSlashCommand(text: string): ParsedSlashCommand | null { return null; } - const spaceIdx = trimmed.indexOf(" "); - const name = spaceIdx === -1 ? trimmed.slice(1) : trimmed.slice(1, spaceIdx); - const args = spaceIdx === -1 ? "" : trimmed.slice(spaceIdx + 1).trim(); + const body = trimmed.slice(1); + const firstSeparator = body.search(/[\s:]/u); + const name = firstSeparator === -1 ? body : body.slice(0, firstSeparator); + let remainder = firstSeparator === -1 ? "" : body.slice(firstSeparator).trimStart(); + if (remainder.startsWith(":")) { + remainder = remainder.slice(1).trimStart(); + } + const args = remainder.trim(); if (!name) { return null; diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index f87b498100a..7cde5adee61 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -395,6 +395,7 @@ export type AgentsFilesSetResult = { export type GatewaySessionRow = { key: string; + spawnedBy?: string; kind: "direct" | "group" | "global" | "unknown"; label?: string; displayName?: string;