diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ceabf408fb..8228a1985d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai - Agents/Kimi: preserve already-valid Anthropic-compatible tool call argument objects while still clearing cached repairs when later trailing junk exceeds the repair allowance. (#54491) Thanks @yuanaichi. - Docker/setup: force BuildKit for local image builds (including sandbox image builds) so `./docker-setup.sh` no longer fails on `RUN --mount=...` when hosts default to Docker's legacy builder. (#56681) Thanks @zhanghui-china. - Control UI/agents: auto-load agent workspace files on initial Files panel open, and populate overview model/workspace/fallbacks from effective runtime agent metadata so defaulted models no longer show as `Not set`. (#56637) Thanks @dxsx84. +- Control UI/slash commands: make `/steer` and `/redirect` work from the chat command palette with visible pending state for active-run `/steer`, correct redirected-run tracking, and a single canonical `/steer` entry in the command menu. (#54625) Thanks @fuller-stack-dev. ## 2026.3.28 diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index 49aff34a6e7..87e9b1d1aa2 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -13,10 +13,12 @@ vi.mock("./app-settings.ts", () => ({ let handleSendChat: typeof import("./app-chat.ts").handleSendChat; let refreshChatAvatar: typeof import("./app-chat.ts").refreshChatAvatar; +let clearPendingQueueItemsForRun: typeof import("./app-chat.ts").clearPendingQueueItemsForRun; async function loadChatHelpers(): Promise { vi.resetModules(); - ({ handleSendChat, refreshChatAvatar } = await import("./app-chat.ts")); + ({ handleSendChat, refreshChatAvatar, clearPendingQueueItemsForRun } = + await import("./app-chat.ts")); } function makeHost(overrides?: Partial): ChatHost { @@ -96,6 +98,7 @@ describe("handleSendChat", () => { afterEach(() => { vi.unstubAllGlobals(); + vi.doUnmock("./chat/slash-command-executor.ts"); }); it("keeps slash-command model changes in sync with the chat header cache", async () => { @@ -156,6 +159,64 @@ describe("handleSendChat", () => { }); expect(onSlashAction).toHaveBeenCalledWith("refresh-tools-effective"); }); + + it("shows a visible pending item for /steer on the active run", async () => { + vi.doMock("./chat/slash-command-executor.ts", async () => { + const actual = await vi.importActual( + "./chat/slash-command-executor.ts", + ); + return { + ...actual, + executeSlashCommand: vi.fn(async () => ({ + content: "Steered.", + pendingCurrentRun: true, + })), + }; + }); + await loadChatHelpers(); + + const host = makeHost({ + client: { request: vi.fn() } as unknown as ChatHost["client"], + chatRunId: "run-1", + chatMessage: "/steer tighten the plan", + }); + + await handleSendChat(host); + + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + text: "/steer tighten the plan", + pendingRunId: "run-1", + }), + ]); + }); + + it("removes pending steer indicators when the run finishes", async () => { + const host = makeHost({ + chatQueue: [ + { + id: "pending", + text: "/steer tighten the plan", + createdAt: 1, + pendingRunId: "run-1", + }, + { + id: "queued", + text: "follow up", + createdAt: 2, + }, + ], + }); + + clearPendingQueueItemsForRun(host, "run-1"); + + expect(host.chatQueue).toEqual([ + expect.objectContaining({ + id: "queued", + text: "follow up", + }), + ]); + }); }); afterAll(() => { diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 465504f0c1d..637ba709236 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -11,6 +11,7 @@ import { loadSessions } from "./controllers/sessions.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "./gateway.ts"; import { normalizeBasePath } from "./navigation.ts"; import type { ChatModelOverride, ModelCatalogEntry } from "./types.ts"; +import type { SessionsListResult } from "./types.ts"; import type { ChatAttachment, ChatQueueItem } from "./ui-types.ts"; import { generateUUID } from "./uuid.ts"; @@ -32,6 +33,7 @@ export type ChatHost = { chatModelOverrides: Record; chatModelsLoading: boolean; chatModelCatalog: ModelCatalogEntry[]; + sessionsResult?: SessionsListResult | null; updateComplete?: Promise; refreshSessionsAfterChat: Set; /** Callback for slash-command side effects that need app-level access. */ @@ -108,6 +110,22 @@ function enqueueChatMessage( ]; } +function enqueuePendingRunMessage(host: ChatHost, text: string, pendingRunId: string) { + const trimmed = text.trim(); + if (!trimmed) { + return; + } + host.chatQueue = [ + ...host.chatQueue, + { + id: generateUUID(), + text: trimmed, + createdAt: Date.now(), + pendingRunId, + }, + ]; +} + async function sendChatMessageNow( host: ChatHost, message: string, @@ -158,11 +176,12 @@ async function flushChatQueue(host: ChatHost) { if (!host.connected || isChatBusy(host)) { return; } - const [next, ...rest] = host.chatQueue; - if (!next) { + const nextIndex = host.chatQueue.findIndex((item) => !item.pendingRunId); + if (nextIndex < 0) { return; } - host.chatQueue = rest; + const next = host.chatQueue[nextIndex]; + host.chatQueue = host.chatQueue.filter((_, index) => index !== nextIndex); let ok = false; try { if (next.localCommandName) { @@ -189,6 +208,13 @@ export function removeQueuedMessage(host: ChatHost, id: string) { host.chatQueue = host.chatQueue.filter((item) => item.id !== id); } +export function clearPendingQueueItemsForRun(host: ChatHost, runId: string | undefined) { + if (!runId) { + return; + } + host.chatQueue = host.chatQueue.filter((item) => item.pendingRunId !== runId); +} + export async function handleSendChat( host: ChatHost, messageOverride?: string, @@ -260,7 +286,7 @@ export async function handleSendChat( } function shouldQueueLocalSlashCommand(name: string): boolean { - return !["stop", "focus", "export-session"].includes(name); + return !["stop", "focus", "export-session", "steer", "redirect"].includes(name); } // ── Slash Command Dispatch ── @@ -307,12 +333,23 @@ async function dispatchSlashCommand( const targetSessionKey = host.sessionKey; const result = await executeSlashCommand(host.client, targetSessionKey, name, args, { chatModelCatalog: host.chatModelCatalog, + sessionsResult: host.sessionsResult, }); if (result.content) { injectCommandResult(host, result.content); } + if (result.trackRunId) { + host.chatRunId = result.trackRunId; + host.chatStream = ""; + host.chatSending = false; + } + + if (result.pendingCurrentRun && host.chatRunId) { + enqueuePendingRunMessage(host, `/${name} ${args}`.trim(), host.chatRunId); + } + if (result.sessionPatch && "modelOverride" in result.sessionPatch) { host.chatModelOverrides = { ...host.chatModelOverrides, diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts index 77345baae5b..ec1285e22c9 100644 --- a/ui/src/ui/app-gateway.ts +++ b/ui/src/ui/app-gateway.ts @@ -2,7 +2,11 @@ import { GATEWAY_EVENT_UPDATE_AVAILABLE, type GatewayUpdateAvailableEventPayload, } from "../../../src/gateway/events.js"; -import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat.ts"; +import { + CHAT_SESSIONS_ACTIVE_MINUTES, + clearPendingQueueItemsForRun, + flushChatQueueForEvent, +} from "./app-chat.ts"; import type { EventLogEntry } from "./app-events.ts"; import { applySettings, @@ -289,6 +293,10 @@ function handleTerminalChatEvent( const toolHost = host as unknown as Parameters[0]; const hadToolEvents = toolHost.toolStreamOrder.length > 0; resetToolStream(toolHost); + clearPendingQueueItemsForRun( + host as unknown as Parameters[0], + payload?.runId, + ); void flushChatQueueForEvent(host as unknown as Parameters[0]); const runId = payload?.runId; if (runId && host.refreshSessionsAfterChat.has(runId)) { 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 3702b6c2245..ad934e8446c 100644 --- a/ui/src/ui/chat/slash-command-executor.node.test.ts +++ b/ui/src/ui/chat/slash-command-executor.node.test.ts @@ -6,7 +6,7 @@ import { OPENAI_GPT5_MINI_MODEL, } from "../chat-model.test-helpers.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { GatewaySessionRow } from "../types.ts"; +import type { GatewaySessionRow, SessionsListResult } from "../types.ts"; import { executeSlashCommand } from "./slash-command-executor.ts"; function row(key: string, overrides?: Partial): GatewaySessionRow { @@ -19,39 +19,26 @@ function row(key: string, overrides?: Partial): GatewaySessio }; } -function createKillRequest(params: { sessions: GatewaySessionRow[]; aborted?: boolean }) { - return vi.fn(async (method: string, _payload?: unknown) => { - if (method === "sessions.list") { - return { sessions: params.sessions }; - } - if (method === "chat.abort") { - return { ok: true, aborted: params.aborted ?? true }; - } - throw new Error(`unexpected method: ${method}`); - }); -} - -function expectAbortCalls(request: ReturnType, sessionKeys: string[]) { - expect(request).toHaveBeenNthCalledWith(1, "sessions.list", {}); - for (const [index, sessionKey] of sessionKeys.entries()) { - expect(request).toHaveBeenNthCalledWith(index + 2, "chat.abort", { - sessionKey, - }); - } -} - describe("executeSlashCommand /kill", () => { it("aborts every sub-agent session for /kill all", async () => { - const request = createKillRequest({ - sessions: [ - row("main"), - 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"), - ], + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + 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"), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); }); const result = await executeSlashCommand( @@ -62,20 +49,33 @@ describe("executeSlashCommand /kill", () => { ); expect(result.content).toBe("Aborted 3 sub-agent sessions."); - expectAbortCalls(request, [ - "agent:main:subagent:one", - "agent:main:subagent:parent", - "agent:main:subagent:parent:subagent:child", - ]); + 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", + }); }); it("aborts matching sub-agent sessions for /kill ", async () => { - const request = createKillRequest({ - sessions: [ - 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" }), - ], + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + 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" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); }); const result = await executeSlashCommand( @@ -86,7 +86,13 @@ describe("executeSlashCommand /kill", () => { ); expect(result.content).toBe("Aborted 2 matching sub-agent sessions for `main`."); - expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]); + 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:two", + }); }); it("does not exact-match a session key outside the current subagent subtree", async () => { @@ -123,12 +129,19 @@ describe("executeSlashCommand /kill", () => { }); it("returns a no-op summary when matching sessions have no active runs", async () => { - const request = createKillRequest({ - sessions: [ - row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), - row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), - ], - aborted: false, + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:subagent:one", { spawnedBy: "agent:main:main" }), + row("agent:main:subagent:two", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: false }; + } + throw new Error(`unexpected method: ${method}`); }); const result = await executeSlashCommand( @@ -139,17 +152,31 @@ describe("executeSlashCommand /kill", () => { ); expect(result.content).toBe("No active sub-agent runs to abort."); - expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]); + 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:two", + }); }); it("treats the legacy main session key as the default agent scope", async () => { - const request = createKillRequest({ - sessions: [ - row("main"), - 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" }), - ], + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("main"), + 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" }), + ], + }; + } + if (method === "chat.abort") { + return { ok: true, aborted: true }; + } + throw new Error(`unexpected method: ${method}`); }); const result = await executeSlashCommand( @@ -160,7 +187,13 @@ describe("executeSlashCommand /kill", () => { ); expect(result.content).toBe("Aborted 2 sub-agent sessions."); - expectAbortCalls(request, ["agent:main:subagent:one", "agent:main:subagent:two"]); + 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:two", + }); }); it("does not abort unrelated same-agent subagents from another root session", async () => { @@ -525,3 +558,392 @@ describe("executeSlashCommand directives", () => { }); }); }); + +describe("executeSlashCommand /steer (soft inject)", () => { + it("injects into the current session via chat.send with deliver: false", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("agent:main:main", { status: "running" })] }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-1", messageSeq: 2 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "try a different approach", + ); + + expect(result.content).toBe("Steered."); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "try a different approach", + deliver: false, + }), + ); + }); + + it("injects into a matching subagent when the first word resolves to one", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:researcher", { + spawnedBy: "agent:main:main", + status: "running", + }), + ], + }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-2", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "researcher try a different approach", + ); + + expect(result.content).toBe("Steered `researcher`."); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:subagent:researcher", + message: "try a different approach", + deliver: false, + }), + ); + }); + + it("uses cached sessions to avoid an extra sessions.list round trip", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "chat.send") { + return { status: "started", runId: "run-2", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "researcher try a different approach", + { + sessionsResult: { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:researcher", { + spawnedBy: "agent:main:main", + status: "running", + }), + ], + } as SessionsListResult, + }, + ); + + expect(result.content).toBe("Steered `researcher`."); + expect(request).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:subagent:researcher", + message: "try a different approach", + deliver: false, + }), + ); + }); + + it("matches an explicit full subagent session key", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:researcher", { + spawnedBy: "agent:main:main", + status: "running", + }), + ], + }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-2", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "agent:main:subagent:researcher try a different approach", + ); + + expect(result.content).toBe("Steered `agent:main:subagent:researcher`."); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:subagent:researcher", + message: "try a different approach", + deliver: false, + }), + ); + }); + + it("does not treat 'all' as a subagent wildcard", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("agent:main:main", { status: "running" })] }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-3", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "all good now", + ); + + expect(result.content).toBe("Steered."); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "all good now", + deliver: false, + }), + ); + }); + + it("does not match agent id as target — treats 'main' as message text", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { status: "running" }), + row("agent:main:subagent:researcher", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-4", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "main refine the plan", + ); + + expect(result.content).toBe("Steered."); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "main refine the plan", + deliver: false, + }), + ); + }); + + it("ignores ended subagent sessions when resolving target", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main", { status: "running" }), + row("agent:main:subagent:researcher", { + spawnedBy: "agent:main:main", + endedAt: Date.now() - 60_000, + }), + ], + }; + } + if (method === "chat.send") { + return { status: "started", runId: "run-5", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "researcher try again", + ); + + // "researcher" is ended, so the full string is sent to current session + expect(result.content).toBe("Steered."); + expect(request).toHaveBeenCalledWith( + "chat.send", + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "researcher try again", + deliver: false, + }), + ); + }); + + it("returns a no-op summary when the current session has no active run", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("agent:main:main", { status: "done", endedAt: Date.now() })] }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "try again", + ); + + expect(result.content).toBe("No active run. Use the chat input or `/redirect` instead."); + expect(request).toHaveBeenCalledWith("sessions.list", {}); + expect(request).not.toHaveBeenCalledWith("chat.send", expect.anything()); + }); + + it("returns usage when no message is provided", async () => { + const request = vi.fn(); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "", + ); + + expect(result.content).toBe("Usage: `/steer [id] `"); + expect(request).not.toHaveBeenCalled(); + }); + + it("returns error message on RPC failure", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("agent:main:main", { status: "running" })] }; + } + throw new Error("connection lost"); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "steer", + "try again", + ); + + expect(result.content).toBe("Failed to steer: Error: connection lost"); + }); +}); + +describe("executeSlashCommand /redirect (hard kill-and-restart)", () => { + it("calls sessions.steer to abort and restart the current session", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("agent:main:main")] }; + } + if (method === "sessions.steer") { + return { status: "started", runId: "run-1", messageSeq: 2, interruptedActiveRun: true }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "redirect", + "start over with a new plan", + ); + + expect(result.content).toBe("Redirected."); + expect(result.trackRunId).toBe("run-1"); + expect(request).toHaveBeenCalledWith("sessions.steer", { + key: "agent:main:main", + message: "start over with a new plan", + }); + }); + + it("redirects a matching subagent when the first word resolves to one", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { + sessions: [ + row("agent:main:main"), + row("agent:main:subagent:researcher", { spawnedBy: "agent:main:main" }), + ], + }; + } + if (method === "sessions.steer") { + return { status: "started", runId: "run-2", messageSeq: 1 }; + } + throw new Error(`unexpected method: ${method}`); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "redirect", + "researcher start over completely", + ); + + expect(result.content).toBe("Redirected `researcher`."); + // Subagent redirect must NOT set trackRunId — the run belongs to a + // different session so chat events would never clear chatRunId. + expect(result.trackRunId).toBeUndefined(); + expect(request).toHaveBeenCalledWith("sessions.steer", { + key: "agent:main:subagent:researcher", + message: "start over completely", + }); + }); + + it("returns usage when no message is provided", async () => { + const request = vi.fn(); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "redirect", + "", + ); + + expect(result.content).toBe("Usage: `/redirect [id] `"); + expect(request).not.toHaveBeenCalled(); + }); + + it("returns error message on RPC failure", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "sessions.list") { + return { sessions: [row("agent:main:main")] }; + } + throw new Error("connection lost"); + }); + + const result = await executeSlashCommand( + { request } as unknown as GatewayBrowserClient, + "agent:main:main", + "redirect", + "try again", + ); + + expect(result.content).toBe("Failed to redirect: Error: connection lost"); + }); +}); diff --git a/ui/src/ui/chat/slash-command-executor.ts b/ui/src/ui/chat/slash-command-executor.ts index bbbe15f5f4f..8ed9ee676cb 100644 --- a/ui/src/ui/chat/slash-command-executor.ts +++ b/ui/src/ui/chat/slash-command-executor.ts @@ -25,6 +25,7 @@ import type { SessionsListResult, SessionsPatchResult, } from "../types.ts"; +import { generateUUID } from "../uuid.ts"; import { SLASH_COMMANDS } from "./slash-commands.ts"; export type SlashCommandResult = { @@ -44,11 +45,16 @@ export type SlashCommandResult = { sessionPatch?: { modelOverride?: ChatModelOverride | null; }; + /** When set, the caller should track this as the active run (enables Abort, blocks concurrent sends). */ + trackRunId?: string; + /** When set, the caller should surface a visible pending item tied to the current run. */ + pendingCurrentRun?: boolean; }; export type SlashCommandContext = { chatModelCatalog?: ModelCatalogEntry[]; modelCatalog?: ModelCatalogEntry[]; + sessionsResult?: SessionsListResult | null; }; export async function executeSlashCommand( client: GatewayBrowserClient, @@ -88,6 +94,10 @@ export async function executeSlashCommand( return await executeAgents(client); case "kill": return await executeKill(client, sessionKey, args); + case "steer": + return await executeSteer(client, sessionKey, args, context); + case "redirect": + return await executeRedirect(client, sessionKey, args, context); default: return { content: `Unknown command: \`/${commandName}\`` }; } @@ -604,6 +614,177 @@ function resolveCurrentFastMode(session: GatewaySessionRow | undefined): "on" | return session?.fastMode === true ? "on" : "off"; } +/** + * Match a target name against active subagent sessions by key/label only. + * Unlike resolveKillTargets, this does NOT match by agent id (avoiding + * false positives for common words like "main") and filters to active + * sessions (no endedAt) so stale subagents are not targeted. + */ +function resolveSteerSubagent( + sessions: GatewaySessionRow[], + currentSessionKey: string, + target: string, +): string[] { + const normalizedTarget = target.trim().toLowerCase(); + if (!normalizedTarget) { + return []; + } + const normalizedCurrentSessionKey = currentSessionKey.trim().toLowerCase(); + const currentParsed = parseAgentSessionKey(normalizedCurrentSessionKey); + const currentAgentId = + currentParsed?.agentId ?? + (normalizedCurrentSessionKey === DEFAULT_MAIN_KEY ? DEFAULT_AGENT_ID : undefined); + const sessionIndex = buildSessionIndex(sessions); + + const keys = new Set(); + for (const session of sessions) { + const key = session?.key?.trim(); + if (!key || !isSubagentSessionKey(key)) { + continue; + } + // P1: skip ended sessions so stale subagents are not targeted + if (session.endedAt) { + continue; + } + const normalizedKey = key.toLowerCase(); + const parsed = parseAgentSessionKey(normalizedKey); + const belongsToCurrentSession = isWithinCurrentSessionSubtree( + normalizedKey, + normalizedCurrentSessionKey, + sessionIndex, + currentAgentId, + parsed?.agentId, + ); + if (!belongsToCurrentSession) { + continue; + } + // P2: match only on subagent key suffix or label, not agent id + const isMatch = + normalizedKey === normalizedTarget || + normalizedKey.endsWith(`:subagent:${normalizedTarget}`) || + normalizedKey === `subagent:${normalizedTarget}` || + (session.label ?? "").toLowerCase() === normalizedTarget; + if (isMatch) { + keys.add(key); + } + } + return [...keys]; +} + +/** + * Resolve an optional subagent target from the first word of args. + * Returns the resolved session key and the remaining message, or + * falls back to the current session key with the full args as message. + */ +async function resolveSteerTarget( + client: GatewayBrowserClient, + sessionKey: string, + args: string, + context: SlashCommandContext, +): Promise< + | { key: string; message: string; label?: string; sessions?: SessionsListResult } + | { error: string } +> { + const trimmed = args.trim(); + if (!trimmed) { + return { error: "empty" }; + } + const spaceIdx = trimmed.indexOf(" "); + if (spaceIdx > 0) { + const maybeTarget = trimmed.slice(0, spaceIdx); + const rest = trimmed.slice(spaceIdx + 1).trim(); + // Skip "all" — resolveKillTargets treats it as a wildcard, but steer/redirect + // target a single session, so "all good now" should not match subagents. + if (rest && maybeTarget.toLowerCase() !== "all") { + const sessions = + context.sessionsResult ?? (await client.request("sessions.list", {})); + const matched = resolveSteerSubagent(sessions?.sessions ?? [], sessionKey, maybeTarget); + if (matched.length === 1) { + return { key: matched[0], message: rest, label: maybeTarget, sessions }; + } + if (matched.length > 1) { + return { error: `Multiple sub-agents match \`${maybeTarget}\`. Be more specific.` }; + } + } + } + return { key: sessionKey, message: trimmed }; +} + +function isActiveSteerSession(session: GatewaySessionRow | undefined): boolean { + return session?.status === "running" && session.endedAt == null; +} + +/** Soft inject — queues a message into the active run via chat.send (deliver: false). */ +async function executeSteer( + client: GatewayBrowserClient, + sessionKey: string, + args: string, + context: SlashCommandContext, +): Promise { + try { + const resolved = await resolveSteerTarget(client, sessionKey, args, context); + if ("error" in resolved) { + return { + content: resolved.error === "empty" ? "Usage: `/steer [id] `" : resolved.error, + }; + } + const sessions = + resolved.sessions ?? (await client.request("sessions.list", {})); + const targetSession = resolveCurrentSession(sessions, resolved.key); + if (!isActiveSteerSession(targetSession)) { + return { + content: resolved.label + ? `No active run matched \`${resolved.label}\`. Use \`/redirect\` instead.` + : "No active run. Use the chat input or `/redirect` instead.", + }; + } + await client.request("chat.send", { + sessionKey: resolved.key, + message: resolved.message, + deliver: false, + idempotencyKey: generateUUID(), + }); + return { + content: resolved.label ? `Steered \`${resolved.label}\`.` : "Steered.", + pendingCurrentRun: resolved.key === sessionKey, + }; + } catch (err) { + return { content: `Failed to steer: ${String(err)}` }; + } +} + +/** Hard redirect — aborts the active run and restarts with a new message. */ +async function executeRedirect( + client: GatewayBrowserClient, + sessionKey: string, + args: string, + context: SlashCommandContext, +): Promise { + try { + const resolved = await resolveSteerTarget(client, sessionKey, args, context); + if ("error" in resolved) { + return { + content: resolved.error === "empty" ? "Usage: `/redirect [id] `" : resolved.error, + }; + } + const resp = await client.request<{ runId?: string }>("sessions.steer", { + key: resolved.key, + message: resolved.message, + }); + // Only track the run when redirecting the current session. Subagent + // redirects target a different sessionKey, so chat events for that run + // would never clear chatRunId on the current view. + const runId = typeof resp?.runId === "string" ? resp.runId : undefined; + const trackRunId = resolved.key === sessionKey ? runId : undefined; + return { + content: resolved.label ? `Redirected \`${resolved.label}\`.` : "Redirected.", + trackRunId, + }; + } catch (err) { + return { content: `Failed to redirect: ${String(err)}` }; + } +} + function fmtTokens(n: number): string { if (n >= 1_000_000) { return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`; diff --git a/ui/src/ui/chat/slash-commands.node.test.ts b/ui/src/ui/chat/slash-commands.node.test.ts index 0722d32b0e5..aa81587578b 100644 --- a/ui/src/ui/chat/slash-commands.node.test.ts +++ b/ui/src/ui/chat/slash-commands.node.test.ts @@ -82,6 +82,18 @@ describe("parseSlashCommand", () => { }); }); + it("keeps a single local /steer entry with the control-ui metadata", () => { + const steerEntries = SLASH_COMMANDS.filter((entry) => entry.name === "steer"); + expect(steerEntries).toHaveLength(1); + expect(steerEntries[0]).toMatchObject({ + key: "steer", + description: "Inject a message into the active run", + args: "[id] ", + aliases: expect.arrayContaining(["tell"]), + executeLocal: true, + }); + }); + it("keeps focus as a local slash command", () => { expect(parseSlashCommand("/focus")).toMatchObject({ command: { key: "focus", executeLocal: true }, diff --git a/ui/src/ui/chat/slash-commands.ts b/ui/src/ui/chat/slash-commands.ts index a31a2deeac3..3a48cf9a8b1 100644 --- a/ui/src/ui/chat/slash-commands.ts +++ b/ui/src/ui/chat/slash-commands.ts @@ -66,6 +66,8 @@ const LOCAL_COMMANDS = new Set([ "usage", "agents", "kill", + "steer", + "redirect", ]); const UI_ONLY_COMMANDS: SlashCommandDef[] = [ @@ -77,6 +79,15 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [ category: "session", executeLocal: true, }, + { + key: "redirect", + name: "redirect", + description: "Abort and restart with a new message", + args: "[id] ", + icon: "refresh", + category: "agents", + executeLocal: true, + }, ]; const CATEGORY_OVERRIDES: Partial> = { @@ -92,6 +103,7 @@ const CATEGORY_OVERRIDES: Partial> = { subagents: "agents", kill: "agents", steer: "agents", + redirect: "agents", session: "session", stop: "session", reset: "session", @@ -109,6 +121,14 @@ const CATEGORY_OVERRIDES: Partial> = { queue: "model", }; +const COMMAND_DESCRIPTION_OVERRIDES: Partial> = { + steer: "Inject a message into the active run", +}; + +const COMMAND_ARGS_OVERRIDES: Partial> = { + steer: "[id] ", +}; + function normalizeUiKey(command: ChatCommandDefinition): string { return command.key.replace(/[:.-]/g, "_"); } @@ -170,8 +190,8 @@ function toSlashCommand(command: ChatCommandDefinition): SlashCommandDef | null key: command.key, name, aliases: getSlashAliases(command).filter((alias) => alias !== name), - description: command.description, - args: formatArgs(command), + description: COMMAND_DESCRIPTION_OVERRIDES[command.key] ?? command.description, + args: COMMAND_ARGS_OVERRIDES[command.key] ?? formatArgs(command), icon: mapIcon(command), category: mapCategory(command), executeLocal: LOCAL_COMMANDS.has(command.key), diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 2cd1709d841..d41e3b6ea2c 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -12,6 +12,7 @@ export type ChatQueueItem = { refreshSessions?: boolean; localCommandArgs?: string; localCommandName?: string; + pendingRunId?: string; }; export const CRON_CHANNEL_LAST = "last"; diff --git a/vitest.config.ts b/vitest.config.ts index c3a6cd6fc62..a1282906bc2 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -88,6 +88,7 @@ export default defineConfig({ "ui/src/ui/controllers/sessions.test.ts", "ui/src/ui/views/sessions.test.ts", "ui/src/ui/app-gateway.sessions.node.test.ts", + "ui/src/ui/chat/slash-command-executor.node.test.ts", ], setupFiles: ["test/setup.ts"], exclude: [