From 04b391b7caf3c4b3455f2b044044f5821c81b5ac Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:18:38 +0000 Subject: [PATCH 01/20] refactor(chat): return slash command results MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _Generated with `xum` • Model: `openai:gpt-5.6-sol` • Thinking: `high`_ --- src/browser/features/ChatInput/index.tsx | 117 +- .../ChatInput/useCreationWorkspace.ts | 33 +- src/browser/utils/chatCommands.test.ts | 2280 ++++++----------- src/browser/utils/chatCommands.ts | 2066 +++++++-------- 4 files changed, 1817 insertions(+), 2679 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 310a318d695..e713f600d6c 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -78,7 +78,8 @@ import { import { prepareCompactionMessage, processSlashCommand, - type SlashCommandContext, + type CommandAction, + type SlashCommandEnv, } from "@/browser/utils/chatCommands"; import { addWorkflowRunCardMessageForRun, @@ -2474,62 +2475,100 @@ const ChatInputInner: React.FC = (props) => { // Prepare file parts for commands that need to send messages with attachments const commandFileParts = chatAttachmentsToFileParts(attachments, { validate: true }); const asyncCommandToken = ++asyncCommandTokenRef.current; - const commandContext: SlashCommandContext = { + const commandEnv: SlashCommandEnv = { api, variant, workspaceId: commandWorkspaceId, projectPath: commandProjectPath, rawInput: restoreInput, dynamicWorkflowsEnabled: dynamicWorkflowsExperimentEnabled, - openSettings: open, currentModel: workspaceSidebarState?.currentModel ?? null, sendMessageOptions: commandSendMessageOptions, - getInput: () => getDraft().text, - setInput, - setAttachments, - setSendingState: (increment: boolean) => setSendingCount((c) => c + (increment ? 1 : -1)), - setToast, - setPreferredModel, - setVimEnabled, - asyncCommandToken, - isAsyncCommandCurrent: (token, originWorkspaceId) => { + resetContext: variant === "workspace" ? props.onResetContext : undefined, + truncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined, + editMessageId: editingMessageForUi?.id, + reviews: reviewsData, + attachments, + fileParts: commandFileParts.length > 0 ? commandFileParts : undefined, + attachedReviewIds: reviewIdsForCheck, + isCurrent: () => { const scope = asyncCommandScopeRef.current; return ( - token === asyncCommandTokenRef.current && + asyncCommandToken === asyncCommandTokenRef.current && scope.variant === "workspace" && - scope.workspaceId === originWorkspaceId + scope.workspaceId === commandWorkspaceId ); }, - onResetContext: variant === "workspace" ? props.onResetContext : undefined, - onTruncateHistory: variant === "workspace" ? props.onTruncateHistory : undefined, - resetInputHeight: () => { - if (inputRef.current) { - inputRef.current.style.height = ""; + }; + + // Command actions stop at the caller's UI boundary; creation mode intentionally has its own applier. + const applyCommandActions = (actions: CommandAction[]) => { + for (const action of actions) { + switch (action.type) { + case "clear-input": + setInput(""); + break; + case "reset-input-height": + if (inputRef.current) inputRef.current.style.height = ""; + break; + case "show-toast": + setToast(action.toast); + break; + case "set-preferred-model": + setPreferredModel(action.model); + break; + case "toggle-vim": + setVimEnabled((enabled) => !enabled); + break; + case "set-sending": + setSendingCount((count) => count + (action.sending ? 1 : -1)); + break; + case "clear-attachments": + setAttachments([]); + break; + case "detach-reviews": + if (variant === "workspace") props.onDetachAllReviews?.(); + break; + case "check-reviews": + if (variant === "workspace" && action.reviewIds.length > 0) { + props.onCheckReviews?.(action.reviewIds); + } + break; + case "message-sent": + if (variant === "workspace") props.onMessageSent?.(action.dispatchMode); + break; + case "cancel-edit": + commandOnCancelEdit?.(); + break; } - }, - editMessageId: editingMessageForUi?.id, - onCancelEdit: commandOnCancelEdit, - reviews: reviewsData, - attachments, - fileParts: commandFileParts.length > 0 ? commandFileParts : undefined, - onMessageSent: variant === "workspace" ? props.onMessageSent : undefined, - onDetachAllReviews: variant === "workspace" ? props.onDetachAllReviews : undefined, - onCheckReviews: variant === "workspace" ? props.onCheckReviews : undefined, - attachedReviewIds: reviewIdsForCheck, + } }; - const result = await processSlashCommand(parsed, commandContext); + let result = await processSlashCommand(parsed, commandEnv); + while (result.kind === "phase") { + applyCommandActions(result.actions); + result = await result.continue(); + } + applyCommandActions(result.actions); + if (result.backgroundTask) { + void result.backgroundTask().then(applyCommandActions); + } - if (!result.clearInput) { - setInput(restoreInput); - } else { - setDraftReviews(null); - if (variant === "workspace" && parsed.type === "compact") { - if (reviewIdsForCheck.length > 0) { - props.onCheckReviews?.(reviewIdsForCheck); + switch (result.inputDisposition) { + case "consume": + if (getDraft().text === restoreInput) setInput(""); + setDraftReviews(null); + break; + case "restore": + setInput(restoreInput); + break; + case "restore-if-empty": + if (getDraft().text.trim().length === 0) { + setInput(restoreInput); + } else { + setDraftReviews(null); } - props.onMessageSent?.(dispatchMode); - } + break; } return true; diff --git a/src/browser/features/ChatInput/useCreationWorkspace.ts b/src/browser/features/ChatInput/useCreationWorkspace.ts index 80d9e475830..05c198db90b 100644 --- a/src/browser/features/ChatInput/useCreationWorkspace.ts +++ b/src/browser/features/ChatInput/useCreationWorkspace.ts @@ -66,7 +66,11 @@ import { } from "@/browser/features/ChatInput/draftAttachmentsStorage"; import type { MuxMessageMetadata } from "@/common/types/message"; import type { ParsedCommand } from "@/browser/utils/slashCommands/types"; -import { processSlashCommand, type SlashCommandContext } from "@/browser/utils/chatCommands"; +import { + processSlashCommand, + type CommandAction, + type SlashCommandEnv, +} from "@/browser/utils/chatCommands"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; import { useWorkspaceName, @@ -741,7 +745,7 @@ export function useCreationWorkspace({ if (initialSlashCommand) { await initialAiSettingsPersisted; - const commandContext: SlashCommandContext = { + const commandEnv: SlashCommandEnv = { api, workspaceId: metadata.id, variant: "workspace", @@ -749,18 +753,25 @@ export function useCreationWorkspace({ rawInput: messageText, dynamicWorkflowsEnabled, sendMessageOptions, - setInput: () => undefined, - setAttachments: () => undefined, - setSendingState: () => undefined, - setToast, - setPreferredModel: () => undefined, - setVimEnabled: () => undefined, - resetInputHeight: () => undefined, }; - const commandResult = await processSlashCommand(initialSlashCommand, commandContext); + // Creation owns only toast state; composer actions intentionally remain local to ChatInput. + const applyCommandActions = (actions: CommandAction[]) => { + for (const action of actions) { + if (action.type === "show-toast") setToast(action.toast); + } + }; + let commandResult = await processSlashCommand(initialSlashCommand, commandEnv); + while (commandResult.kind === "phase") { + applyCommandActions(commandResult.actions); + commandResult = await commandResult.continue(); + } + applyCommandActions(commandResult.actions); + if (commandResult.backgroundTask) { + void commandResult.backgroundTask().then(applyCommandActions); + } setIsSending(false); - if (!commandResult.clearInput) { + if (commandResult.inputDisposition !== "consume") { workspaceStore.clearPendingInitialSendState(metadata.id); return { success: false }; } diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index ed157332b1f..ecfb11649e4 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, test, beforeEach, mock, spyOn } from "bun:test"; +import { describe, expect, test, beforeEach, mock } from "bun:test"; import type { SendMessageOptions } from "@/common/orpc/types"; import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments"; import { parseRuntimeString, prepareCompactionMessage, - handlePlanShowCommand, - handlePlanOpenCommand, - handleCompactCommand, WORKFLOW_FREEFORM_ARGS_ERROR_MESSAGE, processSlashCommand, + type CommandAction, + type CommandInputDisposition, + type CommandResult, + type SlashCommandEnv, } from "./chatCommands"; import { parseCommand } from "./slashCommands/parser"; -import type { CommandHandlerContext, SlashCommandContext } from "./chatCommands"; import type { ReviewNoteData } from "@/common/types/review"; import { useWorkspaceStoreRaw, workspaceStore } from "@/browser/stores/WorkspaceStore"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -112,787 +112,484 @@ function ensureWindowDispatchEvent(): void { Object.defineProperty(window, "dispatchEvent", { value: mock(() => true), configurable: true }); } -function createSlashCommandContext( - overrides: Partial & Pick -): SlashCommandContext { +const sendMessageOptions: SendMessageOptions = { + model: "anthropic:claude-sonnet-4-6", + thinkingLevel: "off", + toolPolicy: [], + agentId: "exec", +}; + +function createEnv(overrides: Partial = {}): SlashCommandEnv { return { + api: null, workspaceId: "test-ws", variant: "workspace", projectPath: "/tmp/project", - setPreferredModel: mock(() => undefined), - setVimEnabled: mock((cb: (prev: boolean) => boolean) => cb(false)), - resetInputHeight: mock(() => undefined), - onTruncateHistory: mock(() => Promise.resolve(undefined)), - sendMessageOptions: { - model: "anthropic:claude-sonnet-4-6", - thinkingLevel: "off", - toolPolicy: [], - agentId: "exec", - }, - setInput: mock(() => undefined), - setToast: mock(() => undefined), - setAttachments: mock(() => undefined), - setSendingState: mock(() => undefined), + sendMessageOptions, ...overrides, }; } -function createGoalCommandContext(api: SlashCommandContext["api"]): SlashCommandContext { - return createSlashCommandContext({ - api, - workspaceId: "goal-ws", - onMessageSent: mock(() => undefined), - onCheckReviews: mock(() => undefined), - attachedReviewIds: [], - openSettings: mock(() => undefined), - }); +type CompleteCommandResult = Extract; + +async function finishCommand(result: CommandResult): Promise<{ + batches: CommandAction[][]; + result: CompleteCommandResult; +}> { + const batches: CommandAction[][] = []; + while (result.kind === "phase") { + batches.push(result.actions); + result = await result.continue(); + } + batches.push(result.actions); + return { batches, result }; +} + +function expectDisposition(result: CompleteCommandResult, expected: CommandInputDisposition): void { + expect(result.inputDisposition).toBe(expected); +} + +function expectToast( + actions: CommandAction[], + expected: { type: "success" | "error"; message: string; title?: string } +): void { + const action = actions.find( + (candidate): candidate is Extract => + candidate.type === "show-toast" + ); + expect(action?.toast.type).toBe(expected.type); + expect(action?.toast.message).toBe(expected.message); + if (expected.title) expect(action?.toast.title).toBe(expected.title); } -describe("processSlashCommand - workflow", () => { - test("rejects workflow execution when dynamic workflows are disabled", async () => { +function setHeartbeatExperiment(enabled: boolean): void { + localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS), + JSON.stringify(enabled) + ); +} + +const completedWorkflowRun = { + id: "wfr_123", + workspaceId: "test-ws", + workflow: { + name: "skill://deep-research/workflow.js", + description: "Deep research", + scope: "built-in", + executable: true, + }, + source: "export default function workflow() { return null; }", + sourceHash: "sha256:test", + args: { input: "mux" }, + status: "completed" as const, + createdAt: "2026-05-29T00:00:00.000Z", + updatedAt: "2026-05-29T00:00:01.000Z", + events: [], + steps: [], +}; + +describe("processSlashCommand workflow results", () => { + test("returns validation failures without starting a workflow", async () => { const start = mock(() => Promise.resolve({ runId: "wfr_123", status: "running", result: null }) ); - const context = createSlashCommandContext({ - api: { - workflows: { start }, - } as unknown as SlashCommandContext["api"], - dynamicWorkflowsEnabled: false, - }); + const disabled = await processSlashCommand( + { type: "workflow-run", scriptPath: "skill://deep-research/workflow.js", argsText: "{}" }, + createEnv({ + api: { workflows: { start } } as unknown as SlashCommandEnv["api"], + dynamicWorkflowsEnabled: false, + }) + ); + expect(disabled.kind).toBe("complete"); + if (disabled.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(disabled, "restore"); + expectToast(disabled.actions, { type: "error", message: "Dynamic workflows are disabled" }); + expect(start).not.toHaveBeenCalled(); - const result = await processSlashCommand( + const invalidArgs = await processSlashCommand( { type: "workflow-run", scriptPath: "skill://deep-research/workflow.js", - argsText: '{"input":"mux"}', + argsText: "freeform arguments", }, - context - ); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(start).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ type: "error", message: "Dynamic workflows are disabled" }) - ); - }); - - test("rejects freeform workflow slash arguments", async () => { - const start = mock(() => - Promise.resolve({ runId: "wfr_123", status: "running", result: null }) + createEnv({ + api: { workflows: { start } } as unknown as SlashCommandEnv["api"], + dynamicWorkflowsEnabled: true, + }) ); - const context = createSlashCommandContext({ - api: { - workflows: { start }, - } as unknown as SlashCommandContext["api"], - dynamicWorkflowsEnabled: true, + expect(invalidArgs.kind).toBe("complete"); + if (invalidArgs.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(invalidArgs, "restore"); + expectToast(invalidArgs.actions, { + type: "error", + message: WORKFLOW_FREEFORM_ARGS_ERROR_MESSAGE, }); - - const result = await processSlashCommand( - { type: "workflow-run", scriptPath: "skill://deep-research/workflow.js", argsText: "mux" }, - context - ); - - expect(result).toEqual({ clearInput: false, toastShown: true }); expect(start).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ type: "error", message: WORKFLOW_FREEFORM_ARGS_ERROR_MESSAGE }) - ); }); - test.each([ - ['"hello"', "hello"], - ["123", 123], - ] as const)( - "passes JSON scalar workflow slash arguments from %p", - async (argsText, expectedArgs) => { - const start = mock(() => - Promise.resolve({ - runId: "wfr_123", - status: "running", - result: null, - invocationMessagePersisted: true, - }) - ); - const context = createSlashCommandContext({ - api: { - workflows: { start }, - } as unknown as SlashCommandContext["api"], - rawInput: `/workflow ./echo.js ${argsText}`, - dynamicWorkflowsEnabled: true, - }); - - const result = await processSlashCommand( - { type: "workflow-run", scriptPath: "./echo.js", argsText }, - context - ); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(start).toHaveBeenCalledWith( - expect.objectContaining({ - args: expectedArgs, - rawCommand: `/workflow ./echo.js ${argsText}`, - }) - ); - } - ); - - test("sends completed workflow slash output to the main agent as hidden context", async () => { + test("keeps each workflow await behind a lazy phase", async () => { const workflowResult = { reportMarkdown: "# Research\n\nFindings", structuredOutput: { confidence: "high" }, }; const start = mock(() => - Promise.resolve({ - runId: "wfr_123", - status: "completed", - result: workflowResult, - }) + Promise.resolve({ runId: "wfr_123", status: "completed" as const, result: workflowResult }) ); - const getRun = mock(() => - Promise.resolve({ - id: "wfr_123", - workspaceId: "test-ws", - workflow: { - name: "skill://deep-research/workflow.js", - description: "Deep research", - scope: "built-in", - executable: true, - }, - source: "export default function workflow() { return null; }", - sourceHash: "sha256:test", - args: { input: "mux" }, - status: "completed", - createdAt: "2026-05-29T00:00:00.000Z", - updatedAt: "2026-05-29T00:00:01.000Z", - events: [ - { - sequence: 1, - type: "result", - at: "2026-05-29T00:00:01.000Z", - result: workflowResult, - }, - ], - steps: [], - }) - ); - interface SentWorkflowMessage { - message: string; - options: { muxMetadata?: { type?: string; rawCommand?: string; commandPrefix?: string } }; - } - const sentMessages: SentWorkflowMessage[] = []; - const sendMessage = mock((input: SentWorkflowMessage) => { + const getRun = mock(() => Promise.resolve(completedWorkflowRun)); + const sentMessages: Array<{ message: string; options: { muxMetadata?: { type?: string } } }> = + []; + const sendMessage = mock((input: (typeof sentMessages)[number]) => { sentMessages.push(input); return Promise.resolve({ success: true }); }); - const onMessageSent = mock(() => undefined); - const context = createSlashCommandContext({ - api: { - workflows: { start, getRun }, - workspace: { sendMessage }, - } as unknown as SlashCommandContext["api"], - rawInput: '/deep-research {"input":"mux"}', - dynamicWorkflowsEnabled: true, - onMessageSent, - }); - - const result = await processSlashCommand( + const initial = await processSlashCommand( { type: "workflow-run", scriptPath: "skill://deep-research/workflow.js", argsText: '{"input":"mux"}', }, - context - ); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(start).toHaveBeenCalledWith({ - workspaceId: "test-ws", - scriptPath: "skill://deep-research/workflow.js", - runInBackground: true, - args: { input: "mux" }, - rawCommand: '/deep-research {"input":"mux"}', - continuationOptions: context.sendMessageOptions, - }); - expect(getRun).toHaveBeenCalledWith({ workspaceId: "test-ws", runId: "wfr_123" }); - expect(sendMessage).toHaveBeenCalledTimes(1); - const sendInput = sentMessages[0]; - expect(sendInput).toBeDefined(); - expect(sendInput.message).toContain('/deep-research {"input":"mux"}'); - expect(sendInput.message).toContain(""); - expect(sendInput.message).toContain("Findings"); - expect(sendInput.message).toContain("confidence"); - expect(sendInput.options.muxMetadata?.type).toBe("workflow-result"); - expect(sendInput.options.muxMetadata?.rawCommand).toBe('/deep-research {"input":"mux"}'); - expect(sendInput.options.muxMetadata?.commandPrefix).toBe("/deep-research"); - expect(context.setSendingState).toHaveBeenNthCalledWith(1, true); - expect(context.setSendingState).toHaveBeenNthCalledWith(2, false); - expect(context.setSendingState).toHaveBeenNthCalledWith(3, true); - expect(context.setSendingState).toHaveBeenNthCalledWith(4, false); - expect(onMessageSent).toHaveBeenCalledWith("tool-end"); - }); - - test("leaves slash workflow continuation to backend when invocation is persisted", async () => { - const start = mock(() => - Promise.resolve({ - runId: "wfr_123", - status: "running", - result: null, - invocationMessagePersisted: true, + createEnv({ + api: { + workflows: { start, getRun }, + workspace: { sendMessage }, + } as unknown as SlashCommandEnv["api"], + rawInput: '/deep-research {"input":"mux"}', + dynamicWorkflowsEnabled: true, }) ); - const getRun = mock(() => Promise.resolve(null)); - const sendMessage = mock(() => Promise.resolve({ success: true })); - const context = createSlashCommandContext({ - api: { - workflows: { start, getRun }, - workspace: { sendMessage }, - } as unknown as SlashCommandContext["api"], - rawInput: '/deep-research {"input":"mux"}', - dynamicWorkflowsEnabled: true, - }); - - const result = await processSlashCommand( - { - type: "workflow-run", - scriptPath: "skill://deep-research/workflow.js", - argsText: '{"input":"mux"}', - }, - context - ); + expect(initial.kind).toBe("phase"); + if (initial.kind !== "phase") throw new Error("expected phase result"); + expect(initial.actions).toEqual([ + { type: "clear-input" }, + { type: "set-sending", sending: true }, + ]); + expect(start).not.toHaveBeenCalled(); - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(start).toHaveBeenCalledWith({ - workspaceId: "test-ws", - scriptPath: "skill://deep-research/workflow.js", - runInBackground: true, - args: { input: "mux" }, - rawCommand: '/deep-research {"input":"mux"}', - continuationOptions: context.sendMessageOptions, - }); + const afterStart = await initial.continue(); + expect(afterStart.kind).toBe("phase"); + if (afterStart.kind !== "phase") throw new Error("expected phase result"); + expect(afterStart.actions).toEqual([{ type: "set-sending", sending: false }]); expect(getRun).not.toHaveBeenCalled(); - expect(sendMessage).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "success", - message: "Workflow skill://deep-research/workflow.js started", - }) - ); - expect(context.setSendingState).toHaveBeenNthCalledWith(1, true); - expect(context.setSendingState).toHaveBeenNthCalledWith(2, false); - }); - - test("does not send terminal workflow results for superseded slash commands", async () => { - const workflowResult = { reportMarkdown: "done" }; - const start = mock(() => - Promise.resolve({ - runId: "wfr_completed", - status: "completed", - result: workflowResult, - }) - ); - const getRun = mock(() => - Promise.resolve({ - id: "wfr_completed", - workspaceId: "test-ws", - workflow: { - name: "skill://deep-research/workflow.js", - description: "Deep research", - scope: "built-in", - executable: true, - }, - source: "export default function workflow() { return null; }", - sourceHash: "sha256:test", - args: { input: "mux" }, - status: "completed", - createdAt: "2026-05-29T00:00:00.000Z", - updatedAt: "2026-05-29T00:00:01.000Z", - events: [ - { sequence: 1, type: "result", at: "2026-05-29T00:00:01.000Z", result: workflowResult }, - ], - steps: [], - }) - ); - const sendMessage = mock(() => Promise.resolve({ success: true })); - const context = createSlashCommandContext({ - api: { - workflows: { start, getRun }, - workspace: { sendMessage }, - } as unknown as SlashCommandContext["api"], - rawInput: '/deep-research {"input":"mux"}', - dynamicWorkflowsEnabled: true, - asyncCommandToken: 1, - isAsyncCommandCurrent: mock(() => false), - }); - const result = await processSlashCommand( - { - type: "workflow-run", - scriptPath: "skill://deep-research/workflow.js", - argsText: '{"input":"mux"}', - }, - context - ); - - expect(result).toEqual({ clearInput: true, toastShown: false }); + const afterPoll = await afterStart.continue(); + expect(afterPoll.kind).toBe("phase"); + if (afterPoll.kind !== "phase") throw new Error("expected phase result"); + expect(afterPoll.actions).toEqual([{ type: "set-sending", sending: true }]); expect(sendMessage).not.toHaveBeenCalled(); - expect(context.setToast).not.toHaveBeenCalled(); + + const complete = await afterPoll.continue(); + expect(complete.kind).toBe("complete"); + if (complete.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(complete, "consume"); + expect(complete.actions.slice(0, 2)).toEqual([ + { type: "set-sending", sending: false }, + { type: "message-sent", dispatchMode: "tool-end" }, + ]); + expect(complete.actions[2]).toMatchObject({ type: "show-toast", toast: { type: "success" } }); + expect(sentMessages[0]?.message).toContain(""); + expect(sentMessages[0]?.message).toContain("Findings"); + expect(sentMessages[0]?.options.muxMetadata?.type).toBe("workflow-result"); }); - test("does not restore a superseded workflow slash command", async () => { + test("completes after start when the invocation message is already persisted", async () => { const start = mock(() => Promise.resolve({ - runId: "wfr_running", - status: "running", + runId: "wfr_123", + status: "running" as const, result: null, + invocationMessagePersisted: true, }) ); - const getRun = mock(() => - Promise.resolve({ - id: "wfr_running", - workspaceId: "test-ws", - workflow: { - name: "skill://deep-research/workflow.js", - description: "Deep research", - scope: "built-in", - executable: true, - }, - source: "export default function workflow() { return null; }", - sourceHash: "sha256:test", - args: { input: "mux" }, - status: "running", - createdAt: "2026-05-29T00:00:00.000Z", - updatedAt: "2026-05-29T00:00:01.000Z", - events: [], - steps: [], + const initial = await processSlashCommand( + { type: "workflow-run", scriptPath: "skill://flow/workflow.js", argsText: "{}" }, + createEnv({ + api: { workflows: { start } } as unknown as SlashCommandEnv["api"], + dynamicWorkflowsEnabled: true, }) ); - const sendMessage = mock(() => Promise.resolve({ success: true })); - const context = createSlashCommandContext({ - api: { - workflows: { start, getRun }, - workspace: { sendMessage }, - } as unknown as SlashCommandContext["api"], - rawInput: '/deep-research {"input":"mux"}', - dynamicWorkflowsEnabled: true, - asyncCommandToken: 1, - isAsyncCommandCurrent: mock(() => false), + const settled = await finishCommand(initial); + expectDisposition(settled.result, "consume"); + expect(settled.batches).toHaveLength(2); + expectToast(settled.result.actions, { + type: "success", + message: "Workflow skill://flow/workflow.js started", }); - - const result = await processSlashCommand( - { - type: "workflow-run", - scriptPath: "skill://deep-research/workflow.js", - argsText: '{"input":"mux"}', - }, - context - ); - - expect(result).toEqual({ clearInput: true, toastShown: false }); - expect(sendMessage).not.toHaveBeenCalled(); - expect(context.setToast).not.toHaveBeenCalled(); }); - test("does not restore failed workflow slash commands over newer drafts", async () => { + test("returns consume without continuation actions when polling is superseded", async () => { const start = mock(() => - Promise.resolve({ - runId: "wfr_failed_send", - status: "completed", - result: { reportMarkdown: "done" }, - }) + Promise.resolve({ runId: "wfr_123", status: "completed" as const, result: null }) ); - const getRun = mock(() => - Promise.resolve({ - id: "wfr_failed_send", - workspaceId: "test-ws", - workflow: { - name: "skill://deep-research/workflow.js", - description: "Deep research", - scope: "built-in", - executable: true, - }, - source: "export default function workflow() { return null; }", - sourceHash: "sha256:test", - args: { input: "mux" }, - status: "completed", - createdAt: "2026-05-29T00:00:00.000Z", - updatedAt: "2026-05-29T00:00:01.000Z", - events: [], - steps: [], + const getRun = mock(() => Promise.resolve(completedWorkflowRun)); + const initial = await processSlashCommand( + { type: "workflow-run", scriptPath: "skill://flow/workflow.js", argsText: "{}" }, + createEnv({ + api: { + workflows: { start, getRun }, + workspace: { sendMessage: mock(() => Promise.resolve({ success: true })) }, + } as unknown as SlashCommandEnv["api"], + dynamicWorkflowsEnabled: true, + isCurrent: () => false, }) ); - const sendMessage = mock(() => Promise.resolve({ success: false })); - const context = createSlashCommandContext({ - api: { - workflows: { start, getRun }, - workspace: { sendMessage }, - } as unknown as SlashCommandContext["api"], - rawInput: '/deep-research {"input":"mux"}', - dynamicWorkflowsEnabled: true, - getInput: mock(() => "newer draft"), - }); - - const result = await processSlashCommand( - { - type: "workflow-run", - scriptPath: "skill://deep-research/workflow.js", - argsText: '{"input":"mux"}', - }, - context - ); + const settled = await finishCommand(initial); + expectDisposition(settled.result, "consume"); + expect(settled.result.actions).toEqual([]); + }); - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "Failed to send workflow result to the agent", + test("uses restore-if-empty for workflow failures", async () => { + const initial = await processSlashCommand( + { type: "workflow-run", scriptPath: "skill://flow/workflow.js", argsText: "{}" }, + createEnv({ + api: { + workflows: { start: mock(() => Promise.reject(new Error("workflow failed"))) }, + } as unknown as SlashCommandEnv["api"], + dynamicWorkflowsEnabled: true, }) ); + const settled = await finishCommand(initial); + expectDisposition(settled.result, "restore-if-empty"); + expectToast(settled.result.actions, { type: "error", message: "workflow failed" }); + expect(settled.result.actions[0]).toEqual({ type: "set-sending", sending: false }); }); - test("does not continue the agent after an interrupted workflow slash run", async () => { + test("does not send an interrupted workflow result to the agent", async () => { + const sendMessage = mock(() => Promise.resolve({ success: true })); const start = mock(() => - Promise.resolve({ - runId: "wfr_interrupted", - status: "interrupted", - result: null, - }) + Promise.resolve({ runId: "wfr_123", status: "interrupted" as const, result: null }) ); const getRun = mock(() => - Promise.resolve({ - id: "wfr_interrupted", - workspaceId: "test-ws", - workflow: { - name: "skill://deep-research/workflow.js", - description: "Deep research", - scope: "built-in", - executable: true, - }, - source: "export default function workflow() { return null; }", - sourceHash: "sha256:test", - args: { input: "mux" }, - status: "interrupted", - createdAt: "2026-05-29T00:00:00.000Z", - updatedAt: "2026-05-29T00:00:01.000Z", - events: [], - steps: [], - }) + Promise.resolve({ ...completedWorkflowRun, status: "interrupted" as const }) ); - const sendMessage = mock(() => Promise.resolve({ success: true })); - const onMessageSent = mock(() => undefined); - const context = createSlashCommandContext({ - api: { - workflows: { start, getRun }, - workspace: { sendMessage }, - } as unknown as SlashCommandContext["api"], - rawInput: '/deep-research {"input":"mux"}', - dynamicWorkflowsEnabled: true, - onMessageSent, - }); - - const result = await processSlashCommand( - { - type: "workflow-run", - scriptPath: "skill://deep-research/workflow.js", - argsText: '{"input":"mux"}', - }, - context + const settled = await finishCommand( + await processSlashCommand( + { type: "workflow-run", scriptPath: "skill://flow/workflow.js", argsText: "{}" }, + createEnv({ + api: { + workflows: { start, getRun }, + workspace: { sendMessage }, + } as unknown as SlashCommandEnv["api"], + dynamicWorkflowsEnabled: true, + }) + ) ); - - expect(result).toEqual({ clearInput: true, toastShown: true }); + expectDisposition(settled.result, "consume"); + expectToast(settled.result.actions, { + type: "success", + message: "Workflow skill://flow/workflow.js interrupted", + }); expect(sendMessage).not.toHaveBeenCalled(); - expect(onMessageSent).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "success", - message: "Workflow skill://deep-research/workflow.js interrupted", - }) - ); }); }); -describe("processSlashCommand - clear", () => { - function createClearContext(overrides: Partial = {}): SlashCommandContext { - return createSlashCommandContext({ - api: null, - onDetachAllReviews: mock(() => undefined), - onResetContext: mock(() => Promise.resolve("reset" as const)), - ...overrides, - }); - } - - test("hard clear truncates history", async () => { - const context = createClearContext(); - - const result = await processSlashCommand({ type: "clear", mode: "hard" }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(context.onTruncateHistory).toHaveBeenCalledWith(1.0); - expect(context.setAttachments).toHaveBeenCalledWith([]); - expect(context.onDetachAllReviews).toHaveBeenCalled(); - expect(context.onResetContext).not.toHaveBeenCalled(); - }); - - test("soft clear resets context without truncating history", async () => { - const context = createClearContext(); - - const result = await processSlashCommand({ type: "clear", mode: "soft" }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(context.onResetContext).toHaveBeenCalled(); - expect(context.setAttachments).toHaveBeenCalledWith([]); - expect(context.onDetachAllReviews).toHaveBeenCalled(); - expect(context.onTruncateHistory).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ message: "Context reset; history preserved", type: "success" }) +describe("processSlashCommand clear results", () => { + test("hard clear returns actions around truncation", async () => { + const truncateHistory = mock(() => Promise.resolve()); + const initial = await processSlashCommand( + { type: "clear", mode: "hard" }, + createEnv({ truncateHistory }) ); + expect(initial.kind).toBe("phase"); + if (initial.kind !== "phase") throw new Error("expected phase result"); + expect(initial.actions).toEqual([{ type: "clear-input" }, { type: "reset-input-height" }]); + expect(truncateHistory).not.toHaveBeenCalled(); + const complete = await initial.continue(); + expect(complete.kind).toBe("complete"); + if (complete.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(complete, "consume"); + expect(complete.actions.slice(0, 2)).toEqual([ + { type: "clear-attachments" }, + { type: "detach-reviews" }, + ]); + expect(truncateHistory).toHaveBeenCalledWith(1); }); - test("soft clear preserves attachments when reset is a no-op", async () => { - const context = createClearContext({ - onResetContext: mock(() => Promise.resolve("noop" as const)), - }); - - const result = await processSlashCommand({ type: "clear", mode: "soft" }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(context.setAttachments).not.toHaveBeenCalled(); - expect(context.onDetachAllReviews).not.toHaveBeenCalled(); - }); - - test("soft clear reports errors without clearing composer state", async () => { - const context = createClearContext({ - onResetContext: mock(() => Promise.reject(new Error("reset failed"))), + test("soft clear preserves attachments for no-op and restores on failure", async () => { + const noOp = await finishCommand( + await processSlashCommand( + { type: "clear", mode: "soft" }, + createEnv({ resetContext: () => Promise.resolve("noop") }) + ) + ); + expectDisposition(noOp.result, "consume"); + expect(noOp.result.actions).not.toContainEqual({ type: "clear-attachments" }); + expectToast(noOp.result.actions, { + type: "success", + message: "No context to reset", }); - const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => undefined); - try { - const result = await processSlashCommand({ type: "clear", mode: "soft" }, context); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(context.setInput).not.toHaveBeenCalled(); - expect(context.setAttachments).not.toHaveBeenCalled(); - expect(context.onDetachAllReviews).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ message: "reset failed", type: "error" }) - ); - } finally { - consoleErrorSpy.mockRestore(); - } + const failure = await finishCommand( + await processSlashCommand( + { type: "clear", mode: "soft" }, + createEnv({ + resetContext: () => Promise.reject(new Error("reset failed")), + }) + ) + ); + expectDisposition(failure.result, "restore"); + expectToast(failure.result.actions, { type: "error", message: "reset failed" }); }); - test("soft clear reports no-op resets", async () => { - const context = createClearContext({ - onResetContext: mock(() => Promise.resolve("noop" as const)), - }); - - const result = await processSlashCommand({ type: "clear", mode: "soft" }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ message: "No context to reset", type: "success" }) + test("missing clear capabilities consume without a toast", async () => { + const soft = await processSlashCommand({ type: "clear", mode: "soft" }, createEnv()); + expect(soft).toEqual({ kind: "complete", actions: [], inputDisposition: "consume" }); + const hard = await finishCommand( + await processSlashCommand({ type: "clear", mode: "hard" }, createEnv()) ); + expectDisposition(hard.result, "consume"); + expect(hard.result.actions).toEqual([]); }); }); -describe("processSlashCommand - model-set", () => { - const createModelSetContext = (api: SlashCommandContext["api"]): SlashCommandContext => - createSlashCommandContext({ - api, - onMessageSent: mock(() => undefined), - onCheckReviews: mock(() => undefined), - attachedReviewIds: [], - openSettings: mock(() => undefined), +describe("processSlashCommand model and gating results", () => { + test("reports provider verification failure through result data", async () => { + const result = await processSlashCommand( + { type: "model-set", modelString: "custom:model" }, + createEnv({ + api: { + providers: { getConfig: mock(() => Promise.reject(new Error("offline"))) }, + } as unknown as SlashCommandEnv["api"], + }) + ); + expect(result.kind).toBe("complete"); + if (result.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(result, "restore"); + expectToast(result.actions, { + type: "error", + message: 'Could not verify provider "custom": backend unreachable. Please retry.', }); - - test("reports backend verification failure for custom providers when config loading fails", async () => { - const getConfig = mock(() => Promise.reject(new Error("backend offline"))); - const context = createModelSetContext({ - providers: { - getConfig, - }, - } as unknown as SlashCommandContext["api"]); - const consoleErrorSpy = spyOn(console, "error").mockImplementation(() => undefined); - - try { - const result = await processSlashCommand( - { type: "model-set", modelString: "local-vllm:qwen3-coder" }, - context - ); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: 'Could not verify provider "local-vllm": backend unreachable. Please retry.', - }) - ); - expect(context.setToast).not.toHaveBeenCalledWith( - expect.objectContaining({ message: 'Unknown provider "local-vllm"' }) - ); - } finally { - consoleErrorSpy.mockRestore(); - } }); - test("refuses switching budgeted active goals to an unpriced model", async () => { - ensureWindowDispatchEvent(); - const setPreferredModel = mock(() => undefined); - const context = createModelSetContext({ - providers: { - getConfig: mock(() => Promise.resolve({})), - setModels: mock(() => Promise.resolve(undefined)), - }, - workspace: { - getGoal: mock(() => - Promise.resolve({ - goal: { - goalId: "11111111-1111-4111-8111-111111111111", - status: "active", - budgetCents: 500, - }, - }) - ), - }, - } as unknown as SlashCommandContext["api"]); - context.setPreferredModel = setPreferredModel; - + test("refuses an unpriced model for a budgeted active goal", async () => { const result = await processSlashCommand( - { type: "model-set", modelString: "openai:not-priced-model" }, - context + { type: "model-set", modelString: "openai:unpriced-model" }, + createEnv({ + api: { + providers: { getConfig: mock(() => Promise.resolve({ openai: { models: [] } })) }, + workspace: { + getGoal: mock(() => + Promise.resolve({ + goal: { objective: "ship", status: "active", budgetCents: 500 }, + }) + ), + }, + } as unknown as SlashCommandEnv["api"], + }) ); + expect(result.kind).toBe("complete"); + if (result.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(result, "restore"); + expect(result.actions[0]).toMatchObject({ type: "show-toast", toast: { type: "error" } }); + }); - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(setPreferredModel).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "Target model has no pricing data. Pick a priced model before switching.", + test("returns model and vim actions", async () => { + const model = await processSlashCommand( + { type: "model-set", modelString: "anthropic:claude-sonnet-4-6" }, + createEnv({ + api: { + providers: { + getConfig: mock(() => Promise.resolve({})), + setModels: mock(() => Promise.resolve()), + }, + } as unknown as SlashCommandEnv["api"], }) ); + expect(model.kind).toBe("complete"); + if (model.kind !== "complete") throw new Error("expected complete result"); + expect(model.actions).toContainEqual({ + type: "set-preferred-model", + model: "anthropic:claude-sonnet-4-6", + }); + const vim = await processSlashCommand({ type: "vim-toggle" }, createEnv()); + expect(vim).toEqual({ + kind: "complete", + actions: [{ type: "clear-input" }, { type: "toggle-vim" }], + inputDisposition: "consume", + }); }); - test("allows switching unbudgeted active goals to an unpriced model", async () => { - const setPreferredModel = mock(() => undefined); - const context = createModelSetContext({ - providers: { - getConfig: mock(() => Promise.resolve({})), - setModels: mock(() => Promise.resolve(undefined)), - }, - workspace: { - getGoal: mock(() => - Promise.resolve({ - goal: { - goalId: "11111111-1111-4111-8111-111111111111", - status: "active", - budgetCents: null, - }, - }) - ), - }, - } as unknown as SlashCommandContext["api"]); - context.setPreferredModel = setPreferredModel; - + test("returns goal parse errors during creation", async () => { const result = await processSlashCommand( - { type: "model-set", modelString: "openai:not-priced-model" }, - context + { type: "command-missing-args", command: "goal", usage: "/goal " }, + createEnv({ variant: "creation", workspaceId: undefined }) ); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(setPreferredModel).toHaveBeenCalledWith("openai:not-priced-model"); + expect(result.kind).toBe("complete"); + if (result.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(result, "restore"); + expectToast(result.actions, { + type: "error", + message: "/goal requires arguments", + }); }); -}); - -describe("processSlashCommand - workspace command gating", () => { - test("shows goal parse errors during workspace creation", async () => { - const context = createGoalCommandContext(null); - context.variant = "creation"; - const result = await processSlashCommand( - { type: "command-unknown-flag", command: "goal", flag: "--bogus" }, - context + test("returns require-client and workspace-creation guard results", async () => { + const disconnected = await processSlashCommand( + { type: "idle-compaction", hours: 2 }, + createEnv({ api: null }) ); + if (disconnected.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(disconnected, "restore"); + expectToast(disconnected.actions, { type: "error", message: "Not connected to server" }); - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ message: "Unknown flag for /goal: --bogus" }) + const guarded = await processSlashCommand( + { type: "clear", mode: "soft" }, + createEnv({ variant: "creation", workspaceId: undefined }) ); + if (guarded.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(guarded, "restore"); + expectToast(guarded.actions, { + type: "error", + message: "Command not available during workspace creation", + }); }); -}); -describe("processSlashCommand - goal optimistic concurrency", () => { - test("retries once after a goal conflict and reapplies the slash command intent", async () => { - ensureWindowDispatchEvent(); - const getGoal = mock() - .mockResolvedValueOnce({ - goal: { - goalId: "11111111-1111-4111-8111-111111111111", - objective: "old objective", - }, - }) - .mockResolvedValueOnce({ - goal: { - goalId: "22222222-2222-4222-8222-222222222222", - objective: "fresh objective", - }, - }); - const setGoal = mock() - .mockResolvedValueOnce({ - success: false, - error: { - type: "goal_conflict", - expectedGoalId: "11111111-1111-4111-8111-111111111111", - actualGoalId: "22222222-2222-4222-8222-222222222222", - }, + test("returns idle-compaction and debug actions", async () => { + const setIdleCompaction = mock(() => Promise.resolve({ success: true, data: undefined })); + const idle = await processSlashCommand( + { type: "idle-compaction", hours: 2 }, + createEnv({ + api: { + projects: { idleCompaction: { set: setIdleCompaction } }, + } as unknown as SlashCommandEnv["api"], }) - .mockResolvedValueOnce({ - success: true, - data: { - goalId: "33333333-3333-4333-8333-333333333333", - objective: "new objective", - }, - }); - const context = createGoalCommandContext({ - workspace: { getGoal, setGoal, clearGoal: mock() }, - } as unknown as SlashCommandContext["api"]); - - const result = await processSlashCommand( - { type: "goal-set", objective: "new objective" }, - context ); - - expect(result).toEqual({ clearInput: true, toastShown: false }); - expect(getGoal).toHaveBeenCalledTimes(2); - expect(setGoal).toHaveBeenNthCalledWith(1, { - workspaceId: "goal-ws", - objective: "new objective", - budgetCents: 200, - turnCap: null, - expectedGoalId: "11111111-1111-4111-8111-111111111111", + if (idle.kind !== "phase") throw new Error("expected phase result"); + expect(idle.actions).toEqual([{ type: "clear-input" }]); + expect(setIdleCompaction).not.toHaveBeenCalled(); + const idleComplete = await idle.continue(); + if (idleComplete.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(idleComplete, "consume"); + expectToast(idleComplete.actions, { + type: "success", + message: "Idle compaction set to 2 hours", }); - expect(setGoal).toHaveBeenNthCalledWith(2, { - workspaceId: "goal-ws", - objective: "new objective", - budgetCents: 200, - turnCap: null, - expectedGoalId: "22222222-2222-4222-8222-222222222222", + + ensureWindowDispatchEvent(); + const debug = await processSlashCommand({ type: "debug-llm-request" }, createEnv()); + expect(debug).toEqual({ + kind: "complete", + actions: [{ type: "clear-input" }], + inputDisposition: "consume", }); - expect(context.setToast).not.toHaveBeenCalled(); + expect(window.dispatchEvent).toHaveBeenCalledWith( + expect.objectContaining({ type: "mux:openDebugLlmRequest" }) + ); }); +}); - test("surfaces a toast and stops after two consecutive goal conflicts", async () => { +function createGoalEnv(api: SlashCommandEnv["api"]): SlashCommandEnv { + return createEnv({ api, workspaceId: "goal-ws" }); +} + +describe("processSlashCommand goal results", () => { + test("retries one conflict and returns a consumed result", async () => { ensureWindowDispatchEvent(); const getGoal = mock() .mockResolvedValueOnce({ - goal: { - goalId: "11111111-1111-4111-8111-111111111111", - objective: "old objective", - }, + goal: { goalId: "11111111-1111-4111-8111-111111111111", objective: "old" }, }) .mockResolvedValueOnce({ - goal: { - goalId: "22222222-2222-4222-8222-222222222222", - objective: "fresh objective", - }, + goal: { goalId: "22222222-2222-4222-8222-222222222222", objective: "fresh" }, }); const setGoal = mock() .mockResolvedValueOnce({ @@ -904,458 +601,160 @@ describe("processSlashCommand - goal optimistic concurrency", () => { }, }) .mockResolvedValueOnce({ - success: false, - error: { - type: "goal_conflict", - expectedGoalId: "22222222-2222-4222-8222-222222222222", - actualGoalId: "33333333-3333-4333-8333-333333333333", - }, + success: true, + data: { goalId: "33333333-3333-4333-8333-333333333333", objective: "new" }, }); - const context = createGoalCommandContext({ - workspace: { getGoal, setGoal, clearGoal: mock() }, - } as unknown as SlashCommandContext["api"]); - - const result = await processSlashCommand( - { type: "goal-set", objective: "new objective" }, - context + const settled = await finishCommand( + await processSlashCommand( + { type: "goal-set", objective: "new" }, + createGoalEnv({ + config: { getConfig: mock(() => Promise.resolve({})) }, + workspace: { getGoal, setGoal }, + } as unknown as SlashCommandEnv["api"]) + ) ); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(getGoal).toHaveBeenCalledTimes(2); + expect(settled.batches[0]).toEqual([{ type: "clear-input" }]); + expectDisposition(settled.result, "consume"); + expect(settled.result.actions).toEqual([]); expect(setGoal).toHaveBeenCalledTimes(2); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "Goal changed in another window. Please try again.", - }) - ); }); -}); -describe("processSlashCommand - goal lifecycle commands", () => { - test("surfaces invalid transition messages for lifecycle commands", async () => { - ensureWindowDispatchEvent(); - const context = createGoalCommandContext({ - workspace: { - getGoal: mock(() => Promise.resolve({ goal: null })), - setGoal: mock(() => - Promise.resolve({ - success: false, - error: { type: "invalid_transition", message: "Cannot pause a missing goal." }, - }) - ), - clearGoal: mock(), + test("surfaces a second conflict as a restore result", async () => { + const conflict = { + success: false as const, + error: { + type: "goal_conflict" as const, + expectedGoalId: "11111111-1111-4111-8111-111111111111", + actualGoalId: "22222222-2222-4222-8222-222222222222", }, - } as unknown as SlashCommandContext["api"]); - - const result = await processSlashCommand({ type: "goal-pause" }, context); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ type: "error", message: "Cannot pause a missing goal." }) + }; + const settled = await finishCommand( + await processSlashCommand( + { type: "goal-pause" }, + createGoalEnv({ + workspace: { + getGoal: mock(() => + Promise.resolve({ + goal: { goalId: "11111111-1111-4111-8111-111111111111" }, + }) + ), + setGoal: mock(() => Promise.resolve(conflict)), + }, + } as unknown as SlashCommandEnv["api"]) + ) ); + expectDisposition(settled.result, "restore"); + expectToast(settled.result.actions, { + type: "error", + message: "Goal changed in another window. Please try again.", + }); }); - test("dispatches pause, resume, and complete goal commands", async () => { + test("passes configured defaults and multiline objectives to the backend", async () => { ensureWindowDispatchEvent(); + const objective = "Implement PRD\n\nRead first:\n- CONTEXT.md\n- PRD.md"; + const parsed = parseCommand("/goal " + objective); + if (parsed?.type !== "goal-set") throw new Error("expected goal-set"); const setGoal = mock(() => Promise.resolve({ success: true, - data: { goalId: "33333333-3333-4333-8333-333333333333", objective: "goal" }, + data: { goalId: "33333333-3333-4333-8333-333333333333", objective }, }) ); - const context = createGoalCommandContext({ - providers: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: { status: "paused", budgetCents: null } })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - - await processSlashCommand({ type: "goal-pause" }, context); - await processSlashCommand({ type: "goal-resume" }, context); - await processSlashCommand({ type: "goal-complete", summary: "Done." }, context); - - expect(setGoal).toHaveBeenNthCalledWith(1, { - workspaceId: "goal-ws", - expectedGoalId: null, - status: "paused", - }); - expect(setGoal).toHaveBeenNthCalledWith(2, { - workspaceId: "goal-ws", - status: "active", - expectedGoalId: null, - }); - expect(setGoal).toHaveBeenNthCalledWith(3, { - workspaceId: "goal-ws", - status: "complete", - completionSummary: "Done.", - expectedGoalId: null, - }); - }); - - test("refuses to resume a budgeted goal on an unpriced current model", async () => { - ensureWindowDispatchEvent(); - const setGoal = mock(() => Promise.resolve({ success: true, data: {} })); - const context = createGoalCommandContext({ - providers: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => - Promise.resolve({ - goal: { - goalId: "11111111-1111-4111-8111-111111111111", - status: "paused", - budgetCents: 500, - }, - }) - ), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - context.sendMessageOptions.model = "custom:unpriced-model"; - - const result = await processSlashCommand({ type: "goal-resume" }, context); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(setGoal).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: - "Current model has no pricing data. Pick a priced model, use -b 0 with a turn cap, or change goal budget defaults in Settings.", - }) + const settled = await finishCommand( + await processSlashCommand( + parsed, + createGoalEnv({ + config: { + getConfig: mock(() => + Promise.resolve({ + goalDefaults: { + defaultBudgetCents: 350, + defaultTurnCap: 25, + alwaysRequireExplicitBudget: true, + }, + }) + ), + }, + workspace: { + getGoal: mock(() => Promise.resolve({ goal: null })), + setGoal, + }, + } as unknown as SlashCommandEnv["api"]) + ) ); - }); -}); - -describe("processSlashCommand - goal budgets", () => { - test("applies configured defaults when budget and turn cap are omitted", async () => { - ensureWindowDispatchEvent(); - const setGoal = mock().mockResolvedValueOnce({ - success: true, - data: { goalId: "33333333-3333-4333-8333-333333333333", objective: "new objective" }, - }); - const context = createGoalCommandContext({ - config: { - getConfig: mock(() => - Promise.resolve({ - goalDefaults: { - defaultBudgetCents: 350, - defaultTurnCap: 25, - alwaysRequireExplicitBudget: true, - }, - }) - ), - }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: null })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - - await processSlashCommand({ type: "goal-set", objective: "new objective" }, context); - + expectDisposition(settled.result, "consume"); expect(setGoal).toHaveBeenCalledWith({ workspaceId: "goal-ws", - objective: "new objective", + objective, expectedGoalId: null, budgetCents: 350, turnCap: 25, }); }); - test("passes parsed multiline goal objectives through to setGoal", async () => { - ensureWindowDispatchEvent(); - const objective = "Implement PRD\n\nRead first:\n- CONTEXT.md\n- PRD.md"; - const setGoal = mock().mockResolvedValueOnce({ - success: true, - data: { goalId: "33333333-3333-4333-8333-333333333333", objective }, - }); - const context = createGoalCommandContext({ - config: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: null })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - - const parsed = parseCommand("/goal Implement PRD\n\nRead first:\n- CONTEXT.md\n- PRD.md"); - if (parsed?.type !== "goal-set") { - throw new Error("expected multiline /goal to parse as goal-set"); - } - - const result = await processSlashCommand(parsed, context); - - expect(result).toEqual({ clearInput: true, toastShown: false }); - expect(setGoal).toHaveBeenCalledWith({ - workspaceId: "goal-ws", - objective, - expectedGoalId: null, - budgetCents: 200, - turnCap: null, - }); - expect(context.setToast).not.toHaveBeenCalled(); - expect(window.dispatchEvent).toHaveBeenCalledWith( - expect.objectContaining({ type: "mux:openGoalTab" }) + test("returns lifecycle success and pricing failure actions", async () => { + const paused = await finishCommand( + await processSlashCommand( + { type: "goal-pause" }, + createGoalEnv({ + workspace: { + getGoal: mock(() => Promise.resolve({ goal: null })), + setGoal: mock(() => + Promise.resolve({ success: true, data: { goalId: "id", status: "paused" } }) + ), + }, + } as unknown as SlashCommandEnv["api"]) + ) ); - }); - - test("passes explicit no-budget and turn cap through to setGoal", async () => { - ensureWindowDispatchEvent(); - const setGoal = mock().mockResolvedValueOnce({ - success: true, - data: { goalId: "33333333-3333-4333-8333-333333333333", objective: "new objective" }, - }); - const context = createGoalCommandContext({ - config: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: null })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - - await processSlashCommand( - { type: "goal-set", objective: "new objective", budgetCents: null, turnCap: 10 }, - context + expectDisposition(paused.result, "consume"); + expectToast(paused.result.actions, { type: "success", message: "Goal paused" }); + + const resume = await finishCommand( + await processSlashCommand( + { type: "goal-resume" }, + createGoalEnv({ + providers: { getConfig: mock(() => Promise.resolve({})) }, + workspace: { + getGoal: mock(() => Promise.resolve({ goal: { status: "paused", budgetCents: 500 } })), + }, + } as unknown as SlashCommandEnv["api"]) + ) ); - - expect(setGoal).toHaveBeenCalledWith({ - workspaceId: "goal-ws", - objective: "new objective", - expectedGoalId: null, - budgetCents: null, - turnCap: 10, - }); - }); - - test("updates an existing goal budget without applying defaults", async () => { - ensureWindowDispatchEvent(); - const currentGoal = { - goalId: "11111111-1111-4111-8111-111111111111", - objective: "existing objective", - }; - const setGoal = mock().mockResolvedValueOnce({ - success: true, - data: { ...currentGoal, budgetCents: 500 }, - }); - const context = createGoalCommandContext({ - config: { - getConfig: mock(() => - Promise.resolve({ - goalDefaults: { - defaultBudgetCents: 350, - defaultTurnCap: 25, - alwaysRequireExplicitBudget: true, - }, - }) - ), - }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: currentGoal })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - - await processSlashCommand({ type: "goal-budget", budgetCents: 500 }, context); - - expect(setGoal).toHaveBeenCalledWith({ - workspaceId: "goal-ws", - budgetCents: 500, - expectedGoalId: currentGoal.goalId, - }); - }); - - test("passes no-budget budget updates through on unpriced current model", async () => { - ensureWindowDispatchEvent(); - const currentGoal = { - goalId: "11111111-1111-4111-8111-111111111111", - objective: "existing objective", - }; - const setGoal = mock().mockResolvedValueOnce({ - success: true, - data: { ...currentGoal, budgetCents: null }, - }); - const context = createGoalCommandContext({ - config: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: currentGoal })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - context.sendMessageOptions.model = "custom-provider:no-price-model"; - - await processSlashCommand({ type: "goal-budget", budgetCents: null }, context); - - expect(setGoal).toHaveBeenCalledWith({ - workspaceId: "goal-ws", - budgetCents: null, - expectedGoalId: currentGoal.goalId, - }); - }); - - test("passes zero-dollar budget updates through on unpriced current model", async () => { - ensureWindowDispatchEvent(); - const currentGoal = { - goalId: "11111111-1111-4111-8111-111111111111", - objective: "existing objective", - }; - const setGoal = mock().mockResolvedValueOnce({ - success: true, - data: { ...currentGoal, budgetCents: null }, - }); - const context = createGoalCommandContext({ - config: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: currentGoal })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - context.sendMessageOptions.model = "custom-provider:no-price-model"; - - await processSlashCommand({ type: "goal-budget", budgetCents: 0 }, context); - - expect(setGoal).toHaveBeenCalledWith({ - workspaceId: "goal-ws", - budgetCents: 0, - expectedGoalId: currentGoal.goalId, + expectDisposition(resume.result, "restore"); + expect(resume.result.actions[0]).toMatchObject({ + type: "show-toast", + toast: { type: "error" }, }); }); - - test("refuses budgeted goals on an unpriced current model", async () => { - ensureWindowDispatchEvent(); - const setGoal = mock(); - const context = createGoalCommandContext({ - config: { getConfig: mock(() => Promise.resolve({})) }, - workspace: { - getGoal: mock(() => Promise.resolve({ goal: null })), - setGoal, - clearGoal: mock(), - }, - } as unknown as SlashCommandContext["api"]); - context.sendMessageOptions.model = "custom-provider:no-price-model"; - - const result = await processSlashCommand( - { type: "goal-set", objective: "new objective", budgetCents: 500 }, - context - ); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(setGoal).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: - "Current model has no pricing data. Pick a priced model, use -b 0 with a turn cap, or change goal budget defaults in Settings.", - }) - ); - }); }); -describe("processSlashCommand - heartbeat-set", () => { - const HEARTBEAT_EXPERIMENT_KEY = getExperimentKey(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); - - function setHeartbeatExperiment(enabled: boolean) { - globalThis.localStorage.setItem(HEARTBEAT_EXPERIMENT_KEY, JSON.stringify(enabled)); - } - - const createSlashCommandContext = (options?: { - api?: SlashCommandContext["api"] | null; - workspaceId?: string; - variant?: SlashCommandContext["variant"]; - }): SlashCommandContext => { - const setInput = mock(() => undefined); - const setToast = mock(() => undefined); - - return { - api: options?.api ?? null, - workspaceId: - options && Object.hasOwn(options, "workspaceId") ? options.workspaceId : "test-ws", - variant: options?.variant ?? "workspace", - projectPath: "/tmp/project", - setPreferredModel: mock(() => undefined), - setVimEnabled: mock((cb: (prev: boolean) => boolean) => cb(false)), - resetInputHeight: mock(() => undefined), - onTruncateHistory: mock(() => Promise.resolve(undefined)), - onMessageSent: mock(() => undefined), - onCheckReviews: mock(() => undefined), - attachedReviewIds: [], - openSettings: mock(() => undefined), - sendMessageOptions: { - model: "anthropic:claude-sonnet-4-6", - thinkingLevel: "off", - toolPolicy: [], - agentId: "exec", - }, - setInput, - setToast, - setAttachments: mock(() => undefined), - setSendingState: mock(() => undefined), - }; - }; - - test("shows an error toast when the heartbeat experiment is disabled", async () => { - const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - }); - - setHeartbeatExperiment(false); - - const result = await processSlashCommand({ type: "heartbeat-set", minutes: 30 }, context); - - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(heartbeatSet).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: - "Heartbeat configuration requires the Workspace Heartbeats experiment to be enabled", - }) +describe("processSlashCommand heartbeat results", () => { + test("returns gating errors before a phase", async () => { + const api = { + workspace: { heartbeat: { get: mock(), set: mock() } }, + } as unknown as SlashCommandEnv["api"]; + const disabled = await processSlashCommand( + { type: "heartbeat-set", minutes: 30 }, + createEnv({ api }) ); - }); - - test("shows an error toast when no workspace is selected", async () => { - const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - workspaceId: undefined, - }); - - setHeartbeatExperiment(true); - - const result = await processSlashCommand({ type: "heartbeat-set", minutes: 30 }, context); + expect(disabled.kind).toBe("complete"); + if (disabled.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(disabled, "restore"); + expect(disabled.actions[0]).toMatchObject({ type: "show-toast", toast: { type: "error" } }); - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(heartbeatSet).not.toHaveBeenCalled(); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "No workspace selected", - }) + setHeartbeatExperiment(true); + const missing = await processSlashCommand( + { type: "heartbeat-set", minutes: 30 }, + createEnv({ api, workspaceId: undefined }) ); + expect(missing.kind).toBe("complete"); + if (missing.kind !== "complete") throw new Error("expected complete result"); + expectToast(missing.actions, { type: "error", message: "No workspace selected" }); }); - test("enables workspace heartbeats with the requested interval without clearing the saved message", async () => { + test("preserves saved heartbeat fields and returns success", async () => { + setHeartbeatExperiment(true); const heartbeatGet = mock(() => Promise.resolve({ enabled: true as const, @@ -1364,182 +763,337 @@ describe("processSlashCommand - heartbeat-set", () => { }) ); const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - get: heartbeatGet, - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - workspaceId: "test-ws", - }); - - setHeartbeatExperiment(true); - - const result = await processSlashCommand({ type: "heartbeat-set", minutes: 30 }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(context.setInput).toHaveBeenCalledWith(""); - expect(heartbeatGet).toHaveBeenCalledWith({ workspaceId: "test-ws" }); + const initial = await processSlashCommand( + { type: "heartbeat-set", minutes: 30 }, + createEnv({ + api: { + workspace: { heartbeat: { get: heartbeatGet, set: heartbeatSet } }, + } as unknown as SlashCommandEnv["api"], + }) + ); + expect(initial.kind).toBe("phase"); + if (initial.kind !== "phase") throw new Error("expected phase result"); + expect(initial.actions).toEqual([{ type: "clear-input" }]); + const complete = await initial.continue(); + expect(complete.kind).toBe("complete"); + if (complete.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(complete, "consume"); expect(heartbeatSet).toHaveBeenCalledWith({ workspaceId: "test-ws", enabled: true, intervalMs: 30 * 60 * 1000, message: "Review the workspace status before taking action.", }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "success", - message: "Heartbeat set to every 30 minutes", - }) - ); + expectToast(complete.actions, { + type: "success", + message: "Heartbeat set to every 30 minutes", + }); }); - test("still updates the interval when reading current heartbeat settings fails", async () => { - const heartbeatGet = mock(() => Promise.reject(new Error("Corrupted heartbeat settings"))); + test("uses the default interval when disabling without saved settings", async () => { + setHeartbeatExperiment(true); const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - get: heartbeatGet, - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], + const settled = await finishCommand( + await processSlashCommand( + { type: "heartbeat-set", minutes: null }, + createEnv({ + api: { + workspace: { + heartbeat: { + get: mock(() => Promise.reject(new Error("missing"))), + set: heartbeatSet, + }, + }, + } as unknown as SlashCommandEnv["api"], + }) + ) + ); + expectDisposition(settled.result, "consume"); + expect(heartbeatSet).toHaveBeenCalledWith({ workspaceId: "test-ws", + enabled: false, + intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS, }); + }); + test("returns backend update failures with restore disposition", async () => { setHeartbeatExperiment(true); - - const result = await processSlashCommand({ type: "heartbeat-set", minutes: 30 }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(heartbeatGet).toHaveBeenCalledWith({ workspaceId: "test-ws" }); - expect(heartbeatSet).toHaveBeenCalledWith({ - workspaceId: "test-ws", - enabled: true, - intervalMs: 30 * 60 * 1000, - }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "success", - message: "Heartbeat set to every 30 minutes", - }) + const settled = await finishCommand( + await processSlashCommand( + { type: "heartbeat-set", minutes: 30 }, + createEnv({ + api: { + workspace: { + heartbeat: { + get: mock(() => Promise.resolve({ enabled: false, intervalMs: 1 })), + set: mock(() => + Promise.resolve({ success: false, error: "Heartbeat update failed" }) + ), + }, + }, + } as unknown as SlashCommandEnv["api"], + }) + ) ); + expectDisposition(settled.result, "restore"); + expectToast(settled.result.actions, { + type: "error", + message: "Heartbeat update failed", + }); }); +}); - test("preserves the configured interval and message when disabling workspace heartbeats", async () => { - const heartbeatGet = mock(() => +describe("detached command work", () => { + test("dream returns immediately and maps success and rejection to settle actions", async () => { + const consolidate = mock(() => Promise.resolve({ - enabled: true as const, - intervalMs: 45 * 60 * 1000, - message: "Review the workspace status before taking action.", + success: true as const, + data: { ops: [{ applied: true }, { applied: false }] }, }) ); - const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - get: heartbeatGet, - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - workspaceId: "test-ws", + const result = await processSlashCommand( + { type: "dream" }, + createEnv({ + api: { memory: { consolidate } } as unknown as SlashCommandEnv["api"], + }) + ); + expect(result.kind).toBe("complete"); + if (result.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(result, "consume"); + expect(consolidate).not.toHaveBeenCalled(); + const successActions = await result.backgroundTask?.(); + expect(successActions).toBeDefined(); + expectToast(successActions ?? [], { + type: "success", + message: "Memory consolidated: 1 change(s)", }); - setHeartbeatExperiment(true); - - const result = await processSlashCommand({ type: "heartbeat-set", minutes: null }, context); - - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(heartbeatGet).toHaveBeenCalledWith({ workspaceId: "test-ws" }); - expect(heartbeatSet).toHaveBeenCalledWith({ - workspaceId: "test-ws", - enabled: false, - intervalMs: 45 * 60 * 1000, - message: "Review the workspace status before taking action.", + const failed = await processSlashCommand( + { type: "dream" }, + createEnv({ + api: { + memory: { + consolidate: mock(() => + Promise.resolve({ success: false as const, error: "backend refused" }) + ), + }, + } as unknown as SlashCommandEnv["api"], + }) + ); + if (failed.kind !== "complete") throw new Error("expected complete result"); + const failedActions = await failed.backgroundTask?.(); + expectToast(failedActions ?? [], { + type: "error", + message: "Memory consolidation failed: backend refused", }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "success", - message: "Heartbeat disabled", + + const rejected = await processSlashCommand( + { type: "dream" }, + createEnv({ + api: { + memory: { consolidate: mock(() => Promise.reject(new Error("offline"))) }, + } as unknown as SlashCommandEnv["api"], }) ); + if (rejected.kind !== "complete") throw new Error("expected complete result"); + const rejectedActions = await rejected.backgroundTask?.(); + expectToast(rejectedActions ?? [], { + type: "error", + message: "Memory consolidation failed: Error: offline", + }); }); - test("uses the default interval when disabling heartbeats without saved settings", async () => { - const heartbeatGet = mock(() => Promise.resolve(null)); - const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - get: heartbeatGet, - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - workspaceId: "test-ws", + test("refine returns immediate validation or detached settle actions", async () => { + const missingProposal = await processSlashCommand( + { type: "refine", apply: true }, + createEnv({ + api: { refinements: {} } as unknown as SlashCommandEnv["api"], + }) + ); + if (missingProposal.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(missingProposal, "consume"); + expect(missingProposal.backgroundTask).toBeUndefined(); + expect(missingProposal.actions[0]).toMatchObject({ + type: "show-toast", + toast: { type: "error" }, }); - setHeartbeatExperiment(true); + const run = mock(() => + Promise.resolve({ + success: true as const, + data: { applied: [], staged: [{ path: "src/a.ts" }], failed: [], noOp: false }, + }) + ); + const result = await processSlashCommand( + { type: "refine", apply: false }, + createEnv({ + api: { refinements: { run } } as unknown as SlashCommandEnv["api"], + }) + ); + if (result.kind !== "complete") throw new Error("expected complete result"); + expect(run).not.toHaveBeenCalled(); + const actions = await result.backgroundTask?.(); + expectToast(actions ?? [], { + type: "success", + message: "Refine: 1 edit(s) staged — approve with /refine apply", + }); - const result = await processSlashCommand({ type: "heartbeat-set", minutes: null }, context); + const failed = await processSlashCommand( + { type: "refine", apply: false }, + createEnv({ + api: { + refinements: { + run: mock(() => Promise.resolve({ success: false as const, error: "backend refused" })), + }, + } as unknown as SlashCommandEnv["api"], + }) + ); + if (failed.kind !== "complete") throw new Error("expected complete result"); + expectToast((await failed.backgroundTask?.()) ?? [], { + type: "error", + message: "Refine failed: backend refused", + }); - expect(result).toEqual({ clearInput: true, toastShown: true }); - expect(heartbeatGet).toHaveBeenCalledWith({ workspaceId: "test-ws" }); - expect(heartbeatSet).toHaveBeenCalledWith({ - workspaceId: "test-ws", - enabled: false, - intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS, + const rejected = await processSlashCommand( + { type: "refine", apply: false }, + createEnv({ + api: { + refinements: { run: mock(() => Promise.reject(new Error("offline"))) }, + } as unknown as SlashCommandEnv["api"], + }) + ); + if (rejected.kind !== "complete") throw new Error("expected complete result"); + expectToast((await rejected.backgroundTask?.()) ?? [], { + type: "error", + message: "Refine failed: Error: offline", }); }); +}); - test("surfaces backend heartbeat update failures", async () => { - const heartbeatGet = mock(() => - Promise.resolve({ - enabled: true as const, - intervalMs: 45 * 60 * 1000, - message: "Review the workspace status before taking action.", +describe("compact and plan command results", () => { + test("compact returns phased composer actions and terminal review actions", async () => { + const reviews: ReviewNoteData[] = [ + { + filePath: "src/test.ts", + lineRange: "10-15", + selectedCode: "const x = 1;", + userNote: "Please fix this bug", + }, + ]; + const sentMessages: Array<{ + options?: { muxMetadata?: { parsed?: { followUpContent?: { reviews?: ReviewNoteData[] } } } }; + }> = []; + const sendMessage = mock((input: (typeof sentMessages)[number]) => { + sentMessages.push(input); + return Promise.resolve({ success: true }); + }); + const initial = await processSlashCommand( + { type: "compact" }, + createEnv({ + api: { workspace: { sendMessage } } as unknown as SlashCommandEnv["api"], + reviews, + editMessageId: "edit-id", + attachedReviewIds: ["review-1"], + sendMessageOptions: { ...sendMessageOptions, queueDispatchMode: "turn-end" }, }) ); - const heartbeatSet = mock(() => - Promise.resolve({ success: false as const, error: "Heartbeat update failed" }) + expect(initial.kind).toBe("phase"); + if (initial.kind !== "phase") throw new Error("expected phase result"); + expect(initial.actions).toEqual([ + { type: "clear-input" }, + { type: "clear-attachments" }, + { type: "set-sending", sending: true }, + ]); + const complete = await initial.continue(); + expect(complete.kind).toBe("complete"); + if (complete.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(complete, "consume"); + expect(complete.actions).toContainEqual({ type: "cancel-edit" }); + expect(complete.actions).toContainEqual({ type: "check-reviews", reviewIds: ["review-1"] }); + expect(complete.actions).toContainEqual({ type: "message-sent", dispatchMode: "turn-end" }); + expect(sentMessages[0]?.options?.muxMetadata?.parsed?.followUpContent?.reviews).toEqual( + reviews ); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - get: heartbeatGet, - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - workspaceId: "test-ws", - }); + }); - setHeartbeatExperiment(true); + test("compact validation errors restore without starting a phase", async () => { + const result = await processSlashCommand( + { type: "compact", model: "invalid" }, + createEnv({ api: {} as unknown as SlashCommandEnv["api"] }) + ); + expect(result.kind).toBe("complete"); + if (result.kind !== "complete") throw new Error("expected complete result"); + expectDisposition(result, "restore"); + expect(result.actions[0]).toMatchObject({ type: "show-toast", toast: { type: "error" } }); + }); - const result = await processSlashCommand({ type: "heartbeat-set", minutes: 30 }, context); + test("plan show replaces its singleton preview", async () => { + const workspaceId = "test-workspace-id"; + const store = useWorkspaceStoreRaw(); + store.dispose(); + const metadata: FrontendWorkspaceMetadata = { + id: workspaceId, + name: "test-workspace", + title: "Test Workspace", + projectName: "Project", + projectPath: "/tmp/project", + namedWorkspacePath: "/tmp/project/test-workspace", + runtimeConfig: { type: "local" }, + createdAt: "2026-08-05T00:00:00.000Z", + }; + workspaceStore.addWorkspace(metadata); + try { + for (const content of ["# First plan", "# Updated plan"]) { + const settled = await finishCommand( + await processSlashCommand( + { type: "plan-show" }, + createEnv({ + workspaceId, + api: { + workspace: { + getPlanContent: mock(() => + Promise.resolve({ + success: true, + data: { content, path: "/path/to/plan.md" }, + }) + ), + }, + } as unknown as SlashCommandEnv["api"], + }) + ) + ); + expectDisposition(settled.result, "consume"); + } + const previews = store + .getWorkspaceState(workspaceId) + .messages.filter((message) => message.type === "plan-display"); + expect(previews).toHaveLength(1); + expect(previews[0]).toMatchObject({ content: "# Updated plan" }); + } finally { + store.dispose(); + } + }); - expect(result).toEqual({ clearInput: false, toastShown: true }); - expect(heartbeatSet).toHaveBeenCalledWith({ - workspaceId: "test-ws", - enabled: true, - intervalMs: 30 * 60 * 1000, - message: "Review the workspace status before taking action.", - }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "Heartbeat update failed", - }) + test("plan show missing result consumes with an error toast", async () => { + const settled = await finishCommand( + await processSlashCommand( + { type: "plan-show" }, + createEnv({ + api: { + workspace: { + getPlanContent: mock(() => + Promise.resolve({ success: false, error: "No plan found" }) + ), + }, + } as unknown as SlashCommandEnv["api"], + }) + ) ); + expectDisposition(settled.result, "consume"); + expectToast(settled.result.actions, { + type: "error", + message: "No plan found for this workspace", + }); }); }); @@ -1820,279 +1374,3 @@ describe("prepareCompactionMessage", () => { expect(metadata.parsed.followUpContent?.reviews).toHaveLength(1); }); }); - -describe("handlePlanShowCommand", () => { - const createMockContext = ( - getPlanContentResult: - | { success: true; data: { content: string; path: string } } - | { success: false; error: string } - ): CommandHandlerContext => { - const setInput = mock(() => undefined); - const setToast = mock(() => undefined); - - return { - workspaceId: "test-workspace-id", - setInput, - setToast, - api: { - workspace: { - getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), - }, - general: {}, - } as unknown as CommandHandlerContext["api"], - // Required fields for CommandHandlerContext - sendMessageOptions: { - model: "anthropic:claude-sonnet-4-6", - thinkingLevel: "off", - toolPolicy: [], - agentId: "exec", - }, - setAttachments: mock(() => undefined), - setSendingState: mock(() => undefined), - }; - }; - - test("shows error toast when no plan exists", async () => { - const context = createMockContext({ success: false, error: "No plan found" }); - - const result = await handlePlanShowCommand(context); - - expect(result.clearInput).toBe(true); - expect(result.toastShown).toBe(true); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "No plan found for this workspace", - }) - ); - }); - - test("replaces the previous plan preview instead of stacking another tail row", async () => { - const workspaceId = "test-workspace-id"; - const store = useWorkspaceStoreRaw(); - store.dispose(); - const metadata: FrontendWorkspaceMetadata = { - id: workspaceId, - name: "test-workspace", - title: "Test Workspace", - projectName: "Project", - projectPath: "/tmp/project", - namedWorkspacePath: "/tmp/project/test-workspace", - runtimeConfig: { type: "local" }, - createdAt: "2026-08-05T00:00:00.000Z", - }; - workspaceStore.addWorkspace(metadata); - - try { - await handlePlanShowCommand( - createMockContext({ - success: true, - data: { content: "# First plan", path: "/path/to/plan.md" }, - }) - ); - await handlePlanShowCommand( - createMockContext({ - success: true, - data: { content: "# Updated plan", path: "/path/to/plan.md" }, - }) - ); - - const previews = store - .getWorkspaceState(workspaceId) - .messages.filter((message) => message.type === "plan-display"); - expect(previews).toHaveLength(1); - expect(previews[0]).toMatchObject({ content: "# Updated plan" }); - } finally { - store.dispose(); - } - }); - - test("clears input when plan is found", async () => { - const context = createMockContext({ - success: true, - data: { content: "# My Plan\n\nStep 1", path: "/path/to/plan.md" }, - }); - - const result = await handlePlanShowCommand(context); - - expect(result.clearInput).toBe(true); - expect(result.toastShown).toBe(false); - expect(context.setInput).toHaveBeenCalledWith(""); - expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ - workspaceId: "test-workspace-id", - }); - }); -}); - -describe("handlePlanOpenCommand", () => { - const createMockContext = ( - getPlanContentResult: - | { success: true; data: { content: string; path: string } } - | { success: false; error: string }, - openInEditorResult?: { success: true; data: undefined } | { success: false; error: string } - ): CommandHandlerContext => { - const setInput = mock(() => undefined); - const setToast = mock(() => undefined); - - return { - workspaceId: "test-workspace-id", - setInput, - setToast, - api: { - workspace: { - getPlanContent: mock(() => Promise.resolve(getPlanContentResult)), - getInfo: mock(() => Promise.resolve(null)), - }, - general: { - openInEditor: mock(() => - Promise.resolve(openInEditorResult ?? { success: true, data: undefined }) - ), - }, - } as unknown as CommandHandlerContext["api"], - // Required fields for CommandHandlerContext - sendMessageOptions: { - model: "anthropic:claude-sonnet-4-6", - thinkingLevel: "off", - toolPolicy: [], - agentId: "exec", - }, - setAttachments: mock(() => undefined), - setSendingState: mock(() => undefined), - }; - }; - - test("shows error toast when no plan exists", async () => { - const context = createMockContext({ success: false, error: "No plan found" }); - - const result = await handlePlanOpenCommand(context); - - expect(result.clearInput).toBe(true); - expect(result.toastShown).toBe(true); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "No plan found for this workspace", - }) - ); - expect(context.api.workspace.getInfo).not.toHaveBeenCalled(); - // Should not attempt to open editor - expect(context.api.general.openInEditor).not.toHaveBeenCalled(); - }); - - test("opens plan in editor when plan exists", async () => { - const context = createMockContext( - { success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } }, - { success: true, data: undefined } - ); - - const result = await handlePlanOpenCommand(context); - - expect(result.clearInput).toBe(true); - expect(context.setInput).toHaveBeenCalledWith(""); - expect(context.api.workspace.getPlanContent).toHaveBeenCalledWith({ - workspaceId: "test-workspace-id", - }); - expect(context.api.workspace.getInfo).toHaveBeenCalledWith({ - workspaceId: "test-workspace-id", - }); - // Note: Built-in editors (VS Code/Cursor/Zed) now use deep links directly - // via window.open(), not the backend API. The backend API is only used - // for custom editors. - }); - - // Note: The "editor fails to open" test was removed because built-in editors - // (VS Code/Cursor/Zed) now use deep links that open via window.open() and - // always succeed from the app's perspective. Failures happen in the external - // editor, not in our code path. -}); - -describe("handleCompactCommand", () => { - const createMockContext = ( - sendMessageResult: { success: true } | { success: false; error?: string }, - options?: { reviews?: ReviewNoteData[] } - ): CommandHandlerContext => { - const setInput = mock(() => undefined); - const setToast = mock(() => undefined); - const setAttachments = mock(() => undefined); - const setSendingState = mock(() => undefined); - - // Track the options passed to sendMessage - const sendMessageMock = mock(() => Promise.resolve(sendMessageResult)); - - return { - workspaceId: "test-workspace-id", - setInput, - setToast, - setAttachments, - setSendingState, - reviews: options?.reviews, - api: { - workspace: { - sendMessage: sendMessageMock, - }, - } as unknown as CommandHandlerContext["api"], - sendMessageOptions: { - model: "anthropic:claude-sonnet-4-6", - thinkingLevel: "off", - toolPolicy: [], - agentId: "exec", - }, - }; - }; - - test("passes reviews to followUpContent when reviews are attached", async () => { - const reviews: ReviewNoteData[] = [ - { - filePath: "src/test.ts", - lineRange: "10-15", - selectedCode: "const x = 1;", - userNote: "Please fix this bug", - }, - ]; - - const context = createMockContext({ success: true }, { reviews }); - - await handleCompactCommand({ type: "compact" }, context); - - // Verify sendMessage was called with reviews in the metadata - const sendMessageMock = context.api.workspace.sendMessage as ReturnType; - expect(sendMessageMock).toHaveBeenCalled(); - - const callArgs = sendMessageMock.mock.calls[0][0] as { - options?: { muxMetadata?: { parsed?: { followUpContent?: { reviews?: ReviewNoteData[] } } } }; - }; - const followUpContent = callArgs?.options?.muxMetadata?.parsed?.followUpContent; - - expect(followUpContent).toBeDefined(); - expect(followUpContent?.reviews).toHaveLength(1); - expect(followUpContent?.reviews?.[0].userNote).toBe("Please fix this bug"); - }); - - test("creates followUpContent with only reviews (no text)", async () => { - const reviews: ReviewNoteData[] = [ - { - filePath: "src/test.ts", - lineRange: "10", - selectedCode: "x = 1", - userNote: "Check this", - }, - ]; - - const context = createMockContext({ success: true }, { reviews }); - - // No followUpContent text, just reviews - await handleCompactCommand({ type: "compact" }, context); - - const sendMessageMock = context.api.workspace.sendMessage as ReturnType; - expect(sendMessageMock).toHaveBeenCalled(); - - const callArgs = sendMessageMock.mock.calls[0][0] as { - options?: { muxMetadata?: { parsed?: { followUpContent?: { reviews?: ReviewNoteData[] } } } }; - }; - const followUpContent = callArgs?.options?.muxMetadata?.parsed?.followUpContent; - - // Should have followUpContent even without text, because reviews are present - expect(followUpContent).toBeDefined(); - expect(followUpContent?.reviews).toHaveLength(1); - }); -}); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 152d9739f8a..2a46702ee3d 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -167,41 +167,79 @@ export async function forkWorkspace(options: ForkOptions): Promise { return { success: true, workspaceInfo }; } -export interface SlashCommandContext extends Omit { +export type CommandInputDisposition = "consume" | "restore" | "restore-if-empty"; + +export type CommandAction = + | { type: "clear-input" } + | { type: "reset-input-height" } + | { type: "show-toast"; toast: Toast } + | { type: "set-preferred-model"; model: string } + | { type: "toggle-vim" } + | { type: "set-sending"; sending: boolean } + | { type: "clear-attachments" } + | { type: "detach-reviews" } + | { type: "check-reviews"; reviewIds: string[] } + | { type: "message-sent"; dispatchMode: QueueDispatchMode } + | { type: "cancel-edit" }; + +export type CommandResult = + | { kind: "phase"; actions: CommandAction[]; continue: () => Promise } + | { + kind: "complete"; + actions: CommandAction[]; + inputDisposition: CommandInputDisposition; + /** Detached work whose settle actions are applied independently of the command chain. */ + backgroundTask?: () => Promise; + }; + +export interface SlashCommandEnv { api: RouterClient | null; workspaceId?: string; variant: "workspace" | "creation"; projectPath?: string | null; - openSettings?: (section?: string) => void; - /** Original slash command text as typed, for durable command display. */ rawInput?: string; - /** Current dynamic-workflows experiment assignment for executable workflow commands. */ dynamicWorkflowsEnabled?: boolean; - - // Global Actions - setPreferredModel: (model: string) => void; - setVimEnabled: (cb: (prev: boolean) => boolean) => void; - - // Workspace Actions - onResetContext?: () => Promise<"reset" | "noop">; - onTruncateHistory?: (percentage?: number) => Promise; - resetInputHeight: () => void; - /** Read the latest composer text so async command failures don't overwrite newer drafts. */ - getInput?: () => string; - /** Token identifying the command invocation that launched async follow-up work. */ - asyncCommandToken?: number; - /** Return false when an async command completion belongs to a stale workspace/input. */ - isAsyncCommandCurrent?: (token: number, workspaceId: string) => boolean; - /** Callback to trigger message-sent side effects (auto-scroll, auto-background) */ - onMessageSent?: (dispatchMode: QueueDispatchMode) => void; - /** Callback to detach review context from the composer without marking it checked */ - onDetachAllReviews?: () => void; - /** Callback to mark review IDs as checked after successful send */ - onCheckReviews?: (reviewIds: string[]) => void; - /** Review IDs that are attached (for marking as checked on success) */ + currentModel?: string | null; + sendMessageOptions: SendMessageOptions; + attachments?: ChatAttachment[]; + fileParts?: FilePart[]; + reviews?: ReviewNoteData[]; + editMessageId?: string; attachedReviewIds?: string[]; + resetContext?: () => Promise<"reset" | "noop">; + truncateHistory?: (percentage?: number) => Promise; + isCurrent?: () => boolean; +} + +interface WorkspaceCommandEnv extends SlashCommandEnv { + api: RouterClient; + workspaceId: string; +} + +function complete( + inputDisposition: CommandInputDisposition, + actions: CommandAction[] = [], + backgroundTask?: () => Promise +): CommandResult { + return { + kind: "complete", + actions, + inputDisposition, + ...(backgroundTask ? { backgroundTask } : {}), + }; +} + +function phase( + actions: CommandAction[], + continuation: () => Promise +): CommandResult { + return { kind: "phase", actions, continue: continuation }; +} + +function showToast(toast: Toast): CommandAction { + return { type: "show-toast", toast }; } export const WORKFLOW_FREEFORM_ARGS_ERROR_MESSAGE = @@ -291,86 +329,66 @@ function isWorkspaceOnlyParsedCommand( return WORKSPACE_ONLY_COMMAND_TYPES.has(parsed.type); } -/** - * Process any slash command - * Returns true if the command was handled (even if it failed) - * Returns false if it's not a command (should be sent as message) - though parsed usually implies it is a command - */ +/** Dispatch a parsed slash command into caller-applied result phases. */ export async function processSlashCommand( parsed: ParsedCommand, - context: SlashCommandContext -): Promise { - if (!parsed) return { clearInput: false, toastShown: false }; - const { api: client, setInput, setToast, variant, setVimEnabled, setPreferredModel } = context; - - const requireClient = (): RouterClient | null => { - if (client) return client; - setToast({ - id: Date.now().toString(), - type: "error", - message: "Not connected to server", - }); - return null; - }; + env: SlashCommandEnv +): Promise { + if (!parsed) return complete("restore"); + const client = env.api; + const notConnected = () => + complete("restore", [ + showToast({ id: Date.now().toString(), type: "error", message: "Not connected to server" }), + ]); - // 1. Global Commands if (parsed.type === "model-set") { - const modelString = parsed.modelString; - - const activeClient = client; - const normalized = normalizeModelInput(modelString); - + const normalized = normalizeModelInput(parsed.modelString); if (!normalized.model) { - setToast({ - id: Date.now().toString(), - type: "error", - message: `Invalid model format: expected "provider:model"`, - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: 'Invalid model format: expected "provider:model"', + }), + ]); } - const selectedModel = normalized.model; const separatorIndex = selectedModel.indexOf(":"); const provider = selectedModel.slice(0, separatorIndex); const modelId = selectedModel.slice(separatorIndex + 1); const canonicalModel = normalizeToCanonical(selectedModel); const explicitGateway = getExplicitGatewayPrefix(selectedModel); - try { let providersConfig: ProvidersConfigMap | null = null; let providersConfigLoadFailed = false; - if (activeClient) { + if (client) { try { - providersConfig = await activeClient.providers.getConfig(); + providersConfig = await client.providers.getConfig(); } catch (error) { providersConfigLoadFailed = true; console.error("Failed to load provider settings:", error); } } - const providerConfig = providersConfig?.[provider]; if (!isValidProvider(provider) && !isCustomProviderConfig(providerConfig)) { - setToast({ - id: Date.now().toString(), - type: "error", - message: providersConfigLoadFailed - ? `Could not verify provider "${provider}": backend unreachable. Please retry.` - : `Unknown provider "${provider}"`, - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: providersConfigLoadFailed + ? 'Could not verify provider "' + provider + '": backend unreachable. Please retry.' + : 'Unknown provider "' + provider + '"', + }), + ]); } - if ( !modelHasPricingData(selectedModel, providersConfig ?? null) && - (await hasBudgetedResumableGoalForWorkspaceModelSwitch(context)) + (await hasBudgetedResumableGoalForWorkspaceModelSwitch(env)) ) { - showUnpricedModelGoalToast(setToast, "target"); - return { clearInput: false, toastShown: true }; + return complete("restore", [showToast(createUnpricedModelGoalToast("target"))]); } - - // Align with settings behavior: only persist non-built-in direct-provider models. if ( - activeClient && + client && providersConfig && !BUILT_IN_MODEL_SET.has(canonicalModel) && !explicitGateway @@ -378,265 +396,255 @@ export async function processSlashCommand( try { const existingModels: ProviderModelEntry[] = providerConfig?.models ?? []; if (!existingModels.some((entry) => getProviderModelEntryId(entry) === modelId)) { - // Add model via the same API as settings - await activeClient.providers.setModels({ - provider, - models: [...existingModels, modelId], - }); + await client.providers.setModels({ provider, models: [...existingModels, modelId] }); } } catch (error) { console.error("Failed to sync model settings:", error); } } - - setInput(""); - setPreferredModel(selectedModel); trackCommandUsed("model"); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Model changed to ${selectedModel}`, - }); - return { clearInput: true, toastShown: true }; + return complete("consume", [ + { type: "clear-input" }, + { type: "set-preferred-model", model: selectedModel }, + showToast({ + id: Date.now().toString(), + type: "success", + message: "Model changed to " + selectedModel, + }), + ]); } catch (error) { console.error("Failed to update model:", error); - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to update model", - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to update model", + }), + ]); } } - // model-oneshot ("/ ...") is handled directly in ChatInput. - // This keeps the command parsing centralized, but routes actual sending through the - // normal message-send flow (so side effects like review completion and last-read - // tracking can't drift). - if (parsed.type === "model-oneshot") { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Model one-shot is handled in the chat input.", - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "Model one-shot is handled in the chat input.", + }), + ]); } if (parsed.type === "workflow-run") { const workflowsEnabled = - context.dynamicWorkflowsEnabled ?? - isExperimentEnabled(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS) === true; + env.dynamicWorkflowsEnabled ?? isExperimentEnabled(EXPERIMENT_IDS.DYNAMIC_WORKFLOWS) === true; if (!workflowsEnabled) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Dynamic workflows are disabled", - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "Dynamic workflows are disabled", + }), + ]); } - - const activeClient = requireClient(); - if (!activeClient) { - return { clearInput: false, toastShown: true }; + if (!client) return notConnected(); + if (!env.workspaceId) { + return complete("restore", [ + showToast({ id: Date.now().toString(), type: "error", message: "No workspace selected" }), + ]); } - if (!context.workspaceId) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "No workspace selected", - }); - return { clearInput: false, toastShown: true }; - } - let args: unknown; try { args = parseWorkflowSlashArgs(parsed.argsText); } catch (error) { - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Invalid workflow arguments", - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Invalid workflow arguments", + }), + ]); } - - const workspaceId = context.workspaceId; + const workspaceId = env.workspaceId; const scriptPath = parsed.scriptPath; - const rawInput = context.rawInput?.trim(); - const rawCommand = rawInput && rawInput.length > 0 ? rawInput : `/${scriptPath}`; - const commandPrefix = rawCommand.split(/\s+/u)[0] ?? `/${scriptPath}`; - const isCurrent = - context.asyncCommandToken != null && context.isAsyncCommandCurrent != null - ? () => context.isAsyncCommandCurrent?.(context.asyncCommandToken!, workspaceId) !== false - : undefined; - - setInput(""); + const rawInput = env.rawInput?.trim(); + const rawCommand = rawInput && rawInput.length > 0 ? rawInput : "/" + scriptPath; + const commandPrefix = rawCommand.split(/\s+/u)[0] ?? "/" + scriptPath; let sendingStateActive = false; - const setWorkflowSendingState = (active: boolean) => { - if (sendingStateActive === active) { - return; - } - sendingStateActive = active; - context.setSendingState(active); + const setWorkflowSending = (sending: boolean): CommandAction[] => { + if (sendingStateActive === sending) return []; + sendingStateActive = sending; + return [{ type: "set-sending", sending }]; }; - - setWorkflowSendingState(true); - try { - const result = await activeClient.workflows.start({ - workspaceId, - scriptPath, - runInBackground: true, - args, - continuationOptions: context.sendMessageOptions, - rawCommand, - }); - // The workflow is durable and backgrounded; do not pin the composer while polling for - // completion, otherwise the user cannot supersede a long-running slash workflow. - setWorkflowSendingState(false); - if (result.invocationMessagePersisted === true) { - trackCommandUsed("workflow"); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Workflow ${scriptPath} started`, + return phase([{ type: "clear-input" }, ...setWorkflowSending(true)], async () => { + try { + const result = await client.workflows.start({ + workspaceId, + scriptPath, + runInBackground: true, + args, + continuationOptions: env.sendMessageOptions, + rawCommand, }); - return { clearInput: true, toastShown: true }; - } - const run = await waitForWorkflowTerminalRun({ - client: activeClient, - workspaceId, - runId: result.runId, - initialStatus: result.status, - isCurrent, - }); - const terminalStatus = run?.status ?? result.status; - if (terminalStatus === "interrupted") { - trackCommandUsed("workflow"); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Workflow ${scriptPath} interrupted`, + const stoppedActions = setWorkflowSending(false); + if (result.invocationMessagePersisted === true) { + trackCommandUsed("workflow"); + return complete("consume", [ + ...stoppedActions, + showToast({ + id: Date.now().toString(), + type: "success", + message: "Workflow " + scriptPath + " started", + }), + ]); + } + return phase(stoppedActions, async () => { + try { + const run = await waitForWorkflowTerminalRun({ + client, + workspaceId, + runId: result.runId, + initialStatus: result.status, + isCurrent: env.isCurrent, + }); + const terminalStatus = run?.status ?? result.status; + if (terminalStatus === "interrupted") { + trackCommandUsed("workflow"); + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: "Workflow " + scriptPath + " interrupted", + }), + ]); + } + const workflowResultMessage = buildWorkflowResultContextMessage({ + rawCommand, + name: scriptPath, + runId: result.runId, + status: terminalStatus, + result: result.result, + run, + }); + return phase(setWorkflowSending(true), async () => { + try { + const sendResult = await client.workspace.sendMessage({ + workspaceId, + message: workflowResultMessage, + options: { + ...env.sendMessageOptions, + muxMetadata: { + type: WORKFLOW_RESULT_METADATA_TYPE, + rawCommand, + commandPrefix, + runId: result.runId, + requestedModel: env.sendMessageOptions.model, + }, + }, + }); + if (!sendResult.success) { + throw new Error("Failed to send workflow result to the agent"); + } + trackCommandUsed("workflow"); + return complete("consume", [ + ...setWorkflowSending(false), + { + type: "message-sent", + dispatchMode: env.sendMessageOptions.queueDispatchMode ?? "tool-end", + }, + showToast({ + id: Date.now().toString(), + type: "success", + message: "Workflow " + scriptPath + " " + terminalStatus, + }), + ]); + } catch (error) { + return complete("restore-if-empty", [ + ...setWorkflowSending(false), + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to run workflow", + }), + ]); + } + }); + } catch (error) { + if (error instanceof Error && error.message === WORKFLOW_COMMAND_SUPERSEDED_MESSAGE) { + return complete("consume"); + } + return complete("restore-if-empty", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to run workflow", + }), + ]); + } }); - return { clearInput: true, toastShown: true }; - } - const workflowResultMessage = buildWorkflowResultContextMessage({ - rawCommand, - name: scriptPath, - runId: result.runId, - status: terminalStatus, - result: result.result, - run, - }); - // Keep workflow outputs model-visible but UI-hidden: rawCommand drives transcript display, - // while the XML block below gives the main agent the completed workflow result. - setWorkflowSendingState(true); - const sendResult = await activeClient.workspace.sendMessage({ - workspaceId, - message: workflowResultMessage, - options: { - ...context.sendMessageOptions, - muxMetadata: { - type: WORKFLOW_RESULT_METADATA_TYPE, - rawCommand, - commandPrefix, - runId: result.runId, - requestedModel: context.sendMessageOptions.model, - }, - }, - }); - if (!sendResult.success) { - throw new Error("Failed to send workflow result to the agent"); - } - context.onMessageSent?.(context.sendMessageOptions.queueDispatchMode ?? "tool-end"); - trackCommandUsed("workflow"); - setToast({ - id: Date.now().toString(), - type: "success", - message: `Workflow ${scriptPath} ${terminalStatus}`, - }); - return { clearInput: true, toastShown: true }; - } catch (error) { - if (error instanceof Error && error.message === WORKFLOW_COMMAND_SUPERSEDED_MESSAGE) { - return { clearInput: true, toastShown: false }; + } catch (error) { + return complete("restore-if-empty", [ + ...setWorkflowSending(false), + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to run workflow", + }), + ]); } - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to run workflow", - }); - const currentInput = context.getInput?.(); - const shouldRestoreCommand = currentInput === undefined || currentInput.trim().length === 0; - return { clearInput: !shouldRestoreCommand, toastShown: true }; - } finally { - setWorkflowSendingState(false); - } + }); } if (parsed.type === "debug-llm-request") { - setInput(""); window.dispatchEvent(createCustomEvent(CUSTOM_EVENTS.OPEN_DEBUG_LLM_REQUEST)); - return { clearInput: true, toastShown: false }; + return complete("consume", [{ type: "clear-input" }]); } if (parsed.type === "idle-compaction") { - const activeClient = requireClient(); - if (!activeClient) { - return { clearInput: false, toastShown: true }; - } - - if (!context.projectPath) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "No project selected", - }); - return { clearInput: false, toastShown: true }; + if (!client) return notConnected(); + if (!env.projectPath) { + return complete("restore", [ + showToast({ id: Date.now().toString(), type: "error", message: "No project selected" }), + ]); } - - setInput(""); - - try { - const result = await activeClient.projects.idleCompaction.set({ - projectPath: context.projectPath, - hours: parsed.hours, - }); - - if (!result.success) { - setToast({ - id: Date.now().toString(), - type: "error", - message: result.error ?? "Failed to update setting", + const projectPath = env.projectPath; + return phase([{ type: "clear-input" }], async () => { + try { + const result = await client.projects.idleCompaction.set({ + projectPath, + hours: parsed.hours, }); - return { clearInput: false, toastShown: true }; + if (!result.success) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: result.error ?? "Failed to update setting", + }), + ]); + } + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: parsed.hours + ? "Idle compaction set to " + parsed.hours + " hours" + : "Idle compaction disabled", + }), + ]); + } catch (error) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to update setting", + }), + ]); } - - setToast({ - id: Date.now().toString(), - type: "success", - message: parsed.hours - ? `Idle compaction set to ${parsed.hours} hours` - : "Idle compaction disabled", - }); - return { clearInput: true, toastShown: true }; - } catch (error) { - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to update setting", - }); - return { clearInput: false, toastShown: true }; - } + }); } if (parsed.type === "heartbeat-set") { - const activeClient = requireClient(); - if (!activeClient) { - return { clearInput: false, toastShown: true }; - } - - // Manual /heartbeat invocations stay gated until the experiment is explicitly enabled. - // Guard the experiment check so non-browser test environments treat it as disabled safely. + if (!client) return notConnected(); let heartbeatExperimentEnabled: boolean | undefined; try { heartbeatExperimentEnabled = isExperimentEnabled(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS); @@ -644,94 +652,79 @@ export async function processSlashCommand( heartbeatExperimentEnabled = false; } if (!heartbeatExperimentEnabled) { - setToast({ - id: Date.now().toString(), - type: "error", - message: - "Heartbeat configuration requires the Workspace Heartbeats experiment to be enabled", - }); - return { clearInput: false, toastShown: true }; + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: + "Heartbeat configuration requires the Workspace Heartbeats experiment to be enabled", + }), + ]); } - - if (!context.workspaceId) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "No workspace selected", - }); - return { clearInput: false, toastShown: true }; + if (!env.workspaceId) { + return complete("restore", [ + showToast({ id: Date.now().toString(), type: "error", message: "No workspace selected" }), + ]); } - - setInput(""); - - try { - // Best-effort read: malformed persisted heartbeat settings should not block a command that - // can repair them by writing a fresh interval or disabling the feature. - let currentHeartbeatSettings: Awaited< - ReturnType - > | null = null; + const workspaceId = env.workspaceId; + return phase([{ type: "clear-input" }], async () => { try { - currentHeartbeatSettings = await activeClient.workspace.heartbeat.get({ - workspaceId: context.workspaceId, - }); - } catch { - currentHeartbeatSettings = null; - } - - // Preserve the stored cadence when toggling heartbeats off so re-enabling restores it, - // and keep any saved custom heartbeat message when commands only change cadence. - const intervalMs = - parsed.minutes === null - ? (currentHeartbeatSettings?.intervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS) - : parsed.minutes * 60 * 1000; - const result = await activeClient.workspace.heartbeat.set({ - workspaceId: context.workspaceId, - enabled: parsed.minutes !== null, - intervalMs, - // Omit message when the best-effort read failed; WorkspaceService preserves the - // persisted custom message when this field is absent. - ...(currentHeartbeatSettings?.message != null - ? { message: currentHeartbeatSettings.message } - : {}), - }); - - if (!result.success) { - setToast({ - id: Date.now().toString(), - type: "error", - message: result.error ?? "Failed to update setting", + let currentHeartbeatSettings: Awaited< + ReturnType + > | null = null; + try { + currentHeartbeatSettings = await client.workspace.heartbeat.get({ workspaceId }); + } catch { + currentHeartbeatSettings = null; + } + const intervalMs = + parsed.minutes === null + ? (currentHeartbeatSettings?.intervalMs ?? HEARTBEAT_DEFAULT_INTERVAL_MS) + : parsed.minutes * 60 * 1000; + const result = await client.workspace.heartbeat.set({ + workspaceId, + enabled: parsed.minutes !== null, + intervalMs, + ...(currentHeartbeatSettings?.message != null + ? { message: currentHeartbeatSettings.message } + : {}), }); - return { clearInput: false, toastShown: true }; + if (!result.success) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: result.error ?? "Failed to update setting", + }), + ]); + } + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: + parsed.minutes === null + ? "Heartbeat disabled" + : "Heartbeat set to every " + parsed.minutes + " minutes", + }), + ]); + } catch (error) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to update setting", + }), + ]); } - - setToast({ - id: Date.now().toString(), - type: "success", - message: - parsed.minutes === null - ? "Heartbeat disabled" - : `Heartbeat set to every ${parsed.minutes} minutes`, - }); - return { clearInput: true, toastShown: true }; - } catch (error) { - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to update setting", - }); - return { clearInput: false, toastShown: true }; - } + }); } if (parsed.type === "vim-toggle") { - setInput(""); - setVimEnabled((prev) => !prev); trackCommandUsed("vim"); - return { clearInput: true, toastShown: false }; + return complete("consume", [{ type: "clear-input" }, { type: "toggle-vim" }]); } - // 2. Workspace Commands - // Use command keys for help/invalid variants so creation mode doesn't surface workspace-only help text. const workspaceOnlyKey = (() => { switch (parsed.type) { case "command-missing-args": @@ -743,216 +736,153 @@ export async function processSlashCommand( return null; } })(); - const isWorkspaceCommandType = isWorkspaceOnlyParsedCommand(parsed); const isWorkspaceOnlyCommand = isWorkspaceCommandType || (workspaceOnlyKey ? WORKSPACE_ONLY_COMMAND_KEYS.has(workspaceOnlyKey) : false); - - if (isWorkspaceOnlyCommand && variant !== "workspace") { - setToast({ - id: Date.now().toString(), - type: "error", - message: "Command not available during workspace creation", - }); - return { clearInput: false, toastShown: true }; + if (isWorkspaceOnlyCommand && env.variant !== "workspace") { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "Command not available during workspace creation", + }), + ]); } if (isWorkspaceCommandType) { - // Dispatch workspace commands switch (parsed.type) { case "clear": - return handleClearCommand(parsed, context); + return handleClearCommand(parsed, env); case "compact": - // handleCompactCommand expects workspaceId in context - if (!context.workspaceId) throw new Error("Workspace ID required"); - if (!requireClient()) { - return { clearInput: false, toastShown: true }; - } - return handleCompactCommand(parsed, { - ...context, - api: client, - workspaceId: context.workspaceId, - } as CommandHandlerContext); + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + return handleCompactCommand(parsed, { ...env, api: client, workspaceId: env.workspaceId }); case "dream": { - if (!context.workspaceId) throw new Error("Workspace ID required"); - const dreamClient = requireClient(); - if (!dreamClient) { - return { clearInput: false, toastShown: true }; - } - // Fire-and-forget by design (PRD #3534): the dream run is background - // housekeeping; results surface in the Memory tab, not the chat. The - // only toast is the settle toast — an optimistic "started" success - // toast would flash green-then-red whenever the backend rejects - // immediately (experiment off, debounced, run already in flight). - const dreamWorkspaceId = context.workspaceId; - void dreamClient.memory - .consolidate({ workspaceId: dreamWorkspaceId }) - .then((result) => { - // "Changes" counts applied ops only; the journal also records - // rejected/failed commands, which are not changes. - const applied = result.success ? result.data.ops.filter((op) => op.applied).length : 0; - context.setToast( - result.success - ? { - id: Date.now().toString(), - type: "success", - message: - applied === 0 - ? "Memory consolidation: no changes needed" - : `Memory consolidated: ${applied} change(s)`, - } - : { - id: Date.now().toString(), - type: "error", - message: `Memory consolidation failed: ${result.error}`, - } - ); - }) - .catch((error: unknown) => { - context.setToast({ - id: Date.now().toString(), - type: "error", - message: `Memory consolidation failed: ${String(error)}`, - }); - }); - return { clearInput: true, toastShown: true }; + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + const workspaceId = env.workspaceId; + return complete("consume", [], async () => { + try { + const result = await client.memory.consolidate({ workspaceId }); + const applied = result.success + ? result.data.ops.filter((operation) => operation.applied).length + : 0; + return [ + showToast( + result.success + ? { + id: Date.now().toString(), + type: "success", + message: + applied === 0 + ? "Memory consolidation: no changes needed" + : "Memory consolidated: " + applied + " change(s)", + } + : { + id: Date.now().toString(), + type: "error", + message: "Memory consolidation failed: " + result.error, + } + ), + ]; + } catch (error) { + return [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "Memory consolidation failed: " + String(error), + }), + ]; + } + }); } case "refine": { - if (!context.workspaceId) throw new Error("Workspace ID required"); - const refineClient = requireClient(); - if (!refineClient) { - return { clearInput: false, toastShown: true }; - } - // Fire-and-forget like /dream: the pass runs in the background and - // posts its own labeled summary row into the chat when edits were - // staged/applied. Only the settle toast is shown — an optimistic - // "started" toast would flash green-then-red when the backend rejects - // immediately (RLM off, run already in flight). Plain /refine only - // STAGES edits (security: model output is never auto-applied); - // /refine apply is the explicit approval step. - const refineWorkspaceId = context.workspaceId; - const refineApply = parsed.apply === true; - // Ride the renderer's effective experiment flags with the request: - // backend override persistence is asynchronous/best-effort, so a - // backend-only gate could refuse /refine while this client already - // offers the command and runs with the RLM kernel. - const refineExperiments = context.sendMessageOptions.experiments; - // r64: bind approval to the proposal THIS window rendered. The shared - // transcript can hold a newer foreign proposal (second app instance - // over the same root) that this renderer never displayed; the backend - // refuses to apply when the staged set no longer hashes to the - // proposal we send here. - const displayedProposalHash = refineApply - ? getDisplayedRefineProposalHash(refineWorkspaceId) - : null; - if (refineApply && displayedProposalHash === null) { - context.setToast({ - id: Date.now().toString(), - type: "error", - message: - "Refine failed: no staged /refine proposal is visible in this chat; run /refine first", - }); - return { clearInput: true, toastShown: true }; + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + const workspaceId = env.workspaceId; + const apply = parsed.apply === true; + const displayedProposalHash = apply ? getDisplayedRefineProposalHash(workspaceId) : null; + if (apply && displayedProposalHash === null) { + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: + "Refine failed: no staged /refine proposal is visible in this chat; run /refine first", + }), + ]); } - void ( - refineApply && displayedProposalHash !== null - ? refineClient.refinements.apply({ - workspaceId: refineWorkspaceId, - approvedProposalHash: displayedProposalHash, - experiments: refineExperiments, - }) - : refineClient.refinements.run({ - workspaceId: refineWorkspaceId, - experiments: refineExperiments, - }) - ) - .then((result) => { - // untrackedApplied: edits that succeeded but could not be - // journaled (no rollback id) — still real, so counted. + const experiments = env.sendMessageOptions.experiments; + return complete("consume", [], async () => { + try { + const result = + apply && displayedProposalHash !== null + ? await client.refinements.apply({ + workspaceId, + approvedProposalHash: displayedProposalHash, + experiments, + }) + : await client.refinements.run({ workspaceId, experiments }); const appliedCount = result.success ? result.data.applied.length + (result.data.untrackedApplied ?? 0) : 0; const failedCount = result.success ? (result.data.failed?.length ?? 0) : 0; - // r55: an apply where every edit failed (e.g. all staged targets - // changed) returns success:true with zero applied edits — a green - // "0 edit(s) applied, N failed" toast would read like the - // approved changes landed. Surface it as an error instead. const allFailed = - result.success && - refineApply && - !result.data.noOp && - appliedCount === 0 && - failedCount > 0; - context.setToast( - result.success - ? { - id: Date.now().toString(), - type: allFailed ? "error" : "success", - message: result.data.noOp - ? refineApply - ? "Refine: nothing was applied" - : "Refine: nothing worth distilling" - : refineApply - ? `Refine: ${appliedCount} edit(s) applied${ - failedCount > 0 ? `, ${failedCount} failed` : "" - } (see chat summary)` - : `Refine: ${result.data.staged?.length ?? 0} edit(s) staged — approve with /refine apply`, - } - : { - id: Date.now().toString(), - type: "error", - message: `Refine failed: ${result.error}`, - } - ); - }) - .catch((error: unknown) => { - context.setToast({ - id: Date.now().toString(), - type: "error", - message: `Refine failed: ${String(error)}`, - }); - }); - return { clearInput: true, toastShown: true }; + result.success && apply && !result.data.noOp && appliedCount === 0 && failedCount > 0; + return [ + showToast( + result.success + ? { + id: Date.now().toString(), + type: allFailed ? "error" : "success", + message: result.data.noOp + ? apply + ? "Refine: nothing was applied" + : "Refine: nothing worth distilling" + : apply + ? "Refine: " + + appliedCount + + " edit(s) applied" + + (failedCount > 0 ? ", " + failedCount + " failed" : "") + + " (see chat summary)" + : "Refine: " + + (result.data.staged?.length ?? 0) + + " edit(s) staged — approve with /refine apply", + } + : { + id: Date.now().toString(), + type: "error", + message: "Refine failed: " + result.error, + } + ), + ]; + } catch (error) { + return [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "Refine failed: " + String(error), + }), + ]; + } + }); } case "fork": - if (!requireClient()) { - return { clearInput: false, toastShown: true }; - } - return handleForkCommand(parsed, { - ...context, - api: client, - }); + if (!client) return notConnected(); + return handleForkCommand(parsed, { ...env, api: client }); case "new": - if (!context.workspaceId) throw new Error("Workspace ID required"); - if (!requireClient()) { - return { clearInput: false, toastShown: true }; - } - return handleNewCommand(parsed, { - ...context, - api: client, - workspaceId: context.workspaceId, - } as CommandHandlerContext); + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + return handleNewCommand(parsed, { ...env, api: client, workspaceId: env.workspaceId }); case "plan-show": - if (!context.workspaceId) throw new Error("Workspace ID required"); - if (!requireClient()) { - return { clearInput: false, toastShown: true }; - } - return handlePlanShowCommand({ - ...context, - api: client, - workspaceId: context.workspaceId, - } as CommandHandlerContext); + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + return handlePlanShowCommand({ ...env, api: client, workspaceId: env.workspaceId }); case "plan-open": - if (!context.workspaceId) throw new Error("Workspace ID required"); - if (!requireClient()) { - return { clearInput: false, toastShown: true }; - } - return handlePlanOpenCommand({ - ...context, - api: client, - workspaceId: context.workspaceId, - } as CommandHandlerContext); + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + return handlePlanOpenCommand({ ...env, api: client, workspaceId: env.workspaceId }); case "goal-show": case "goal-set": case "goal-budget": @@ -960,30 +890,15 @@ export async function processSlashCommand( case "goal-resume": case "goal-complete": case "goal-clear": - if (!context.workspaceId) throw new Error("Workspace ID required"); - if (!requireClient()) { - return { clearInput: false, toastShown: true }; - } - return handleGoalCommand(parsed, { - ...context, - api: client, - workspaceId: context.workspaceId, - } as CommandHandlerContext); - // No default: parsed is narrowed to workspace-only commands (minus - // workflow-run/heartbeat-set, which returned above), so adding a type - // to WORKSPACE_ONLY_COMMAND_TYPE_LIST without a case fails the - // switch-exhaustiveness lint here. + if (!env.workspaceId) throw new Error("Workspace ID required"); + if (!client) return notConnected(); + return handleGoalCommand(parsed, { ...env, api: client, workspaceId: env.workspaceId }); } } - // 3. Fallback / Help / Unknown const commandToast = createCommandToast(parsed); - if (commandToast) { - setToast(commandToast); - return { clearInput: false, toastShown: true }; - } - - return { clearInput: false, toastShown: false }; + if (commandToast) return complete("restore", [showToast(commandToast)]); + return complete("restore"); } // ============================================================================ @@ -1008,35 +923,22 @@ type GoalSetCommandResult = | { success: false; error: GoalSetError }; async function setGoalWithSingleConflictRetry( - context: CommandHandlerContext, + env: WorkspaceCommandEnv, intent: GoalSetCommandIntent ): Promise { - // Shared retry helper centralized in `@/browser/utils/goals/` to avoid the - // three-way drift Coder-agents-review P3 DEREM-25 flagged. Adapts the raw - // API result to the typed `GoalSetCommandResult` this caller exposes. - const result = await setGoalWithConflictRetry(context.api, context.workspaceId, intent); - if (result.success) { - return { success: true, goal: result.data }; - } + const result = await setGoalWithConflictRetry(env.api, env.workspaceId, intent); + if (result.success) return { success: true, goal: result.data }; return { success: false, error: result.error }; } -async function getGoalDefaults(context: CommandHandlerContext): Promise { - // Centralized in `@/browser/utils/goals/` so the slash command path and - // the command palette path read defaults the same way (Coder-agents- - // review P3 DEREM-27). Pass the workspaceId so the helper layers any - // per-workspace override on top of the global default — workspace rules - // win for `/goal` invocations inside that workspace. - return loadGoalDefaults(context.api, context.workspaceId); +async function getGoalDefaults(env: WorkspaceCommandEnv): Promise { + return loadGoalDefaults(env.api, env.workspaceId); } function resolveSlashGoalSetIntent( parsed: Extract, defaults: GoalDefaults ): GoalSetCommandIntent { - // The slash command's parser leaves `budgetCents`/`turnCap` undefined - // when omitted (rather than `null`), so we forward as-is to the shared - // resolver which treats `undefined` as "apply default". return resolveGoalSetIntent( { objective: parsed.objective, @@ -1048,42 +950,36 @@ function resolveSlashGoalSetIntent( } async function hasBudgetedResumableGoalForWorkspaceModelSwitch( - context: SlashCommandContext + env: SlashCommandEnv ): Promise { - if (context.variant !== "workspace" || !context.api || !context.workspaceId) { - return false; - } - + if (env.variant !== "workspace" || !env.api || !env.workspaceId) return false; try { - const result = await context.api.workspace.getGoal({ workspaceId: context.workspaceId }); + const result = await env.api.workspace.getGoal({ workspaceId: env.workspaceId }); return hasBudgetedResumableGoal(result.goal); } catch { return false; } } -async function currentModelHasPricingData(context: CommandHandlerContext): Promise { +async function currentModelHasPricingData(env: WorkspaceCommandEnv): Promise { let providersConfig: unknown = null; try { - providersConfig = await context.api.providers.getConfig(); + providersConfig = await env.api.providers.getConfig(); } catch { providersConfig = null; } - return modelHasPricingData(context.sendMessageOptions.model, providersConfig); + return modelHasPricingData(env.sendMessageOptions.model, providersConfig); } -function showUnpricedModelGoalToast( - setToast: (toast: Toast) => void, - modelPosition: "current" | "target" = "current" -): void { - setToast({ +function createUnpricedModelGoalToast(modelPosition: "current" | "target" = "current"): Toast { + return { id: Date.now().toString(), type: "error", message: modelPosition === "current" ? UNPRICED_CURRENT_MODEL_GOAL_MESSAGE : UNPRICED_TARGET_MODEL_GOAL_MESSAGE, - }); + }; } function getGoalSetErrorMessage(error: GoalSetError): string { @@ -1093,15 +989,15 @@ function getGoalSetErrorMessage(error: GoalSetError): string { return error.message; } -function showGoalSetErrorToast(setToast: (toast: Toast) => void, error: GoalSetError): void { - setToast({ +function createGoalSetErrorToast(error: GoalSetError): Toast { + return { id: Date.now().toString(), type: "error", message: getGoalSetErrorMessage(error), - }); + }; } -async function handleGoalCommand( +function handleGoalCommand( parsed: Extract< ParsedCommand, { @@ -1115,277 +1011,260 @@ async function handleGoalCommand( | "goal-clear"; } >, - context: CommandHandlerContext -): Promise { - const { api, workspaceId, setInput, setToast } = context; - - setInput(""); - - try { - if (parsed.type === "goal-show") { - const result = await api.workspace.getGoal({ workspaceId }); - if (result.goal) { - window.dispatchEvent?.(createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId })); - return { clearInput: true, toastShown: false }; - } - - setToast({ - id: Date.now().toString(), - type: "success", - message: "No goal is set. Use /goal to create one.", - }); - return { clearInput: true, toastShown: true }; - } - - if (parsed.type === "goal-pause") { - const result = await setGoalWithSingleConflictRetry(context, { status: "paused" }); - if (!result.success) { - showGoalSetErrorToast(setToast, result.error); - return { clearInput: false, toastShown: true }; + env: WorkspaceCommandEnv +): CommandResult { + return phase([{ type: "clear-input" }], async () => { + try { + if (parsed.type === "goal-show") { + const result = await env.api.workspace.getGoal({ workspaceId: env.workspaceId }); + if (result.goal) { + window.dispatchEvent?.( + createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId: env.workspaceId }) + ); + return complete("consume"); + } + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: "No goal is set. Use /goal to create one.", + }), + ]); } - setToast({ id: Date.now().toString(), type: "success", message: "Goal paused" }); - trackCommandUsed("goal"); - return { clearInput: true, toastShown: true }; - } - if (parsed.type === "goal-resume") { - const currentGoal = await api.workspace.getGoal({ workspaceId }); - if ( - hasBudgetedResumableGoal(currentGoal.goal) && - !(await currentModelHasPricingData(context)) - ) { - showUnpricedModelGoalToast(setToast); - return { clearInput: false, toastShown: true }; + if (parsed.type === "goal-pause") { + const result = await setGoalWithSingleConflictRetry(env, { status: "paused" }); + if (!result.success) { + return complete("restore", [showToast(createGoalSetErrorToast(result.error))]); + } + trackCommandUsed("goal"); + return complete("consume", [ + showToast({ id: Date.now().toString(), type: "success", message: "Goal paused" }), + ]); } - const result = await setGoalWithSingleConflictRetry(context, { status: "active" }); - if (!result.success) { - showGoalSetErrorToast(setToast, result.error); - return { clearInput: false, toastShown: true }; + if (parsed.type === "goal-resume") { + const currentGoal = await env.api.workspace.getGoal({ workspaceId: env.workspaceId }); + if ( + hasBudgetedResumableGoal(currentGoal.goal) && + !(await currentModelHasPricingData(env)) + ) { + return complete("restore", [showToast(createUnpricedModelGoalToast())]); + } + const result = await setGoalWithSingleConflictRetry(env, { status: "active" }); + if (!result.success) { + return complete("restore", [showToast(createGoalSetErrorToast(result.error))]); + } + trackCommandUsed("goal"); + return complete("consume", [ + showToast({ id: Date.now().toString(), type: "success", message: "Goal resumed" }), + ]); } - setToast({ id: Date.now().toString(), type: "success", message: "Goal resumed" }); - trackCommandUsed("goal"); - return { clearInput: true, toastShown: true }; - } - if (parsed.type === "goal-complete") { - if (!parsed.summary) { + if (parsed.type === "goal-complete") { + if (!parsed.summary) { + window.dispatchEvent?.( + createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { + workspaceId: env.workspaceId, + openCompleteInput: true, + }) + ); + return complete("consume"); + } + const result = await setGoalWithSingleConflictRetry(env, { + status: "complete", + completionSummary: parsed.summary, + }); + if (!result.success) { + return complete("restore", [showToast(createGoalSetErrorToast(result.error))]); + } window.dispatchEvent?.( - createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { - workspaceId, - openCompleteInput: true, - }) + createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId: env.workspaceId }) ); - return { clearInput: true, toastShown: false }; + trackCommandUsed("goal"); + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: "Goal marked complete", + }), + ]); } - const result = await setGoalWithSingleConflictRetry(context, { - status: "complete", - completionSummary: parsed.summary, - }); - if (!result.success) { - showGoalSetErrorToast(setToast, result.error); - return { clearInput: false, toastShown: true }; + if (parsed.type === "goal-clear") { + const result = await env.api.workspace.clearGoal({ workspaceId: env.workspaceId }); + trackCommandUsed("goal"); + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: result.cleared ? "Goal cleared" : "No goal was set", + }), + ]); } - setToast({ id: Date.now().toString(), type: "success", message: "Goal marked complete" }); - window.dispatchEvent?.(createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId })); - trackCommandUsed("goal"); - return { clearInput: true, toastShown: true }; - } - if (parsed.type === "goal-clear") { - const result = await api.workspace.clearGoal({ workspaceId }); - setToast({ - id: Date.now().toString(), - type: "success", - message: result.cleared ? "Goal cleared" : "No goal was set", - }); - trackCommandUsed("goal"); - return { clearInput: true, toastShown: true }; - } - - if (parsed.type === "goal-budget") { - if (hasGoalBudgetLimit(parsed.budgetCents) && !(await currentModelHasPricingData(context))) { - showUnpricedModelGoalToast(setToast); - return { clearInput: false, toastShown: true }; + if (parsed.type === "goal-budget") { + if (hasGoalBudgetLimit(parsed.budgetCents) && !(await currentModelHasPricingData(env))) { + return complete("restore", [showToast(createUnpricedModelGoalToast())]); + } + const result = await setGoalWithSingleConflictRetry(env, { + budgetCents: parsed.budgetCents, + }); + if (!result.success) { + return complete("restore", [showToast(createGoalSetErrorToast(result.error))]); + } + window.dispatchEvent?.( + createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId: env.workspaceId }) + ); + trackCommandUsed("goal"); + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: "Goal budget updated", + }), + ]); } - const result = await setGoalWithSingleConflictRetry(context, { - budgetCents: parsed.budgetCents, - }); + const goalDefaults = await getGoalDefaults(env); + const goalSetIntent = resolveSlashGoalSetIntent(parsed, goalDefaults); + if ( + hasGoalBudgetLimit(goalSetIntent.budgetCents) && + !(await currentModelHasPricingData(env)) + ) { + return complete("restore", [showToast(createUnpricedModelGoalToast())]); + } + const result = await setGoalWithSingleConflictRetry(env, goalSetIntent); if (!result.success) { - showGoalSetErrorToast(setToast, result.error); - return { clearInput: false, toastShown: true }; + return complete("restore", [showToast(createGoalSetErrorToast(result.error))]); } - setToast({ id: Date.now().toString(), type: "success", message: "Goal budget updated" }); - window.dispatchEvent?.(createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId })); + window.dispatchEvent?.( + createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId: env.workspaceId }) + ); trackCommandUsed("goal"); - return { clearInput: true, toastShown: true }; - } - - const goalDefaults = await getGoalDefaults(context); - const goalSetIntent = resolveSlashGoalSetIntent(parsed, goalDefaults); - if ( - hasGoalBudgetLimit(goalSetIntent.budgetCents) && - !(await currentModelHasPricingData(context)) - ) { - showUnpricedModelGoalToast(setToast); - return { clearInput: false, toastShown: true }; - } - - const result = await setGoalWithSingleConflictRetry(context, goalSetIntent); - if (!result.success) { - showGoalSetErrorToast(setToast, result.error); - return { clearInput: false, toastShown: true }; + return complete("consume"); + } catch (error) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Goal command failed", + }), + ]); } - window.dispatchEvent?.(createCustomEvent(CUSTOM_EVENTS.OPEN_GOAL_TAB, { workspaceId })); - trackCommandUsed("goal"); - return { clearInput: true, toastShown: false }; - } catch (error) { - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Goal command failed", - }); - return { clearInput: false, toastShown: true }; - } + }); } -async function handleClearCommand( +function handleClearCommand( parsed: Extract, - context: SlashCommandContext -): Promise { - const { - setInput, - setAttachments, - onDetachAllReviews, - onResetContext, - onTruncateHistory, - resetInputHeight, - setToast, - } = context; - + env: SlashCommandEnv +): CommandResult { if (parsed.mode === "soft") { - if (!onResetContext) return { clearInput: true, toastShown: false }; - - try { - const result = await onResetContext(); - setInput(""); - resetInputHeight(); - if (result === "reset") { - setAttachments([]); - onDetachAllReviews?.(); + if (!env.resetContext) return complete("consume"); + return phase([], async () => { + try { + const result = await env.resetContext?.(); + const actions: CommandAction[] = [{ type: "clear-input" }, { type: "reset-input-height" }]; + if (result === "reset") { + actions.push({ type: "clear-attachments" }, { type: "detach-reviews" }); + } + trackCommandUsed("clear:soft"); + actions.push( + showToast({ + id: Date.now().toString(), + type: "success", + message: getContextResetSuccessMessage(result ?? "noop"), + }) + ); + return complete("consume", actions); + } catch (error) { + const normalized = error instanceof Error ? error : new Error("Failed to reset context"); + console.error("Failed to reset context:", normalized); + return complete("restore", [ + showToast({ id: Date.now().toString(), type: "error", message: normalized.message }), + ]); } - trackCommandUsed("clear:soft"); - setToast({ - id: Date.now().toString(), - type: "success", - message: getContextResetSuccessMessage(result), - }); - return { clearInput: true, toastShown: true }; - } catch (error) { - const normalized = error instanceof Error ? error : new Error("Failed to reset context"); - console.error("Failed to reset context:", normalized); - setToast({ - id: Date.now().toString(), - type: "error", - message: normalized.message, - }); - return { clearInput: false, toastShown: true }; - } + }); } - setInput(""); - resetInputHeight(); - - if (!onTruncateHistory) return { clearInput: true, toastShown: false }; - - try { - await onTruncateHistory(1.0); - setAttachments([]); - onDetachAllReviews?.(); - trackCommandUsed("clear:hard"); - setToast({ - id: Date.now().toString(), - type: "success", - message: "Chat history cleared", - }); - return { clearInput: true, toastShown: true }; - } catch (error) { - const normalized = error instanceof Error ? error : new Error("Failed to clear history"); - console.error("Failed to clear history:", normalized); - setToast({ - id: Date.now().toString(), - type: "error", - message: normalized.message, - }); - return { clearInput: false, toastShown: true }; + const initialActions: CommandAction[] = [{ type: "clear-input" }, { type: "reset-input-height" }]; + if (!env.truncateHistory) { + return phase(initialActions, () => Promise.resolve(complete("consume"))); } + return phase(initialActions, async () => { + try { + await env.truncateHistory?.(1.0); + trackCommandUsed("clear:hard"); + return complete("consume", [ + { type: "clear-attachments" }, + { type: "detach-reviews" }, + showToast({ + id: Date.now().toString(), + type: "success", + message: "Chat history cleared", + }), + ]); + } catch (error) { + const normalized = error instanceof Error ? error : new Error("Failed to clear history"); + console.error("Failed to clear history:", normalized); + return complete("restore", [ + showToast({ id: Date.now().toString(), type: "error", message: normalized.message }), + ]); + } + }); } -async function handleForkCommand( +function handleForkCommand( parsed: Extract, - context: SlashCommandContext -): Promise { - const { - api: client, - workspaceId, - sendMessageOptions, - setInput, - setSendingState, - setToast, - } = context; - - setInput(""); // Clear input immediately - setSendingState(true); - - try { - // Note: workspaceId is required for fork, but SlashCommandContext allows undefined workspaceId. - // If we are here, variant === "workspace", so workspaceId should be defined. - if (!workspaceId) throw new Error("Workspace ID required for fork"); - - if (!client) throw new Error("Client required for fork"); - const forkResult = await forkWorkspace({ - client, - sourceWorkspaceId: workspaceId, - startMessage: parsed.startMessage, - sendMessageOptions, - }); - - if (!forkResult.success) { - const errorMsg = forkResult.error ?? "Failed to fork workspace"; - console.error("Failed to fork workspace:", errorMsg); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: errorMsg, + env: SlashCommandEnv & { api: RouterClient } +): CommandResult { + return phase([{ type: "clear-input" }, { type: "set-sending", sending: true }], async () => { + try { + if (!env.workspaceId) throw new Error("Workspace ID required for fork"); + const result = await forkWorkspace({ + client: env.api, + sourceWorkspaceId: env.workspaceId, + startMessage: parsed.startMessage, + sendMessageOptions: env.sendMessageOptions, }); - return { clearInput: false, toastShown: true }; - } else { + if (!result.success) { + const message = result.error ?? "Failed to fork workspace"; + console.error("Failed to fork workspace:", message); + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + title: "Fork Failed", + message, + }), + { type: "set-sending", sending: false }, + ]); + } trackCommandUsed("fork"); const displayName = - forkResult.workspaceInfo?.title ?? forkResult.workspaceInfo?.name ?? "new workspace"; - setToast({ - id: Date.now().toString(), - type: "success", - message: `Forked to workspace "${displayName}"`, - }); - return { clearInput: true, toastShown: true }; + result.workspaceInfo?.title ?? result.workspaceInfo?.name ?? "new workspace"; + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: 'Forked to workspace "' + displayName + '"', + }), + { type: "set-sending", sending: false }, + ]); + } catch (error) { + const normalized = error instanceof Error ? error : new Error("Failed to fork workspace"); + console.error("Fork error:", normalized); + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + title: "Fork Failed", + message: normalized.message, + }), + { type: "set-sending", sending: false }, + ]); } - } catch (error) { - const normalized = error instanceof Error ? error : new Error("Failed to fork workspace"); - console.error("Fork error:", normalized); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Fork Failed", - message: normalized.message, - }); - return { clearInput: false, toastShown: true }; - } finally { - setSendingState(false); - } + }); } /** @@ -1709,300 +1588,231 @@ export async function executeCompaction( return { success: true }; } -// ============================================================================ -// Command Handler Types -// ============================================================================ - -export interface CommandHandlerContext { - api: RouterClient; - workspaceId: string; - currentModel?: string | null; - sendMessageOptions: SendMessageOptions; - attachments?: ChatAttachment[]; - fileParts?: FilePart[]; - /** Reviews attached to the message (from code review panel) */ - reviews?: ReviewNoteData[]; - editMessageId?: string; - setInput: (value: string) => void; - setAttachments: (attachments: ChatAttachment[]) => void; - /** Increment/decrement the sending counter. Pass true to increment, false to decrement. */ - setSendingState: (increment: boolean) => void; - setToast: (toast: Toast) => void; - onCancelEdit?: () => void; -} - -export interface CommandHandlerResult { - /** Whether the input should be cleared */ - clearInput: boolean; - /** Whether to show a toast (already set via context.setToast) */ - toastShown: boolean; -} - -/** - * Handle /new command execution. - * - * Mirrors /fork's seamless flow: no modal, no required workspace name. The - * backend auto-generates a branch name, and when a start message is supplied - * we ask it to fill in the workspace title from that message via - * `pendingAutoTitle`. - */ -export async function handleNewCommand( +/** Handle /new command execution. */ +export function handleNewCommand( parsed: Extract, - context: CommandHandlerContext -): Promise { - const { - api: client, - workspaceId, - sendMessageOptions, - setInput, - setSendingState, - setToast, - } = context; - - setInput(""); // Clear input immediately, like /fork. - setSendingState(true); - - try { - // Get workspace info to extract projectPath. /new is a workspace-only - // command, so the parent workspace's project becomes the new workspace's - // project. - const workspaceInfo = await client.workspace.getInfo({ workspaceId }); - if (!workspaceInfo) { - throw new Error("Failed to get workspace info"); - } - - // Treat blank/whitespace-only payloads the same as no message — pendingAutoTitle - // only makes sense when there is real content for the LLM to title from. - const trimmedStartMessage = parsed.startMessage?.trim() ?? ""; - const startMessage = trimmedStartMessage.length > 0 ? trimmedStartMessage : undefined; - - const createResult = await createNewWorkspace({ - client, - projectPath: workspaceInfo.projectPath, - // workspaceName intentionally omitted — backend auto-generates (like /fork). - startMessage, - sendMessageOptions, - // Match /fork: only flag pendingAutoTitle when there is a message to - // generate the title from. - pendingAutoTitle: Boolean(startMessage), - }); - - if (!createResult.success) { - const errorMsg = createResult.error ?? "Failed to create workspace"; - console.error("Failed to create workspace:", errorMsg); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Create Failed", - message: errorMsg, + env: WorkspaceCommandEnv +): CommandResult { + return phase([{ type: "clear-input" }, { type: "set-sending", sending: true }], async () => { + try { + const workspaceInfo = await env.api.workspace.getInfo({ workspaceId: env.workspaceId }); + if (!workspaceInfo) throw new Error("Failed to get workspace info"); + const trimmedStartMessage = parsed.startMessage?.trim() ?? ""; + const startMessage = trimmedStartMessage.length > 0 ? trimmedStartMessage : undefined; + const result = await createNewWorkspace({ + client: env.api, + projectPath: workspaceInfo.projectPath, + startMessage, + sendMessageOptions: env.sendMessageOptions, + pendingAutoTitle: Boolean(startMessage), }); - return { clearInput: false, toastShown: true }; + if (!result.success) { + const message = result.error ?? "Failed to create workspace"; + console.error("Failed to create workspace:", message); + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + title: "Create Failed", + message, + }), + { type: "set-sending", sending: false }, + ]); + } + trackCommandUsed("new"); + const displayName = + result.workspaceInfo?.title ?? result.workspaceInfo?.name ?? "new workspace"; + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: 'Created workspace "' + displayName + '"', + }), + { type: "set-sending", sending: false }, + ]); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to create workspace"; + console.error("Create error:", error); + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + title: "Create Failed", + message, + }), + { type: "set-sending", sending: false }, + ]); } - - trackCommandUsed("new"); - const displayName = - createResult.workspaceInfo?.title ?? createResult.workspaceInfo?.name ?? "new workspace"; - setToast({ - id: Date.now().toString(), - type: "success", - message: `Created workspace "${displayName}"`, - }); - return { clearInput: true, toastShown: true }; - } catch (error) { - const errorMsg = error instanceof Error ? error.message : "Failed to create workspace"; - console.error("Create error:", error); - setToast({ - id: Date.now().toString(), - type: "error", - title: "Create Failed", - message: errorMsg, - }); - return { clearInput: false, toastShown: true }; - } finally { - setSendingState(false); - } + }); } -/** - * Handle /compact command execution - */ -export async function handleCompactCommand( +/** Handle /compact command execution. */ +export function handleCompactCommand( parsed: Extract, - context: CommandHandlerContext -): Promise { - const { - api, - workspaceId, - sendMessageOptions, - editMessageId, - setInput, - setAttachments, - setSendingState, - setToast, - onCancelEdit, - } = context; - - // normalizeModelInput handles null/empty — returns { model: null } for empty input + env: WorkspaceCommandEnv +): CommandResult { const normalizedModel = normalizeModelInput(parsed.model); - - // Validate model format early - fail fast before sending to backend if (parsed.model && !normalizedModel.model) { - setToast(createInvalidCompactModelToast(parsed.model)); - return { clearInput: false, toastShown: true }; + return complete("restore", [showToast(createInvalidCompactModelToast(parsed.model))]); } - setInput(""); - setAttachments([]); - setSendingState(true); - - try { - // Build followUpContent directly from parsed command + context. - const stagedAttachments = context.attachments ? getStagedAttachments(context.attachments) : []; - const hasContent = - parsed.continueMessage ?? - context.fileParts?.length ?? - context.reviews?.length ?? - stagedAttachments.length; - const followUpContent: CompactionFollowUpInput | undefined = hasContent - ? { - text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", stagedAttachments), - fileParts: context.fileParts, - reviews: context.reviews, + return phase( + [ + { type: "clear-input" }, + { type: "clear-attachments" }, + { type: "set-sending", sending: true }, + ], + async () => { + try { + const stagedAttachments = env.attachments ? getStagedAttachments(env.attachments) : []; + const hasContent = + parsed.continueMessage ?? + env.fileParts?.length ?? + env.reviews?.length ?? + stagedAttachments.length; + const followUpContent: CompactionFollowUpInput | undefined = hasContent + ? { + text: appendStagedAttachmentNotice(parsed.continueMessage ?? "", stagedAttachments), + fileParts: env.fileParts, + reviews: env.reviews, + } + : undefined; + const result = await executeCompaction({ + api: env.api, + workspaceId: env.workspaceId, + maxOutputTokens: parsed.maxOutputTokens, + followUpContent, + model: normalizedModel.model ?? undefined, + sendMessageOptions: env.sendMessageOptions, + editMessageId: env.editMessageId, + }); + if (!result.success) { + console.error("Failed to initiate compaction:", result.error); + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: result.error ?? "Failed to start compaction", + }), + { type: "set-sending", sending: false }, + ]); } - : undefined; - - const resolvedModel = normalizedModel.model ?? undefined; - - const result = await executeCompaction({ - api, - workspaceId, - maxOutputTokens: parsed.maxOutputTokens, - followUpContent, - model: resolvedModel, - sendMessageOptions, - editMessageId, - }); - - if (!result.success) { - console.error("Failed to initiate compaction:", result.error); - const errorMsg = result.error ?? "Failed to start compaction"; - setToast({ - id: Date.now().toString(), - type: "error", - message: errorMsg, - }); - return { clearInput: false, toastShown: true }; - } - - trackCommandUsed("compact"); - setToast({ - id: Date.now().toString(), - type: "success", - message: parsed.continueMessage - ? "Compaction started. Will continue automatically after completion." - : "Compaction started. AI will summarize the conversation.", - }); - - // Clear editing state on success - if (editMessageId && onCancelEdit) { - onCancelEdit(); + trackCommandUsed("compact"); + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: parsed.continueMessage + ? "Compaction started. Will continue automatically after completion." + : "Compaction started. AI will summarize the conversation.", + }), + ...(env.editMessageId ? ([{ type: "cancel-edit" }] satisfies CommandAction[]) : []), + { type: "set-sending", sending: false }, + { type: "check-reviews", reviewIds: env.attachedReviewIds ?? [] }, + { + type: "message-sent", + dispatchMode: env.sendMessageOptions.queueDispatchMode ?? "tool-end", + }, + ]); + } catch (error) { + console.error("Compaction error:", error); + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to start compaction", + }), + { type: "set-sending", sending: false }, + ]); + } } - - return { clearInput: true, toastShown: true }; - } catch (error) { - console.error("Compaction error:", error); - setToast({ - id: Date.now().toString(), - type: "error", - message: error instanceof Error ? error.message : "Failed to start compaction", - }); - return { clearInput: false, toastShown: true }; - } finally { - setSendingState(false); - } -} - -// ============================================================================ -// Plan Command Handlers -// ============================================================================ - -export async function handlePlanShowCommand( - context: CommandHandlerContext -): Promise { - const { api, workspaceId, setInput, setToast } = context; - - setInput(""); - - const result = await api.workspace.getPlanContent({ workspaceId }); - if (!result.success) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "No plan found for this workspace", - }); - return { clearInput: true, toastShown: true }; - } - - // Keep the ephemeral preview a singleton so repeated /plan show calls replace it instead of - // accumulating permanent-looking cards at the transcript bottom. - const planMessage = { - id: "plan-display-preview", - role: "assistant" as const, - parts: [{ type: "text" as const, text: result.data.content }], - metadata: { - historySequence: Number.MAX_SAFE_INTEGER, // Appear at end of chat - muxMetadata: { type: "plan-display" as const, path: result.data.path }, - }, - }; - addEphemeralMessage(workspaceId, planMessage); - - trackCommandUsed("plan"); - return { clearInput: true, toastShown: false }; + ); } -export async function handlePlanOpenCommand( - context: CommandHandlerContext -): Promise { - const { api, workspaceId, setInput, setToast } = context; - - setInput(""); - - // First get the plan path - const planResult = await api.workspace.getPlanContent({ workspaceId }); - if (!planResult.success) { - setToast({ - id: Date.now().toString(), - type: "error", - message: "No plan found for this workspace", - }); - return { clearInput: true, toastShown: true }; - } - - const workspaceInfo = await api.workspace.getInfo({ workspaceId }); - const openResult = await openInEditor({ - api, - workspaceId, - targetPath: planResult.data.path, - runtimeConfig: workspaceInfo?.runtimeConfig, - isFile: true, +export function handlePlanShowCommand(env: WorkspaceCommandEnv): CommandResult { + return phase([{ type: "clear-input" }], async () => { + try { + const result = await env.api.workspace.getPlanContent({ workspaceId: env.workspaceId }); + if (!result.success) { + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "No plan found for this workspace", + }), + ]); + } + addEphemeralMessage(env.workspaceId, { + id: "plan-display-preview", + role: "assistant" as const, + parts: [{ type: "text" as const, text: result.data.content }], + metadata: { + historySequence: Number.MAX_SAFE_INTEGER, + muxMetadata: { type: "plan-display" as const, path: result.data.path }, + }, + }); + trackCommandUsed("plan"); + return complete("consume"); + } catch (error) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to show plan", + }), + ]); + } }); +} - if (!openResult.success) { - setToast({ - id: Date.now().toString(), - type: "error", - message: openResult.error ?? "Failed to open editor", - }); - return { clearInput: true, toastShown: true }; - } - - trackCommandUsed("plan"); - setToast({ - id: Date.now().toString(), - type: "success", - message: "Opened plan in editor", +export function handlePlanOpenCommand(env: WorkspaceCommandEnv): CommandResult { + return phase([{ type: "clear-input" }], async () => { + try { + const planResult = await env.api.workspace.getPlanContent({ workspaceId: env.workspaceId }); + if (!planResult.success) { + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: "No plan found for this workspace", + }), + ]); + } + const workspaceInfo = await env.api.workspace.getInfo({ workspaceId: env.workspaceId }); + const openResult = await openInEditor({ + api: env.api, + workspaceId: env.workspaceId, + targetPath: planResult.data.path, + runtimeConfig: workspaceInfo?.runtimeConfig, + isFile: true, + }); + if (!openResult.success) { + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: openResult.error ?? "Failed to open editor", + }), + ]); + } + trackCommandUsed("plan"); + return complete("consume", [ + showToast({ + id: Date.now().toString(), + type: "success", + message: "Opened plan in editor", + }), + ]); + } catch (error) { + return complete("restore", [ + showToast({ + id: Date.now().toString(), + type: "error", + message: error instanceof Error ? error.message : "Failed to open plan", + }), + ]); + } }); - return { clearInput: true, toastShown: true }; } // ============================================================================ From e46f8d90dc64f96958e0a06a493ca64865608686 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:16:27 +0000 Subject: [PATCH 02/20] refactor(chat): unexport internal command handlers, cover /plan open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up: the four per-command handlers are only reachable through processSlashCommand now, and /plan open lost its dedicated tests in the result-based rewrite. _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/utils/chatCommands.test.ts | 78 ++++++++++++++++++++++++++ src/browser/utils/chatCommands.ts | 8 +-- 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index ecfb11649e4..0eee424d5f4 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -1095,6 +1095,84 @@ describe("compact and plan command results", () => { message: "No plan found for this workspace", }); }); + + test("plan open with no plan consumes with an error toast and skips the editor", async () => { + const getInfo = mock(() => Promise.resolve(null)); + const settled = await finishCommand( + await processSlashCommand( + { type: "plan-open" }, + createEnv({ + api: { + workspace: { + getPlanContent: mock(() => + Promise.resolve({ success: false, error: "No plan found" }) + ), + getInfo, + }, + } as unknown as SlashCommandEnv["api"], + }) + ) + ); + expectDisposition(settled.result, "consume"); + expectToast(settled.result.actions, { + type: "error", + message: "No plan found for this workspace", + }); + expect(getInfo).not.toHaveBeenCalled(); + }); + + test("plan open surfaces an editor-open failure as an error toast", async () => { + const getPlanContent = mock(() => + Promise.resolve({ success: true, data: { content: "# My Plan", path: "/path/to/plan.md" } }) + ); + const getInfo = mock(() => + Promise.resolve({ runtimeConfig: { type: "local" } } as unknown as FrontendWorkspaceMetadata) + ); + // openInEditor opens a blank placeholder window before its awaits; give it a + // live stub so the flow reaches the recordEditorOpen admission check, whose + // refusal is the deterministic failure path independent of deep-link launch. + const windowWithOpen = window as unknown as { open?: (...args: unknown[]) => unknown }; + const previousOpen = windowWithOpen.open; + windowWithOpen.open = () => ({ + closed: false, + close: () => undefined, + location: { href: "" }, + }); + // This suite aliases window to globalThis, which turns the tests/setup.ts + // location getter (window.location fallback) into infinite recursion when + // deep-link code reads location. Pin an own-value location for this test. + const previousLocation = Object.getOwnPropertyDescriptor(globalThis, "location"); + Object.defineProperty(globalThis, "location", { + configurable: true, + value: { href: "http://localhost/", hostname: "localhost" }, + }); + try { + const settled = await finishCommand( + await processSlashCommand( + { type: "plan-open" }, + createEnv({ + api: { + workspace: { getPlanContent, getInfo }, + general: { + recordEditorOpen: mock(() => + Promise.resolve({ success: false, error: "Archive in progress" }) + ), + }, + } as unknown as SlashCommandEnv["api"], + }) + ) + ); + expectDisposition(settled.result, "consume"); + expectToast(settled.result.actions, { type: "error", message: "Archive in progress" }); + expect(getPlanContent).toHaveBeenCalledWith({ workspaceId: "test-ws" }); + expect(getInfo).toHaveBeenCalledWith({ workspaceId: "test-ws" }); + } finally { + windowWithOpen.open = previousOpen; + if (previousLocation) { + Object.defineProperty(globalThis, "location", previousLocation); + } + } + }); }); describe("prepareCompactionMessage", () => { diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 2a46702ee3d..73adfa61785 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -1589,7 +1589,7 @@ export async function executeCompaction( } /** Handle /new command execution. */ -export function handleNewCommand( +function handleNewCommand( parsed: Extract, env: WorkspaceCommandEnv ): CommandResult { @@ -1647,7 +1647,7 @@ export function handleNewCommand( } /** Handle /compact command execution. */ -export function handleCompactCommand( +function handleCompactCommand( parsed: Extract, env: WorkspaceCommandEnv ): CommandResult { @@ -1729,7 +1729,7 @@ export function handleCompactCommand( ); } -export function handlePlanShowCommand(env: WorkspaceCommandEnv): CommandResult { +function handlePlanShowCommand(env: WorkspaceCommandEnv): CommandResult { return phase([{ type: "clear-input" }], async () => { try { const result = await env.api.workspace.getPlanContent({ workspaceId: env.workspaceId }); @@ -1765,7 +1765,7 @@ export function handlePlanShowCommand(env: WorkspaceCommandEnv): CommandResult { }); } -export function handlePlanOpenCommand(env: WorkspaceCommandEnv): CommandResult { +function handlePlanOpenCommand(env: WorkspaceCommandEnv): CommandResult { return phase([{ type: "clear-input" }], async () => { try { const planResult = await env.api.workspace.getPlanContent({ workspaceId: env.workspaceId }); From b6660d2b3c62c43b93204c214f4001f575e99c3e Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:40:52 +0000 Subject: [PATCH 03/20] fix(chat): evaluate input disposition against the live draft MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P1: getDraft captured at command invocation reports that render's input, so async commands cleared newer drafts on consume and never fired restore-if-empty. _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/features/ChatInput/index.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index e713f600d6c..0375e3fa24e 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2554,16 +2554,21 @@ const ChatInputInner: React.FC = (props) => { void result.backgroundTask().then(applyCommandActions); } + // Async command phases can outlive the invoking render, so the disposition + // must be evaluated against the live persisted draft: the getDraft closure + // captured here still reports this render's input and would clear or refuse + // to restore a newer draft typed while the command ran. + const liveDraftText = () => readPersistedState(storageKeys.inputKey, ""); switch (result.inputDisposition) { case "consume": - if (getDraft().text === restoreInput) setInput(""); + if (liveDraftText() === restoreInput) setInput(""); setDraftReviews(null); break; case "restore": setInput(restoreInput); break; case "restore-if-empty": - if (getDraft().text.trim().length === 0) { + if (liveDraftText().trim().length === 0) { setInput(restoreInput); } else { setDraftReviews(null); From 2c12b5ab5fc883d8837259edf2b2e98a368b96c7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:52:32 +0000 Subject: [PATCH 04/20] fix(chat): drop the terminal consume-path composer clear MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2: text equality cannot distinguish a retyped identical draft from the original invocation. Commands already clear through their own clear-input actions (matching trunk), so the terminal clear was additive and could only destroy mid-phase drafts. _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/features/ChatInput/index.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 0375e3fa24e..909ed92d32e 100644 --- a/src/browser/features/ChatInput/index.tsx +++ b/src/browser/features/ChatInput/index.tsx @@ -2554,21 +2554,20 @@ const ChatInputInner: React.FC = (props) => { void result.backgroundTask().then(applyCommandActions); } - // Async command phases can outlive the invoking render, so the disposition - // must be evaluated against the live persisted draft: the getDraft closure - // captured here still reports this render's input and would clear or refuse - // to restore a newer draft typed while the command ran. - const liveDraftText = () => readPersistedState(storageKeys.inputKey, ""); switch (result.inputDisposition) { case "consume": - if (liveDraftText() === restoreInput) setInput(""); + // Commands clear the composer through their own clear-input actions; + // clearing again here would wipe a draft typed while phases ran. setDraftReviews(null); break; case "restore": setInput(restoreInput); break; case "restore-if-empty": - if (liveDraftText().trim().length === 0) { + // Async phases can outlive the invoking render, so check the live + // persisted draft: the getDraft closure captured here still reports + // this render's input and would refuse to restore over a newer draft. + if (readPersistedState(storageKeys.inputKey, "").trim().length === 0) { setInput(restoreInput); } else { setDraftReviews(null); From 1703d30ce5423700d83164a42acefef62e1d9800 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:04:42 +0000 Subject: [PATCH 05/20] fix(chat): clear the composer when detached commands are accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex P2 follow-up: with the terminal consume-path clear gone, /dream and /refine left the executed command re-runnable in the composer. Emit clear-input from the handlers so commands own their composer effects. _Generated with `xum` • Model: `anthropic:claude-fable-5` • Thinking: `xhigh`_ --- src/browser/utils/chatCommands.test.ts | 4 +++- src/browser/utils/chatCommands.ts | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index 0eee424d5f4..b3bee5b49d4 100644 --- a/src/browser/utils/chatCommands.test.ts +++ b/src/browser/utils/chatCommands.test.ts @@ -860,6 +860,7 @@ describe("detached command work", () => { expect(result.kind).toBe("complete"); if (result.kind !== "complete") throw new Error("expected complete result"); expectDisposition(result, "consume"); + expect(result.actions).toEqual([{ type: "clear-input" }]); expect(consolidate).not.toHaveBeenCalled(); const successActions = await result.backgroundTask?.(); expect(successActions).toBeDefined(); @@ -913,7 +914,8 @@ describe("detached command work", () => { if (missingProposal.kind !== "complete") throw new Error("expected complete result"); expectDisposition(missingProposal, "consume"); expect(missingProposal.backgroundTask).toBeUndefined(); - expect(missingProposal.actions[0]).toMatchObject({ + expect(missingProposal.actions[0]).toEqual({ type: "clear-input" }); + expect(missingProposal.actions[1]).toMatchObject({ type: "show-toast", toast: { type: "error" }, }); diff --git a/src/browser/utils/chatCommands.ts b/src/browser/utils/chatCommands.ts index 73adfa61785..383e6a69488 100644 --- a/src/browser/utils/chatCommands.ts +++ b/src/browser/utils/chatCommands.ts @@ -762,7 +762,7 @@ export async function processSlashCommand( if (!env.workspaceId) throw new Error("Workspace ID required"); if (!client) return notConnected(); const workspaceId = env.workspaceId; - return complete("consume", [], async () => { + return complete("consume", [{ type: "clear-input" }], async () => { try { const result = await client.memory.consolidate({ workspaceId }); const applied = result.success @@ -805,6 +805,7 @@ export async function processSlashCommand( const displayedProposalHash = apply ? getDisplayedRefineProposalHash(workspaceId) : null; if (apply && displayedProposalHash === null) { return complete("consume", [ + { type: "clear-input" }, showToast({ id: Date.now().toString(), type: "error", @@ -814,7 +815,7 @@ export async function processSlashCommand( ]); } const experiments = env.sendMessageOptions.experiments; - return complete("consume", [], async () => { + return complete("consume", [{ type: "clear-input" }], async () => { try { const result = apply && displayedProposalHash !== null From 2da65535ee45eb6467873af38c30415064a4e44f Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:10:44 +0000 Subject: [PATCH 06/20] refactor(runtime): deepen path handling --- src/node/runtime/DevcontainerRuntime.test.ts | 32 +++++------ src/node/runtime/DevcontainerRuntime.ts | 52 +++++++++++------- src/node/runtime/LocalBaseRuntime.test.ts | 13 +++++ src/node/runtime/LocalBaseRuntime.ts | 35 ++++++------ src/node/runtime/RemoteRuntime.test.ts | 53 +++++++++++++++++++ src/node/runtime/RemoteRuntime.ts | 47 ++++++++++------ src/node/runtime/Runtime.ts | 13 ++--- src/node/runtime/backgroundCommands.ts | 10 +++- src/node/runtime/shellEnv.ts | 13 +++++ .../backgroundProcessExecutor.test.ts | 18 +++++-- .../services/backgroundProcessExecutor.ts | 23 +++++--- src/node/services/backgroundProcessManager.ts | 2 + src/node/services/hooks.test.ts | 44 ++++++++++----- src/node/services/hooks.ts | 37 +++++++------ src/node/services/streamManager.ts | 2 +- src/node/services/tools/bash.ts | 17 +++--- src/node/utils/runtime/helpers.test.ts | 46 ++++++++-------- src/node/utils/runtime/helpers.ts | 30 +++++------ 18 files changed, 321 insertions(+), 166 deletions(-) diff --git a/src/node/runtime/DevcontainerRuntime.test.ts b/src/node/runtime/DevcontainerRuntime.test.ts index 3f0faa359e7..21675ce7938 100644 --- a/src/node/runtime/DevcontainerRuntime.test.ts +++ b/src/node/runtime/DevcontainerRuntime.test.ts @@ -61,9 +61,12 @@ describe("DevcontainerRuntime.stat", () => { }); const abortController = new AbortController(); - await runtime.stat("/container-only/file.txt", abortController.signal); + await runtime.stat("relative/file.txt", abortController.signal); expect(runtime.execOptions?.abortSignal).toBe(abortController.signal); + expect(runtime.execOptions?.pathEnv).toEqual({ + XUM_INTERNAL_FILE_PATH: "relative/file.txt", + }); }); }); @@ -111,18 +114,6 @@ describe("DevcontainerRuntime.resolvePath", () => { }); }); -describe("DevcontainerRuntime.quoteForContainer", () => { - function quoteForContainer(runtime: DevcontainerRuntime, filePath: string): string { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return - return (runtime as any).quoteForContainer(filePath); - } - - it("uses $HOME expansion for tilde paths", () => { - const runtime = createRuntime({}); - expect(quoteForContainer(runtime, "~/.mux")).toBe('"$HOME/.mux"'); - }); -}); - describe("DevcontainerRuntime.resolveContainerCwd", () => { // Access the private method for testing function resolveContainerCwd( @@ -179,15 +170,20 @@ describe("DevcontainerRuntime.resolveHostPathForMounted", () => { expect(resolveHostPathForMounted(runtime, filePath)).toBe(filePath); }); }); -describe("DevcontainerRuntime.mapPathForExec", () => { +describe("DevcontainerRuntime exec path translation", () => { + function mapPathForExec(runtime: DevcontainerRuntime, filePath: string): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-return + return (runtime as any).mapPathForExec(filePath); + } + it("maps workspace roots and nested paths into the container", () => { const runtime = createRuntime({ remoteWorkspaceFolder: "/workspaces/project", currentWorkspacePath: "/home/user/xum/project/branch", }); - expect(runtime.mapPathForExec("/home/user/xum/project/branch")).toBe("/workspaces/project"); - expect(runtime.mapPathForExec("/home/user/xum/project/branch/nested/file")).toBe( + expect(mapPathForExec(runtime, "/home/user/xum/project/branch")).toBe("/workspaces/project"); + expect(mapPathForExec(runtime, "/home/user/xum/project/branch/nested/file")).toBe( "/workspaces/project/nested/file" ); }); @@ -198,13 +194,13 @@ describe("DevcontainerRuntime.mapPathForExec", () => { currentWorkspacePath: "/home/user/xum/project/branch", }); - expect(runtime.mapPathForExec("/tmp/other")).toBe("/tmp/other"); + expect(mapPathForExec(runtime, "/tmp/other")).toBe("/tmp/other"); }); it("keeps paths unchanged when the container workspace is unknown", () => { const runtime = createRuntime({ currentWorkspacePath: "/home/user/xum/project/branch" }); - expect(runtime.mapPathForExec("/home/user/xum/project/branch/nested/file")).toBe( + expect(mapPathForExec(runtime, "/home/user/xum/project/branch/nested/file")).toBe( "/home/user/xum/project/branch/nested/file" ); }); diff --git a/src/node/runtime/DevcontainerRuntime.ts b/src/node/runtime/DevcontainerRuntime.ts index b96568ffbc7..5994106ab00 100644 --- a/src/node/runtime/DevcontainerRuntime.ts +++ b/src/node/runtime/DevcontainerRuntime.ts @@ -15,9 +15,9 @@ import type { FileStat, } from "./Runtime"; import { RuntimeError, WORKSPACE_REPO_MISSING_ERROR } from "./Runtime"; +import { buildShellPathExport } from "./shellEnv"; import { LocalBaseRuntime } from "./LocalBaseRuntime"; import { WorktreeManager } from "@/node/worktree/WorktreeManager"; -import { expandTildeForSSH } from "./tildeExpansion"; import { shescape, streamToString } from "./streamUtils"; import { readHostGitconfig, @@ -38,6 +38,9 @@ import { log } from "@/node/services/log"; import { isGitRepository, stripTrailingSlashes } from "@/node/utils/pathUtils"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; +const FILE_PATH_ENV = "XUM_INTERNAL_FILE_PATH"; +const TEMP_FILE_PATH_ENV = "XUM_INTERNAL_TEMP_FILE_PATH"; + export interface DevcontainerRuntimeOptions { srcBaseDir: string; configPath: string; @@ -186,13 +189,6 @@ export class DevcontainerRuntime extends LocalBaseRuntime { return this.mapContainerPathToHost(filePath); } - private quoteForContainer(filePath: string): string { - if (filePath === "~" || filePath.startsWith("~/")) { - return expandTildeForSSH(filePath); - } - return shescape.quote(filePath); - } - /** * Expand tilde in file paths for container operations. * Returns unexpanded path when container user is unknown (before ensureReady). @@ -290,8 +286,9 @@ export class DevcontainerRuntime extends LocalBaseRuntime { return new ReadableStream({ start: async (controller) => { try { - const stream = await this.exec(`cat ${this.quoteForContainer(filePath)}`, { + const stream = await this.exec(`cat "$${FILE_PATH_ENV}"`, { cwd: this.getContainerBasePath(), + pathEnv: { [FILE_PATH_ENV]: filePath }, timeout: 300, abortSignal, }); @@ -333,10 +330,8 @@ export class DevcontainerRuntime extends LocalBaseRuntime { filePath: string, abortSignal?: AbortSignal ): WritableStream { - const quotedPath = this.quoteForContainer(filePath); const tempPath = getAtomicWriteTempPath(filePath); - const quotedTempPath = this.quoteForContainer(tempPath); - const writeCommand = `mkdir -p $(dirname ${quotedPath}) && cat > ${quotedTempPath} && mv ${quotedTempPath} ${quotedPath}`; + const writeCommand = `mkdir -p "$(dirname "$${FILE_PATH_ENV}")" && cat > "$${TEMP_FILE_PATH_ENV}" && mv "$${TEMP_FILE_PATH_ENV}" "$${FILE_PATH_ENV}"`; let execPromise: Promise | null = null; const writeAbortController = new AbortController(); @@ -353,6 +348,10 @@ export class DevcontainerRuntime extends LocalBaseRuntime { const getExecStream = () => { execPromise ??= this.exec(writeCommand, { cwd: this.getContainerBasePath(), + pathEnv: { + [FILE_PATH_ENV]: filePath, + [TEMP_FILE_PATH_ENV]: tempPath, + }, timeout: 300, abortSignal: writeAbortController.signal, }); @@ -402,8 +401,9 @@ export class DevcontainerRuntime extends LocalBaseRuntime { } private async ensureDirViaExec(dirPath: string, abortSignal?: AbortSignal): Promise { - const stream = await this.exec(`mkdir -p ${this.quoteForContainer(dirPath)}`, { - cwd: "/", + const stream = await this.exec(`mkdir -p "$${FILE_PATH_ENV}"`, { + cwd: this.getContainerBasePath(), + pathEnv: { [FILE_PATH_ENV]: dirPath }, timeout: 10, abortSignal, }); @@ -427,8 +427,9 @@ export class DevcontainerRuntime extends LocalBaseRuntime { private async statViaExec(filePath: string, abortSignal?: AbortSignal): Promise { // -L follows symlinks so symlinked paths report the target's type - const stream = await this.exec(`stat -L -c '%s %Y %F' ${this.quoteForContainer(filePath)}`, { + const stream = await this.exec(`stat -L -c '%s %Y %F' "$${FILE_PATH_ENV}"`, { cwd: this.getContainerBasePath(), + pathEnv: { [FILE_PATH_ENV]: filePath }, timeout: 10, abortSignal, }); @@ -458,7 +459,7 @@ export class DevcontainerRuntime extends LocalBaseRuntime { isDirectory: fileType === "directory", }; } - mapPathForExec(filePath: string): string { + private mapPathForExec(filePath: string): string { // Issue #3709: paths embedded in exec scripts must use the container namespace. return this.mapHostPathToContainer(filePath) ?? filePath; } @@ -612,7 +613,15 @@ export class DevcontainerRuntime extends LocalBaseRuntime { // Merge cached container credential env + caller env + non-interactive vars. // Spread order: container env (lowest) < caller env < NON_INTERACTIVE (highest). - const envVars = { ...this.containerEnv, ...options.env, ...NON_INTERACTIVE_ENV_VARS }; + const mappedPathEnv = Object.fromEntries( + Object.entries(options.pathEnv ?? {}).map(([key, value]) => [key, this.mapPathForExec(value)]) + ); + const envVars = { + ...this.containerEnv, + ...options.env, + ...mappedPathEnv, + ...NON_INTERACTIVE_ENV_VARS, + }; for (const [key, value] of Object.entries(envVars)) { args.push("--remote-env", `${key}=${value}`); } @@ -621,7 +630,14 @@ export class DevcontainerRuntime extends LocalBaseRuntime { // Map host workspace path to container path; fall back to container workspace if unmappable const mappedCwd = options.cwd ? this.mapHostPathToContainer(options.cwd) : null; const cwd = mappedCwd ?? this.resolveContainerCwd(options.cwd, workspaceFolder); - const fullCommand = `cd ${shescape.quote(cwd)} && ${command}`; + const pathEnvPrelude = Object.entries(mappedPathEnv) + .map(([key, value]) => + buildShellPathExport(key, value, (envValue) => shescape.quote(envValue)) + ) + .join(" && "); + const fullCommand = [`cd ${shescape.quote(cwd)}`, pathEnvPrelude, command] + .filter(Boolean) + .join(" && "); args.push("--", "bash", "-c", fullCommand); const childProcess = spawnDevcontainer(args, { diff --git a/src/node/runtime/LocalBaseRuntime.test.ts b/src/node/runtime/LocalBaseRuntime.test.ts index 50e6033164b..f85c74a2a8d 100644 --- a/src/node/runtime/LocalBaseRuntime.test.ts +++ b/src/node/runtime/LocalBaseRuntime.test.ts @@ -104,6 +104,19 @@ describe("LocalBaseRuntime.resolvePath", () => { }); describe("LocalBaseRuntime.exec PATH handling", () => { + it("canonicalizes pathEnv values before command execution", async () => { + const runtime = new TestLocalRuntime(); + const stream = await runtime.exec('printf "%s" "$XUM_TEST_PATH"', { + cwd: os.tmpdir(), + pathEnv: { XUM_TEST_PATH: "~/runtime-path" }, + timeout: 5, + }); + await stream.stdin.close(); + + expect(await readStreamAsString(stream.stdout)).toBe(path.join(os.homedir(), "runtime-path")); + expect(await stream.exitCode).toBe(0); + }); + it("strips mux browser shims and leaked browser env from child shells", async () => { const runtime = new TestLocalRuntime(); const tempBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-path-probe-")); diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 7c62e15c637..1b57299d52a 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -32,7 +32,7 @@ import { } from "./initHook"; import { getErrorMessage } from "@/common/utils/errors"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; -import { buildShellExport } from "./shellEnv"; +import { buildShellExport, buildShellPathExport } from "./shellEnv"; import { sanitizeXumChildEnv } from "./childProcessEnv"; /** @@ -85,10 +85,17 @@ export abstract class LocalBaseRuntime implements Runtime { .map(([key, value]) => buildShellExport(key, value)) .join("\n"); - const spawnArgs = ["-c", `${nonInteractivePrelude}\n${command}`]; + const pathEnvPrelude = Object.entries(options.pathEnv ?? {}) + .map(([key, value]) => buildShellPathExport(key, value)) + .join("\n"); + const spawnArgs = ["-c", `${nonInteractivePrelude}\n${pathEnvPrelude}\n${command}`]; const defaultPath = "/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"; - const mergedEnv = sanitizeXumChildEnv({ ...process.env, ...(options.env ?? {}) }); + const mergedEnv = sanitizeXumChildEnv({ + ...process.env, + ...(options.env ?? {}), + ...(options.pathEnv ?? {}), + }); const basePath = (options.env?.PATH && options.env.PATH.length > 0 ? mergedEnv.PATH @@ -213,9 +220,8 @@ export abstract class LocalBaseRuntime implements Runtime { } readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream { - // Expand tildes before reading (Node.js fs doesn't expand ~) - const expandedPath = expandTilde(filePath); - const nodeStream = fs.createReadStream(expandedPath); + const resolvedPath = path.resolve(expandTilde(filePath)); + const nodeStream = fs.createReadStream(resolvedPath); // Handle errors by wrapping in a transform // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern @@ -284,8 +290,7 @@ export abstract class LocalBaseRuntime implements Runtime { writeFile(filePath: string, _abortSignal?: AbortSignal): WritableStream { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - // Expand tildes before writing (Node.js fs doesn't expand ~) - const expandedPath = expandTilde(filePath); + const canonicalPath = path.resolve(expandTilde(filePath)); let tempPath: string; let writer: WritableStreamDefaultWriter; let resolvedPath: string; @@ -295,13 +300,12 @@ export abstract class LocalBaseRuntime implements Runtime { async start() { // Resolve symlinks to write through them (preserves the symlink) try { - resolvedPath = await fsPromises.realpath(expandedPath); + resolvedPath = await fsPromises.realpath(canonicalPath); // Save original permissions to restore after write const stat = await fsPromises.stat(resolvedPath); originalMode = stat.mode; } catch { - // If file doesn't exist, use the expanded path and default permissions - resolvedPath = expandedPath; + resolvedPath = canonicalPath; originalMode = undefined; } @@ -354,10 +358,9 @@ export abstract class LocalBaseRuntime implements Runtime { async stat(filePath: string, _abortSignal?: AbortSignal): Promise { // Note: _abortSignal ignored for local operations (fast, no need for cancellation) - // Expand tildes before stat (Node.js fs doesn't expand ~) - const expandedPath = expandTilde(filePath); + const resolvedPath = path.resolve(expandTilde(filePath)); try { - const stats = await fsPromises.stat(expandedPath); + const stats = await fsPromises.stat(resolvedPath); return { size: stats.size, modifiedTime: stats.mtime, @@ -376,9 +379,9 @@ export abstract class LocalBaseRuntime implements Runtime { if (abortSignal?.aborted) { throw new RuntimeErrorClass("Operation aborted before directory creation", "file_io"); } - const expandedPath = expandTilde(dirPath); + const resolvedPath = path.resolve(expandTilde(dirPath)); try { - await fsPromises.mkdir(expandedPath, { recursive: true }); + await fsPromises.mkdir(resolvedPath, { recursive: true }); } catch (err) { throw new RuntimeErrorClass( `Failed to create directory ${dirPath}: ${getErrorMessage(err)}`, diff --git a/src/node/runtime/RemoteRuntime.test.ts b/src/node/runtime/RemoteRuntime.test.ts index 54b2e530332..cc90b60c545 100644 --- a/src/node/runtime/RemoteRuntime.test.ts +++ b/src/node/runtime/RemoteRuntime.test.ts @@ -61,6 +61,36 @@ class RecordingRemoteRuntime extends RemoteRuntime { } } +function createStream(value: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); +} + +class CanonicalPathRemoteRuntime extends RecordingRemoteRuntime { + commands: string[] = []; + + override resolvePath(filePath: string): Promise { + if (filePath === "~") return Promise.resolve("/home/test"); + if (filePath.startsWith("~/")) return Promise.resolve(`/home/test/${filePath.slice(2)}`); + return Promise.resolve(filePath); + } + + override exec(command: string, _options: ExecOptions): Promise { + this.commands.push(command); + return Promise.resolve({ + stdout: createStream(command.startsWith("stat ") ? "1 2 regular file\n" : "contents"), + stderr: createStream(""), + stdin: new WritableStream(), + exitCode: Promise.resolve(0), + duration: Promise.resolve(0), + }); + } +} + /** * Fake exec: records the abortSignal readFile passes and returns a wedged * cat whose stdout never yields — exactly the stalled remote read the r18 @@ -86,6 +116,29 @@ class ReadFileRemoteRuntime extends RecordingRemoteRuntime { } } +describe("RemoteRuntime file path canonicalization", () => { + it("resolves relative and tilde paths inside file operations", async () => { + const runtime = new CanonicalPathRemoteRuntime(); + + const reader = runtime.readFile("nested/../read.txt").getReader(); + while (true) { + const { done } = await reader.read(); + if (done) break; + } + const writer = runtime.writeFile("~/write.txt").getWriter(); + await writer.close(); + await runtime.stat("nested/../stat.txt"); + await runtime.ensureDir("~/dir"); + + expect(runtime.commands).toContain("cat '/workspace/read.txt'"); + expect(runtime.commands.some((command) => command.includes("'/home/test/write.txt'"))).toBe( + true + ); + expect(runtime.commands).toContain("stat -L -c '%s %Y %F' '/workspace/stat.txt'"); + expect(runtime.commands).toContain("mkdir -p '/home/test/dir'"); + }); +}); + describe("RemoteRuntime.readFile", () => { it("cancelling the stream aborts the underlying cat exec", async () => { // r18: without cancel forwarding, a cancelled reader (e.g. mux.load's diff --git a/src/node/runtime/RemoteRuntime.ts b/src/node/runtime/RemoteRuntime.ts index 5b84e83a8fb..02d2a5a37b3 100644 --- a/src/node/runtime/RemoteRuntime.ts +++ b/src/node/runtime/RemoteRuntime.ts @@ -14,6 +14,7 @@ */ import type { ChildProcess } from "child_process"; +import * as path from "node:path"; import { Readable } from "stream"; import type { Runtime, @@ -38,7 +39,7 @@ import { DisposableProcess } from "@/node/utils/disposableExec"; import { streamToString, shescape } from "./streamUtils"; import { getErrorMessage } from "@/common/utils/errors"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; -import { buildShellExport } from "./shellEnv"; +import { buildShellExport, buildShellPathExport } from "./shellEnv"; // Cap for the stderr side-buffer kept purely for error reporting on process // failure. 16KB comfortably covers SSH/launch diagnostics while bounding memory @@ -122,6 +123,9 @@ export abstract class RemoteRuntime implements Runtime { for (const [key, value] of Object.entries(envVars)) { parts.push(buildShellExport(key, value, (envValue) => shescape.quote(envValue))); } + for (const [key, value] of Object.entries(options.pathEnv ?? {})) { + parts.push(buildShellPathExport(key, value, (envValue) => shescape.quote(envValue))); + } // Add the actual command parts.push(command); @@ -356,6 +360,17 @@ export abstract class RemoteRuntime implements Runtime { return { stdout, stderr, stdin, exitCode, duration }; } + private async resolveFilePath(filePath: string): Promise { + if (filePath === "~" || filePath.startsWith("~/")) { + return this.resolvePath(filePath); + } + if (path.posix.isAbsolute(filePath)) { + return path.posix.normalize(filePath); + } + const basePath = await this.resolvePath(this.getBasePath()); + return path.posix.resolve(basePath, filePath); + } + /** * Read file contents as a stream via exec. */ @@ -383,7 +398,8 @@ export abstract class RemoteRuntime implements Runtime { }, start: async (controller: ReadableStreamDefaultController) => { try { - const stream = await this.exec(`cat ${this.quoteForRemote(filePath)}`, { + const resolvedPath = await this.resolveFilePath(filePath); + const stream = await this.exec(`cat ${this.quoteForRemote(resolvedPath)}`, { cwd: this.getBasePath(), timeout: 300, abortSignal: readAbort.signal, @@ -431,13 +447,6 @@ export abstract class RemoteRuntime implements Runtime { * Uses temp file + mv for atomic write. */ writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream { - const quotedPath = this.quoteForRemote(filePath); - const tempPath = getAtomicWriteTempPath(filePath); - const quotedTempPath = this.quoteForRemote(tempPath); - - // Build write command - subclasses can override buildWriteCommand for special handling - const writeCommand = this.buildWriteCommand(quotedPath, quotedTempPath); - let execPromise: Promise | null = null; const writeAbortController = new AbortController(); const abortWrite = () => writeAbortController.abort(); @@ -451,10 +460,16 @@ export abstract class RemoteRuntime implements Runtime { }; const getExecStream = () => { - execPromise ??= this.exec(writeCommand, { - cwd: this.getBasePath(), - timeout: 300, - abortSignal: writeAbortController.signal, + execPromise ??= this.resolveFilePath(filePath).then((resolvedPath) => { + const quotedPath = this.quoteForRemote(resolvedPath); + const tempPath = getAtomicWriteTempPath(resolvedPath); + const quotedTempPath = this.quoteForRemote(tempPath); + const writeCommand = this.buildWriteCommand(quotedPath, quotedTempPath); + return this.exec(writeCommand, { + cwd: this.getBasePath(), + timeout: 300, + abortSignal: writeAbortController.signal, + }); }); return execPromise; }; @@ -513,7 +528,8 @@ export abstract class RemoteRuntime implements Runtime { * Ensure a directory exists (mkdir -p semantics). */ async ensureDir(dirPath: string, abortSignal?: AbortSignal): Promise { - const stream = await this.exec(`mkdir -p ${this.quoteForRemote(dirPath)}`, { + const resolvedPath = await this.resolveFilePath(dirPath); + const stream = await this.exec(`mkdir -p ${this.quoteForRemote(resolvedPath)}`, { cwd: "/", timeout: 10, abortSignal, @@ -541,7 +557,8 @@ export abstract class RemoteRuntime implements Runtime { * Uses stat -L to follow symlinks (report target's type, not "symbolic link"). */ async stat(filePath: string, abortSignal?: AbortSignal): Promise { - const stream = await this.exec(`stat -L -c '%s %Y %F' ${this.quoteForRemote(filePath)}`, { + const resolvedPath = await this.resolveFilePath(filePath); + const stream = await this.exec(`stat -L -c '%s %Y %F' ${this.quoteForRemote(resolvedPath)}`, { cwd: this.getBasePath(), timeout: 10, abortSignal, diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index 29406cf2eb4..b4d1fe74734 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -53,6 +53,8 @@ export interface ExecOptions { cwd: string; /** Environment variables to inject */ env?: Record; + /** Host-namespace paths to translate before exposing them as environment variables. */ + pathEnv?: Record; /** * Timeout in seconds. * @@ -373,7 +375,8 @@ export interface Runtime { */ readonly createFlags?: RuntimeCreateFlags; /** - * Execute a bash command with streaming I/O + * Execute a bash command with streaming I/O. + * cwd and pathEnv values are host-namespace paths translated by the adapter. * @param command The bash script to execute * @param options Execution options (cwd, env, timeout, etc.) * @returns Promise that resolves to streaming handles for stdin/stdout/stderr and completion promises @@ -382,13 +385,7 @@ export interface Runtime { exec(command: string, options: ExecOptions): Promise; /** - * Translate a host-visible path into the namespace used by exec() scripts. - * When absent, file I/O and exec share the same path namespace. - */ - mapPathForExec?(filePath: string): string; - - /** - * Read file contents as a stream + * Read file contents as a stream. Adapters canonicalize tilde and relative paths. * @param path Absolute or relative path to file * @param abortSignal Optional abort signal for cancellation * @returns Readable stream of file contents diff --git a/src/node/runtime/backgroundCommands.ts b/src/node/runtime/backgroundCommands.ts index aec25a495ee..1352020f98a 100644 --- a/src/node/runtime/backgroundCommands.ts +++ b/src/node/runtime/backgroundCommands.ts @@ -38,6 +38,8 @@ export interface WrapperScriptOptions { exitCodePath: string; /** Working directory for the script */ cwd: string; + /** Name of the environment variable containing the translated cwd. */ + cwdEnvVar?: string; /** Environment variables to export */ env?: Record; /** The actual script to run */ @@ -63,7 +65,13 @@ export function buildWrapperScript(options: WrapperScriptOptions): string { parts.push(`trap 'echo $? > "$__MUX_EXIT_CODE_PATH"' EXIT`); // Change to working directory - parts.push(`cd ${shellQuote(options.cwd)}`); + if (options.cwdEnvVar) { + parts.push(`__MUX_CWD="$${options.cwdEnvVar}"`); + parts.push(`unset ${options.cwdEnvVar}`); + parts.push('cd "$__MUX_CWD"'); + } else { + parts.push(`cd ${shellQuote(options.cwd)}`); + } // Add environment variable exports if (options.env) { diff --git a/src/node/runtime/shellEnv.ts b/src/node/runtime/shellEnv.ts index 51cd6c0cfb4..a5c64a7ff07 100644 --- a/src/node/runtime/shellEnv.ts +++ b/src/node/runtime/shellEnv.ts @@ -16,3 +16,16 @@ export function buildShellExport( assertShellEnvName(key); return `export ${key}=${quoteValue(value)}`; } + +export function buildShellPathExport( + key: string, + value: string, + quoteValue: (value: string) => string = shellQuote +): string { + assertShellEnvName(key); + return [ + `${key}=${quoteValue(value)}`, + `case "$${key}" in '~') ${key}="$HOME" ;; '~/'*) ${key}="$HOME/\${${key}:2}" ;; /*) ;; *) ${key}="$PWD/$${key}" ;; esac`, + `export ${key}`, + ].join(" && "); +} diff --git a/src/node/services/backgroundProcessExecutor.test.ts b/src/node/services/backgroundProcessExecutor.test.ts index 7f0e81900ec..f43ef222634 100644 --- a/src/node/services/backgroundProcessExecutor.test.ts +++ b/src/node/services/backgroundProcessExecutor.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import type { BackgroundHandle } from "@/node/runtime/Runtime"; +import type { BackgroundHandle, ExecOptions, ExecStream } from "@/node/runtime/Runtime"; import { shellQuote } from "@/node/runtime/backgroundCommands"; import { BG_EXIT_CODE_FILENAME, spawnProcess } from "./backgroundProcessExecutor"; @@ -16,10 +16,18 @@ class ExecPathMappingRuntime extends LocalRuntime { super(projectPath); } - mapPathForExec(filePath: string): string { - return filePath.startsWith(this.hostPrefix) - ? this.execPrefix + filePath.slice(this.hostPrefix.length) - : filePath; + override exec(command: string, options: ExecOptions): Promise { + const mapPath = (filePath: string) => + filePath.startsWith(this.hostPrefix) + ? this.execPrefix + filePath.slice(this.hostPrefix.length) + : filePath; + return super.exec(command, { + ...options, + cwd: mapPath(options.cwd), + pathEnv: Object.fromEntries( + Object.entries(options.pathEnv ?? {}).map(([key, value]) => [key, mapPath(value)]) + ), + }); } } diff --git a/src/node/services/backgroundProcessExecutor.ts b/src/node/services/backgroundProcessExecutor.ts index 9e7e2043f8a..f4e6043b2d7 100644 --- a/src/node/services/backgroundProcessExecutor.ts +++ b/src/node/services/backgroundProcessExecutor.ts @@ -65,6 +65,7 @@ export function spawnRecordsAreHostLocal(runtime: Runtime): boolean { * NOTE: Local runtimes validate that cwd exists before spawning, so this must be a real directory. */ const FALLBACK_CWD = process.platform === "win32" ? (process.env.TEMP ?? "C:\\") : "/tmp"; +const BACKGROUND_CWD_ENV = "XUM_INTERNAL_BACKGROUND_CWD"; /** Helper to extract error message for logging */ function errorMsg(error: unknown): string { @@ -127,6 +128,8 @@ export interface SpawnOptions { processId: string; /** Environment variables to inject */ env?: Record; + /** Host-namespace paths to translate before injecting as environment variables. */ + pathEnv?: Record; } /** @@ -160,17 +163,22 @@ export async function spawnProcess( // Get temp directory from runtime (absolute path, runtime-agnostic) const tempDir = await runtime.tempDir(); const bgOutputDir = `${tempDir}/${BG_OUTPUT_SUBDIR}`; - const execCwd = runtime.mapPathForExec?.(options.cwd) ?? options.cwd; // Use shell-safe quoting for paths (handles spaces, special chars) const quotePath = quotePathForShell; // Verify working directory exists - const cwdCheck = await execBuffered(runtime, `cd ${quotePath(execCwd)}`, { - cwd: FALLBACK_CWD, - timeout: 10, - }); + const cwdCheck = await execBuffered( + runtime, + `printf '%s\n' "$${BACKGROUND_CWD_ENV}"; cd "$${BACKGROUND_CWD_ENV}"`, + { + cwd: FALLBACK_CWD, + pathEnv: { [BACKGROUND_CWD_ENV]: options.cwd }, + timeout: 10, + } + ); if (cwdCheck.exitCode !== 0) { + const execCwd = cwdCheck.stdout.trim() || options.cwd; return { success: false, error: `Working directory does not exist: ${execCwd}` }; } @@ -222,11 +230,11 @@ export async function spawnProcess( }; } - // Build wrapper script (same for all runtimes now that paths are absolute) // Note: buildWrapperScript handles quoting internally via shellQuote const wrapperScript = buildWrapperScript({ exitCodePath, - cwd: execCwd, + cwd: options.cwd, + cwdEnvVar: BACKGROUND_CWD_ENV, env: { ...options.env, ...NON_INTERACTIVE_ENV_VARS }, script, }); @@ -241,6 +249,7 @@ export async function spawnProcess( // No timeout - the spawn command backgrounds the process and returns immediately const result = await execBuffered(runtime, spawnCommand, { cwd: FALLBACK_CWD, + pathEnv: { ...options.pathEnv, [BACKGROUND_CWD_ENV]: options.cwd }, }); if (result.exitCode !== 0) { diff --git a/src/node/services/backgroundProcessManager.ts b/src/node/services/backgroundProcessManager.ts index 38bbb272c92..4f7a10c7690 100644 --- a/src/node/services/backgroundProcessManager.ts +++ b/src/node/services/backgroundProcessManager.ts @@ -1342,6 +1342,7 @@ export class BackgroundProcessManager extends EventEmitter; + pathEnv?: Record; /** Human-readable name for the process - used to generate the process ID */ displayName: string; /** If true, process is foreground (being waited on). Default: false (background) */ @@ -1413,6 +1414,7 @@ export class BackgroundProcessManager extends EventEmitter { + const mapPath = (filePath: string) => + filePath.startsWith(this.hostPrefix) + ? this.execPrefix + filePath.slice(this.hostPrefix.length) + : filePath; + return super.exec(command, { + ...options, + cwd: mapPath(options.cwd), + pathEnv: Object.fromEntries( + Object.entries(options.pathEnv ?? {}).map(([key, value]) => [key, mapPath(value)]) + ), + }); } } @@ -55,26 +64,33 @@ describe("hooks", () => { const mappingRuntime = new ExecPathMappingRuntime(tempDir, tempDir, execPrefix); const statSpy = spyOn(mappingRuntime, "stat"); - expect(await getHookPath(mappingRuntime, tempDir)).toBe( - path.posix.join(execPrefix, ".xum/tool_hook") - ); - expect(await getToolEnvPath(mappingRuntime, tempDir)).toBe( - path.posix.join(execPrefix, ".xum/tool_env") - ); + expect(await getHookPath(mappingRuntime, tempDir)).toBe(hookPath); + expect(await getToolEnvPath(mappingRuntime, tempDir)).toBe(toolEnvPath); const statPaths = statSpy.mock.calls.map(([filePath]) => filePath); expect(statPaths).toContain(hookPath); expect(statPaths).toContain(toolEnvPath); }); test("hook runners export the mapped project dir as XUM_PROJECT_DIR", async () => { - const execPrefix = "/workspaces/project"; + const execPrefix = path.join(tempDir, "exec"); const mappingRuntime = new ExecPathMappingRuntime(tempDir, tempDir, execPrefix); const hookDir = path.join(tempDir, ".xum"); - await fs.mkdir(hookDir, { recursive: true }); + const execHookDir = path.join(execPrefix, ".xum"); + await Promise.all([ + fs.mkdir(hookDir, { recursive: true }), + fs.mkdir(execHookDir, { recursive: true }), + ]); const writeHook = async (name: string) => { const hookPath = path.join(hookDir, name); - await fs.writeFile(hookPath, '#!/bin/bash\necho "project_dir=$XUM_PROJECT_DIR"'); - await fs.chmod(hookPath, 0o755); + const contents = '#!/bin/bash\necho "project_dir=$XUM_PROJECT_DIR"'; + await Promise.all([ + fs.writeFile(hookPath, contents), + fs.writeFile(path.join(execHookDir, name), contents), + ]); + await Promise.all([ + fs.chmod(hookPath, 0o755), + fs.chmod(path.join(execHookDir, name), 0o755), + ]); return hookPath; }; const context = { diff --git a/src/node/services/hooks.ts b/src/node/services/hooks.ts index 4fc1e7854d8..7529e2ea788 100644 --- a/src/node/services/hooks.ts +++ b/src/node/services/hooks.ts @@ -25,6 +25,7 @@ const FLATTENED_TOOL_ENV_MAX_VARS = 200; const FLATTENED_TOOL_ENV_MAX_ARRAY_LENGTH = 50; const DEFAULT_HOOK_PHASE_TIMEOUT_MS = 10_000; // 10 seconds const EXEC_MARKER_PREFIX = "MUX_EXEC_"; +const HOOK_PATH_ENV = "XUM_INTERNAL_HOOK_PATH"; /** Shell-escape a string for safe use in bash -c commands */ function shellEscape(str: string): string { @@ -32,12 +33,16 @@ function shellEscape(str: string): string { return `'${str.replace(/'/g, "'\\''")}'`; } -/** - * Hooks execute in the runtime's exec namespace, so the documented - * XUM_PROJECT_DIR env value must be valid there (issue #3709). - */ -function resolveExecProjectDir(runtime: Runtime, projectDir: string): string { - return runtime.mapPathForExec?.(projectDir) ?? projectDir; +function buildHookCommand(): string { + return `hook_path="$${HOOK_PATH_ENV}"; unset ${HOOK_PATH_ENV}; "$hook_path"`; +} + +function getHookPathEnv(projectDir: string, hookPath: string): Record { + return { + [HOOK_PATH_ENV]: hookPath, + XUM_PROJECT_DIR: projectDir, + MUX_PROJECT_DIR: projectDir, + }; } function isAsyncIterable(value: unknown): value is AsyncIterable { @@ -85,8 +90,7 @@ async function getProjectOrGlobalConfigPath( for (const relativePath of listProjectMetadataRelativePaths(filename)) { const projectPath = joinPathLike(projectDir, relativePath); if (await isFile(runtime, projectPath)) { - // Hook and tool_env paths are embedded in exec scripts, so return them in that namespace. - return runtime.mapPathForExec?.(projectPath) ?? projectPath; + return projectPath; } } return getUserGlobalConfigPath(runtime, filename); @@ -276,7 +280,7 @@ export async function runWithHook( // Ensure the base JSON env var cannot be overwritten by flattened fields. XUM_TOOL_INPUT: toolInputEnv, XUM_WORKSPACE_ID: context.workspaceId, - XUM_PROJECT_DIR: resolveExecProjectDir(runtime, context.projectDir), + XUM_PROJECT_DIR: context.projectDir, XUM_EXEC: execMarker, }; if (toolInputPath) { @@ -315,11 +319,10 @@ export async function runWithHook( let stream; try { - // Shell-escape the hook path to handle spaces and special characters - // runtime.exec() uses bash -c, so unquoted paths would break - stream = await runtime.exec(shellEscape(hookPath), { + stream = await runtime.exec(buildHookCommand(), { cwd: context.projectDir, env: hookEnv, + pathEnv: getHookPathEnv(context.projectDir, hookPath), abortSignal: abortController.signal, }); } catch (err) { @@ -623,7 +626,7 @@ export async function runPreHook( // Ensure the base JSON env var cannot be overwritten by flattened fields. XUM_TOOL_INPUT: toolInputEnv, XUM_WORKSPACE_ID: context.workspaceId, - XUM_PROJECT_DIR: resolveExecProjectDir(runtime, context.projectDir), + XUM_PROJECT_DIR: context.projectDir, }; if (toolInputPath) { canonicalHookEnv.XUM_TOOL_INPUT_PATH = toolInputPath; @@ -631,9 +634,10 @@ export async function runPreHook( const hookEnv = withLegacyMuxEnvironmentAliases(canonicalHookEnv); try { - const result = await execBuffered(runtime, shellEscape(hookPath), { + const result = await execBuffered(runtime, buildHookCommand(), { cwd: context.projectDir, env: hookEnv, + pathEnv: getHookPathEnv(context.projectDir, hookPath), timeout: Math.ceil(timeoutMs / 1000), abortSignal: context.abortSignal, }); @@ -719,7 +723,7 @@ export async function runPostHook( // Ensure base JSON env vars cannot be overwritten by flattened fields. XUM_TOOL_INPUT: toolInputEnv, XUM_WORKSPACE_ID: context.workspaceId, - XUM_PROJECT_DIR: resolveExecProjectDir(runtime, context.projectDir), + XUM_PROJECT_DIR: context.projectDir, XUM_TOOL_RESULT: resultEnv, }; if (toolInputPath) { @@ -745,9 +749,10 @@ export async function runPostHook( }; try { - const result = await execBuffered(runtime, shellEscape(hookPath), { + const result = await execBuffered(runtime, buildHookCommand(), { cwd: context.projectDir, env: hookEnv, + pathEnv: getHookPathEnv(context.projectDir, hookPath), timeout: Math.ceil(timeoutMs / 1000), abortSignal: context.abortSignal, }); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index a25265be36b..31d5826a5a3 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -996,7 +996,7 @@ export class StreamManager extends EventEmitter { } try { - await runtime.ensureDir(resolvedPath); + await runtime.ensureDir(tempDir); } catch (err) { const msg = getErrorMessage(err); throw new Error(`Failed to create temp directory ${resolvedPath}: ${msg}`); diff --git a/src/node/services/tools/bash.ts b/src/node/services/tools/bash.ts index 1a59ade9a1f..e92c57d5166 100644 --- a/src/node/services/tools/bash.ts +++ b/src/node/services/tools/bash.ts @@ -820,24 +820,19 @@ function formatResult( } } -/** - * Shell-escape a string for safe use in bash commands (single-quote wrapping). - */ -function shellEscape(str: string): string { - return `'${str.replace(/'/g, "'\\''")}'`; -} - /** * Build script prelude that sources .xum/tool_env if present. * Returns empty string if no tool_env path is provided. */ +const TOOL_ENV_PATH_ENV = "XUM_INTERNAL_TOOL_ENV_PATH"; + function buildToolEnvPrelude(toolEnvPath: string | null): string { if (!toolEnvPath) return ""; - // Source the tool_env file; fail with clear error if sourcing fails - return `if ! source ${shellEscape(toolEnvPath)} 2>&1; then - echo "mux: failed to source ${toolEnvPath}" >&2 + return `if ! source "$${TOOL_ENV_PATH_ENV}" 2>&1; then + echo "mux: failed to source $${TOOL_ENV_PATH_ENV}" >&2 exit 1 fi +unset ${TOOL_ENV_PATH_ENV} `; } @@ -1035,6 +1030,7 @@ export const createBashTool: ToolFactory = (config: ToolConfiguration) => { { cwd: config.cwd, env: { ...(config.xumEnv ?? {}), ...(config.secrets ?? {}), ...hooksEnv }, + pathEnv: toolEnvPath ? { [TOOL_ENV_PATH_ENV]: toolEnvPath } : undefined, displayName: safeDisplayName, isForeground: false, // Explicit background ...(monitorConfig ? { monitor: monitorConfig } : {}), @@ -1114,6 +1110,7 @@ ${scriptWithEnv}`; const execStream = await config.runtime.exec(scriptWithClosedStdin, { cwd: config.cwd, env: { ...config.xumEnv, ...config.secrets, ...hooksEnv, ...NON_INTERACTIVE_ENV_VARS }, + pathEnv: toolEnvPath ? { [TOOL_ENV_PATH_ENV]: toolEnvPath } : undefined, timeout: effectiveTimeout, abortSignal: wrappedAbortController.signal, }); diff --git a/src/node/utils/runtime/helpers.test.ts b/src/node/utils/runtime/helpers.test.ts index 9c9a5653b77..63417d607c7 100644 --- a/src/node/utils/runtime/helpers.test.ts +++ b/src/node/utils/runtime/helpers.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "bun:test"; import type { ExecOptions, ExecStream, FileStat, Runtime } from "@/node/runtime/Runtime"; import { getLegacyPlanFilePath, getPlanFilePath } from "@/common/utils/planStorage"; -import { shellQuote } from "@/common/utils/shell"; import { copyPlanFileAcrossRuntimes, movePlanFile, readPlanFile } from "./helpers"; interface MockRuntimeState { @@ -194,7 +193,7 @@ describe("copyPlanFileAcrossRuntimes", () => { }); describe("readPlanFile", () => { - it("resolves paths before building the quoted migration command", async () => { + it("passes unresolved migration paths through pathEnv", async () => { const workspaceName = "workspace-a1b2"; const projectName = "demo-project"; const workspaceId = "legacy-workspace-id"; @@ -206,16 +205,12 @@ describe("readPlanFile", () => { const planDir = planPath.substring(0, planPath.lastIndexOf("/")); const resolvedPlanPath = "/home/dev/.mux/plans/demo-project/workspace-a1b2.md"; - const resolvedPlanDir = "/home/dev/.mux/plans/demo-project"; - const resolvedLegacyPath = "/home/dev/.mux/plans/legacy-workspace-id.md"; const state = createRuntimeState(xumHome, { [legacyPath]: legacyContent, }); state.resolvedPaths.set(planPath, resolvedPlanPath); - state.resolvedPaths.set(planDir, resolvedPlanDir); - state.resolvedPaths.set(legacyPath, resolvedLegacyPath); const result = await readPlanFile( createMockRuntime(state), @@ -231,11 +226,18 @@ describe("readPlanFile", () => { }); expect(state.readAttempts).toEqual([planPath, legacyPath]); expect(state.execCalls).toHaveLength(1); - expect(state.execCalls[0]?.command).toBe( - `mkdir -p ${shellQuote(resolvedPlanDir)} && mv ${shellQuote(resolvedLegacyPath)} ${shellQuote(resolvedPlanPath)}` - ); - expect(state.execCalls[0]?.options).toMatchObject({ cwd: "/tmp", timeout: 5 }); - expect(state.execCalls[0]?.command.includes("'~")).toBe(false); + expect(state.execCalls[0]).toEqual({ + command: 'mkdir -p "$XUM_PLAN_DIR" && mv "$XUM_LEGACY_PLAN" "$XUM_PLAN"', + options: { + cwd: "/tmp", + pathEnv: { + XUM_PLAN_DIR: planDir, + XUM_LEGACY_PLAN: legacyPath, + XUM_PLAN: planPath, + }, + timeout: 5, + }, + }); }); it.each([ @@ -277,7 +279,7 @@ describe("readPlanFile", () => { }); describe("movePlanFile", () => { - it("uses resolved absolute paths when constructing the mv command", async () => { + it("passes unresolved plan paths through pathEnv", async () => { const oldWorkspaceName = "old-workspace"; const newWorkspaceName = "new-workspace"; const projectName = "demo-project"; @@ -285,22 +287,24 @@ describe("movePlanFile", () => { const oldPath = getPlanFilePath(oldWorkspaceName, projectName, xumHome); const newPath = getPlanFilePath(newWorkspaceName, projectName, xumHome); - const resolvedOldPath = "/home/dev/.mux/plans/demo-project/old-workspace.md"; - const resolvedNewPath = "/home/dev/.mux/plans/demo-project/new-workspace.md"; const state = createRuntimeState(xumHome, { [oldPath]: "# old plan\n", }); - state.resolvedPaths.set(oldPath, resolvedOldPath); - state.resolvedPaths.set(newPath, resolvedNewPath); - await movePlanFile(createMockRuntime(state), oldWorkspaceName, newWorkspaceName, projectName); expect(state.execCalls).toHaveLength(1); - expect(state.execCalls[0]?.command).toBe( - `mv ${shellQuote(resolvedOldPath)} ${shellQuote(resolvedNewPath)}` - ); - expect(state.execCalls[0]?.options).toMatchObject({ cwd: "/tmp", timeout: 5 }); + expect(state.execCalls[0]).toEqual({ + command: 'mv "$XUM_OLD_PLAN" "$XUM_NEW_PLAN"', + options: { + cwd: "/tmp", + pathEnv: { + XUM_OLD_PLAN: oldPath, + XUM_NEW_PLAN: newPath, + }, + timeout: 5, + }, + }); }); }); diff --git a/src/node/utils/runtime/helpers.ts b/src/node/utils/runtime/helpers.ts index 7b1fcb20ee7..ef613069127 100644 --- a/src/node/utils/runtime/helpers.ts +++ b/src/node/utils/runtime/helpers.ts @@ -2,7 +2,6 @@ import type { Runtime, ExecOptions } from "@/node/runtime/Runtime"; import { streamToString, streamToStringCapped } from "@/node/runtime/streamUtils"; import { PlatformPaths } from "@/node/utils/paths.main"; import { getLegacyPlanFilePath, getPlanFilePath } from "@/common/utils/planStorage"; -import { shellQuote } from "@/common/utils/shell"; /** * Convenience helpers for working with streaming Runtime APIs. @@ -151,16 +150,18 @@ export async function readPlanFile( try { const content = await readFileString(runtime, legacyPath); // Migrate: move to new location. - // Resolve paths first because shellQuote() intentionally prevents ~ expansion. try { const planDir = planPath.substring(0, planPath.lastIndexOf("/")); - const resolvedPlanDir = await runtime.resolvePath(planDir); - const resolvedLegacyPath = await runtime.resolvePath(legacyPath); await execBuffered( runtime, - `mkdir -p ${shellQuote(resolvedPlanDir)} && mv ${shellQuote(resolvedLegacyPath)} ${shellQuote(resolvedPath)}`, + 'mkdir -p "$XUM_PLAN_DIR" && mv "$XUM_LEGACY_PLAN" "$XUM_PLAN"', { cwd: "/tmp", + pathEnv: { + XUM_PLAN_DIR: planDir, + XUM_LEGACY_PLAN: legacyPath, + XUM_PLAN: planPath, + }, timeout: 5, } ); @@ -224,17 +225,14 @@ export async function movePlanFile( try { await runtime.stat(oldPath); - // Resolve tildes to absolute paths - bash doesn't expand ~ inside quotes - const resolvedOldPath = await runtime.resolvePath(oldPath); - const resolvedNewPath = await runtime.resolvePath(newPath); - await execBuffered( - runtime, - `mv ${shellQuote(resolvedOldPath)} ${shellQuote(resolvedNewPath)}`, - { - cwd: "/tmp", - timeout: 5, - } - ); + await execBuffered(runtime, 'mv "$XUM_OLD_PLAN" "$XUM_NEW_PLAN"', { + cwd: "/tmp", + pathEnv: { + XUM_OLD_PLAN: oldPath, + XUM_NEW_PLAN: newPath, + }, + timeout: 5, + }); } catch { // No plan file to move, that's fine } From 8f23de5abe3f49d3c967547be99d69cf4d06b9e2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:03:31 +0000 Subject: [PATCH 07/20] polish: fix stale comments, drop dead env assignments, dedupe test double --- src/node/runtime/LocalBaseRuntime.ts | 1 - src/node/runtime/Runtime.ts | 6 ++-- src/node/runtime/backgroundCommands.ts | 2 +- .../backgroundProcessExecutor.test.ts | 27 ++--------------- .../services/backgroundProcessExecutor.ts | 1 - src/node/services/hooks.test.ts | 26 +--------------- src/node/services/hooks.ts | 3 -- src/node/services/streamManager.ts | 8 ++--- .../services/testExecPathMappingRuntime.ts | 30 +++++++++++++++++++ 9 files changed, 40 insertions(+), 64 deletions(-) create mode 100644 src/node/services/testExecPathMappingRuntime.ts diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 1b57299d52a..96bb6d0420d 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -94,7 +94,6 @@ export abstract class LocalBaseRuntime implements Runtime { const mergedEnv = sanitizeXumChildEnv({ ...process.env, ...(options.env ?? {}), - ...(options.pathEnv ?? {}), }); const basePath = (options.env?.PATH && options.env.PATH.length > 0 diff --git a/src/node/runtime/Runtime.ts b/src/node/runtime/Runtime.ts index b4d1fe74734..96a1aeae807 100644 --- a/src/node/runtime/Runtime.ts +++ b/src/node/runtime/Runtime.ts @@ -394,7 +394,7 @@ export interface Runtime { readFile(path: string, abortSignal?: AbortSignal): ReadableStream; /** - * Write file contents atomically from a stream + * Write file contents atomically from a stream. Adapters canonicalize tilde and relative paths. * @param path Absolute or relative path to file * @param abortSignal Optional abort signal for cancellation * @returns Writable stream for file contents @@ -403,7 +403,7 @@ export interface Runtime { writeFile(path: string, abortSignal?: AbortSignal): WritableStream; /** - * Get file statistics + * Get file statistics. Adapters canonicalize tilde and relative paths. * @param path Absolute or relative path to file/directory * @param abortSignal Optional abort signal for cancellation * @returns File statistics @@ -412,7 +412,7 @@ export interface Runtime { stat(path: string, abortSignal?: AbortSignal): Promise; /** - * Ensure a directory exists (mkdir -p semantics). + * Ensure a directory exists (mkdir -p semantics). Adapters canonicalize tilde and relative paths. * * This intentionally lives on the Runtime abstraction so local runtimes can use * Node fs APIs (Windows-safe) while remote runtimes can use shell commands. diff --git a/src/node/runtime/backgroundCommands.ts b/src/node/runtime/backgroundCommands.ts index 1352020f98a..d8cd7c951de 100644 --- a/src/node/runtime/backgroundCommands.ts +++ b/src/node/runtime/backgroundCommands.ts @@ -38,7 +38,7 @@ export interface WrapperScriptOptions { exitCodePath: string; /** Working directory for the script */ cwd: string; - /** Name of the environment variable containing the translated cwd. */ + /** Name of the environment variable containing the translated cwd; takes precedence over cwd. */ cwdEnvVar?: string; /** Environment variables to export */ env?: Record; diff --git a/src/node/services/backgroundProcessExecutor.test.ts b/src/node/services/backgroundProcessExecutor.test.ts index f43ef222634..de0ea9b06e7 100644 --- a/src/node/services/backgroundProcessExecutor.test.ts +++ b/src/node/services/backgroundProcessExecutor.test.ts @@ -3,34 +3,11 @@ import * as fs from "fs/promises"; import * as os from "os"; import * as path from "path"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import type { BackgroundHandle, ExecOptions, ExecStream } from "@/node/runtime/Runtime"; +import type { BackgroundHandle } from "@/node/runtime/Runtime"; import { shellQuote } from "@/node/runtime/backgroundCommands"; +import { ExecPathMappingRuntime } from "./testExecPathMappingRuntime"; import { BG_EXIT_CODE_FILENAME, spawnProcess } from "./backgroundProcessExecutor"; -class ExecPathMappingRuntime extends LocalRuntime { - constructor( - projectPath: string, - private readonly hostPrefix: string, - private readonly execPrefix: string - ) { - super(projectPath); - } - - override exec(command: string, options: ExecOptions): Promise { - const mapPath = (filePath: string) => - filePath.startsWith(this.hostPrefix) - ? this.execPrefix + filePath.slice(this.hostPrefix.length) - : filePath; - return super.exec(command, { - ...options, - cwd: mapPath(options.cwd), - pathEnv: Object.fromEntries( - Object.entries(options.pathEnv ?? {}).map(([key, value]) => [key, mapPath(value)]) - ), - }); - } -} - /** * Delegates to a real LocalRuntime but is NOT an instanceof LocalBaseRuntime, so * spawnProcess treats it like a remote runtime; its exec throws for the spawn command diff --git a/src/node/services/backgroundProcessExecutor.ts b/src/node/services/backgroundProcessExecutor.ts index f4e6043b2d7..afe6d5a5b2c 100644 --- a/src/node/services/backgroundProcessExecutor.ts +++ b/src/node/services/backgroundProcessExecutor.ts @@ -230,7 +230,6 @@ export async function spawnProcess( }; } - // Note: buildWrapperScript handles quoting internally via shellQuote const wrapperScript = buildWrapperScript({ exitCodePath, cwd: options.cwd, diff --git a/src/node/services/hooks.test.ts b/src/node/services/hooks.test.ts index bf9ff67d3ff..fe133492e4a 100644 --- a/src/node/services/hooks.test.ts +++ b/src/node/services/hooks.test.ts @@ -12,31 +12,7 @@ import { runPostHook, } from "./hooks"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import type { ExecOptions, ExecStream } from "@/node/runtime/Runtime"; - -class ExecPathMappingRuntime extends LocalRuntime { - constructor( - projectPath: string, - private readonly hostPrefix: string, - private readonly execPrefix: string - ) { - super(projectPath); - } - - override exec(command: string, options: ExecOptions): Promise { - const mapPath = (filePath: string) => - filePath.startsWith(this.hostPrefix) - ? this.execPrefix + filePath.slice(this.hostPrefix.length) - : filePath; - return super.exec(command, { - ...options, - cwd: mapPath(options.cwd), - pathEnv: Object.fromEntries( - Object.entries(options.pathEnv ?? {}).map(([key, value]) => [key, mapPath(value)]) - ), - }); - } -} +import { ExecPathMappingRuntime } from "./testExecPathMappingRuntime"; describe("hooks", () => { let tempDir: string; diff --git a/src/node/services/hooks.ts b/src/node/services/hooks.ts index 7529e2ea788..7e6b8026007 100644 --- a/src/node/services/hooks.ts +++ b/src/node/services/hooks.ts @@ -280,7 +280,6 @@ export async function runWithHook( // Ensure the base JSON env var cannot be overwritten by flattened fields. XUM_TOOL_INPUT: toolInputEnv, XUM_WORKSPACE_ID: context.workspaceId, - XUM_PROJECT_DIR: context.projectDir, XUM_EXEC: execMarker, }; if (toolInputPath) { @@ -626,7 +625,6 @@ export async function runPreHook( // Ensure the base JSON env var cannot be overwritten by flattened fields. XUM_TOOL_INPUT: toolInputEnv, XUM_WORKSPACE_ID: context.workspaceId, - XUM_PROJECT_DIR: context.projectDir, }; if (toolInputPath) { canonicalHookEnv.XUM_TOOL_INPUT_PATH = toolInputPath; @@ -723,7 +721,6 @@ export async function runPostHook( // Ensure base JSON env vars cannot be overwritten by flattened fields. XUM_TOOL_INPUT: toolInputEnv, XUM_WORKSPACE_ID: context.workspaceId, - XUM_PROJECT_DIR: context.projectDir, XUM_TOOL_RESULT: resultEnv, }; if (toolInputPath) { diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 31d5826a5a3..dc7c273d1f6 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -982,11 +982,9 @@ export class StreamManager extends EventEmitter { public async createTempDirForStream(streamToken: StreamToken, runtime: Runtime): Promise { const tempDir = `~/.xum-tmp/${streamToken}`; - // Resolve ~ in the runtime's context. - // - // IMPORTANT: On Windows local runtime, Git Bash may use a customized $HOME, - // while runtime.resolvePath expands ~ via Node (USERPROFILE). To avoid drift, - // create the directory using the resolved absolute path. + // Resolve ~ in the runtime's context: callers need the canonical absolute + // path as a value. ensureDir canonicalizes internally, so the unresolved + // form is passed there directly. let resolvedPath = (await runtime.resolvePath(tempDir)).trim(); // In the main process, PlatformPaths defaults to POSIX behavior (no navigator), diff --git a/src/node/services/testExecPathMappingRuntime.ts b/src/node/services/testExecPathMappingRuntime.ts new file mode 100644 index 00000000000..11def556ec6 --- /dev/null +++ b/src/node/services/testExecPathMappingRuntime.ts @@ -0,0 +1,30 @@ +import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { ExecOptions, ExecStream } from "@/node/runtime/Runtime"; + +/** + * Test runtime whose exec namespace differs from the host namespace: paths under + * hostPrefix are remapped to execPrefix (cwd and pathEnv), like DevcontainerRuntime. + */ +export class ExecPathMappingRuntime extends LocalRuntime { + constructor( + projectPath: string, + private readonly hostPrefix: string, + private readonly execPrefix: string + ) { + super(projectPath); + } + + override exec(command: string, options: ExecOptions): Promise { + const mapPath = (filePath: string) => + filePath.startsWith(this.hostPrefix) + ? this.execPrefix + filePath.slice(this.hostPrefix.length) + : filePath; + return super.exec(command, { + ...options, + cwd: mapPath(options.cwd), + pathEnv: Object.fromEntries( + Object.entries(options.pathEnv ?? {}).map(([key, value]) => [key, mapPath(value)]) + ), + }); + } +} From acc7a2bd245eb482d6d1e5b4ca076c757cf2b8a7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:13:18 +0000 Subject: [PATCH 08/20] fix: keep Windows absolute paths intact in shell path exports --- src/node/runtime/shellEnv.test.ts | 36 ++++++++++++++++++++++++++++++- src/node/runtime/shellEnv.ts | 4 +++- 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/src/node/runtime/shellEnv.test.ts b/src/node/runtime/shellEnv.test.ts index 310ce65db48..aa0aba35610 100644 --- a/src/node/runtime/shellEnv.test.ts +++ b/src/node/runtime/shellEnv.test.ts @@ -1,5 +1,18 @@ import { describe, expect, it } from "bun:test"; -import { buildShellExport } from "./shellEnv"; +import { buildShellExport, buildShellPathExport } from "./shellEnv"; + +/** Run the generated export snippet under bash and return the resulting value. */ +async function evalPathExport(value: string, cwd: string): Promise { + const snippet = buildShellPathExport("MUX_TEST_PATH", value); + const proc = Bun.spawn(["bash", "-c", `${snippet} && printf '%s' "$MUX_TEST_PATH"`], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited]); + expect(exitCode).toBe(0); + return stdout; +} describe("buildShellExport", () => { it("quotes values for valid environment variable names", () => { @@ -12,3 +25,24 @@ describe("buildShellExport", () => { ); }); }); + +describe("buildShellPathExport", () => { + it("resolves relative paths against the shell cwd", async () => { + expect(await evalPathExport("rel/path", "/tmp")).toBe("/tmp/rel/path"); + }); + + it("keeps POSIX absolute paths unchanged", async () => { + expect(await evalPathExport("/opt/data", "/tmp")).toBe("/opt/data"); + }); + + // Windows local runtimes exec through Git Bash, whose cd accepts native + // Windows paths; treating them as relative would prepend $PWD and corrupt them. + it("keeps Windows drive-letter paths unchanged", async () => { + expect(await evalPathExport("D:\\a\\xum\\ws", "/tmp")).toBe("D:\\a\\xum\\ws"); + expect(await evalPathExport("D:/a/xum/ws", "/tmp")).toBe("D:/a/xum/ws"); + }); + + it("keeps UNC paths unchanged", async () => { + expect(await evalPathExport("\\\\server\\share\\dir", "/tmp")).toBe("\\\\server\\share\\dir"); + }); +}); diff --git a/src/node/runtime/shellEnv.ts b/src/node/runtime/shellEnv.ts index a5c64a7ff07..6e1809e962e 100644 --- a/src/node/runtime/shellEnv.ts +++ b/src/node/runtime/shellEnv.ts @@ -23,9 +23,11 @@ export function buildShellPathExport( quoteValue: (value: string) => string = shellQuote ): string { assertShellEnvName(key); + // Windows drive-letter ([A-Za-z]:*) and UNC ('\\'*) paths are absolute too: + // Git Bash accepts them natively, and prepending $PWD would corrupt them. return [ `${key}=${quoteValue(value)}`, - `case "$${key}" in '~') ${key}="$HOME" ;; '~/'*) ${key}="$HOME/\${${key}:2}" ;; /*) ;; *) ${key}="$PWD/$${key}" ;; esac`, + `case "$${key}" in '~') ${key}="$HOME" ;; '~/'*) ${key}="$HOME/\${${key}:2}" ;; /* | [A-Za-z]:* | '\\\\'*) ;; *) ${key}="$PWD/$${key}" ;; esac`, `export ${key}`, ].join(" && "); } From 4beb783fa39442d4006a74602f9989499250c646 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:19:52 +0000 Subject: [PATCH 09/20] fix: propagate aborts through remote file path resolution --- src/node/runtime/RemoteRuntime.test.ts | 23 +++++++++++ src/node/runtime/RemoteRuntime.ts | 55 ++++++++++++++++++-------- 2 files changed, 61 insertions(+), 17 deletions(-) diff --git a/src/node/runtime/RemoteRuntime.test.ts b/src/node/runtime/RemoteRuntime.test.ts index cc90b60c545..97852202733 100644 --- a/src/node/runtime/RemoteRuntime.test.ts +++ b/src/node/runtime/RemoteRuntime.test.ts @@ -169,6 +169,29 @@ describe("RemoteRuntime.readFile", () => { }); }); +describe("RemoteRuntime file operation aborts", () => { + it("stat settles immediately when aborted instead of waiting out path resolution", async () => { + const runtime = new RecordingRemoteRuntime(); + let resolverSettled = false; + runtime.resolvePath = () => + new Promise((resolve) => + setTimeout(() => { + resolverSettled = true; + resolve("/workspace"); + }, 1000) + ); + const controller = new AbortController(); + controller.abort(); + + const rejected = await runtime.stat("relative/file.txt", controller.signal).then( + () => false, + () => true + ); + expect(rejected).toBe(true); + expect(resolverSettled).toBe(false); + }); +}); + describe("RemoteRuntime.writeFile", () => { it("does not start a remote write command when aborted before the first write", async () => { const runtime = new RecordingRemoteRuntime(); diff --git a/src/node/runtime/RemoteRuntime.ts b/src/node/runtime/RemoteRuntime.ts index 02d2a5a37b3..9042ab16d4d 100644 --- a/src/node/runtime/RemoteRuntime.ts +++ b/src/node/runtime/RemoteRuntime.ts @@ -40,6 +40,7 @@ import { streamToString, shescape } from "./streamUtils"; import { getErrorMessage } from "@/common/utils/errors"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; import { buildShellExport, buildShellPathExport } from "./shellEnv"; +import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; // Cap for the stderr side-buffer kept purely for error reporting on process // failure. 16KB comfortably covers SSH/launch diagnostics while bounding memory @@ -360,17 +361,35 @@ export abstract class RemoteRuntime implements Runtime { return { stdout, stderr, stdin, exitCode, duration }; } - private async resolveFilePath(filePath: string): Promise { + private async resolveFilePath(filePath: string, abortSignal?: AbortSignal): Promise { if (filePath === "~" || filePath.startsWith("~/")) { - return this.resolvePath(filePath); + return this.resolveWithAbort(this.resolvePath(filePath), abortSignal); } if (path.posix.isAbsolute(filePath)) { return path.posix.normalize(filePath); } - const basePath = await this.resolvePath(this.getBasePath()); + const basePath = await this.resolveWithAbort(this.resolvePath(this.getBasePath()), abortSignal); return path.posix.resolve(basePath, filePath); } + /** + * resolvePath has no signal path into its exec, so a canceled file operation + * must not wait out the resolver (up to its 10s timeout): settle the caller + * immediately and let the orphaned resolver finish in the background. + */ + private async resolveWithAbort( + resolution: Promise, + abortSignal?: AbortSignal + ): Promise { + const result = await raceWithAbortAndTimeout(resolution, { signal: abortSignal }); + if (result.kind !== "ok") { + resolution.catch(() => undefined); + abortSignal?.throwIfAborted(); + throw new RuntimeError("Path resolution aborted", "file_io"); + } + return result.value; + } + /** * Read file contents as a stream via exec. */ @@ -398,7 +417,7 @@ export abstract class RemoteRuntime implements Runtime { }, start: async (controller: ReadableStreamDefaultController) => { try { - const resolvedPath = await this.resolveFilePath(filePath); + const resolvedPath = await this.resolveFilePath(filePath, readAbort.signal); const stream = await this.exec(`cat ${this.quoteForRemote(resolvedPath)}`, { cwd: this.getBasePath(), timeout: 300, @@ -460,17 +479,19 @@ export abstract class RemoteRuntime implements Runtime { }; const getExecStream = () => { - execPromise ??= this.resolveFilePath(filePath).then((resolvedPath) => { - const quotedPath = this.quoteForRemote(resolvedPath); - const tempPath = getAtomicWriteTempPath(resolvedPath); - const quotedTempPath = this.quoteForRemote(tempPath); - const writeCommand = this.buildWriteCommand(quotedPath, quotedTempPath); - return this.exec(writeCommand, { - cwd: this.getBasePath(), - timeout: 300, - abortSignal: writeAbortController.signal, - }); - }); + execPromise ??= this.resolveFilePath(filePath, writeAbortController.signal).then( + (resolvedPath) => { + const quotedPath = this.quoteForRemote(resolvedPath); + const tempPath = getAtomicWriteTempPath(resolvedPath); + const quotedTempPath = this.quoteForRemote(tempPath); + const writeCommand = this.buildWriteCommand(quotedPath, quotedTempPath); + return this.exec(writeCommand, { + cwd: this.getBasePath(), + timeout: 300, + abortSignal: writeAbortController.signal, + }); + } + ); return execPromise; }; @@ -528,7 +549,7 @@ export abstract class RemoteRuntime implements Runtime { * Ensure a directory exists (mkdir -p semantics). */ async ensureDir(dirPath: string, abortSignal?: AbortSignal): Promise { - const resolvedPath = await this.resolveFilePath(dirPath); + const resolvedPath = await this.resolveFilePath(dirPath, abortSignal); const stream = await this.exec(`mkdir -p ${this.quoteForRemote(resolvedPath)}`, { cwd: "/", timeout: 10, @@ -557,7 +578,7 @@ export abstract class RemoteRuntime implements Runtime { * Uses stat -L to follow symlinks (report target's type, not "symbolic link"). */ async stat(filePath: string, abortSignal?: AbortSignal): Promise { - const resolvedPath = await this.resolveFilePath(filePath); + const resolvedPath = await this.resolveFilePath(filePath, abortSignal); const stream = await this.exec(`stat -L -c '%s %Y %F' ${this.quoteForRemote(resolvedPath)}`, { cwd: this.getBasePath(), timeout: 10, From ccd3f6c767eb18d9023075f31171116e2f4c1af2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:38:13 +0000 Subject: [PATCH 10/20] fix: align local pathEnv expansion with file I/O; keep pathEnv authoritative in background wrappers --- src/node/runtime/LocalBaseRuntime.test.ts | 25 +++++++++++++++++ src/node/runtime/LocalBaseRuntime.ts | 8 ++++-- .../backgroundProcessExecutor.test.ts | 27 +++++++++++++++++++ .../services/backgroundProcessExecutor.ts | 10 ++++++- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/node/runtime/LocalBaseRuntime.test.ts b/src/node/runtime/LocalBaseRuntime.test.ts index f85c74a2a8d..bf7ead142ed 100644 --- a/src/node/runtime/LocalBaseRuntime.test.ts +++ b/src/node/runtime/LocalBaseRuntime.test.ts @@ -117,6 +117,31 @@ describe("LocalBaseRuntime.exec PATH handling", () => { expect(await stream.exitCode).toBe(0); }); + it("expands product-home pathEnv values through getXumHome like file I/O", async () => { + const xumRoot = await fs.mkdtemp(path.join(os.tmpdir(), "xum-root-")); + const originalRoot = process.env.XUM_ROOT; + process.env.XUM_ROOT = xumRoot; + try { + const runtime = new TestLocalRuntime(); + const stream = await runtime.exec('printf "%s" "$XUM_TEST_PATH"', { + cwd: os.tmpdir(), + pathEnv: { XUM_TEST_PATH: "~/.xum/plans" }, + timeout: 5, + }); + await stream.stdin.close(); + + expect(await readStreamAsString(stream.stdout)).toBe(path.join(xumRoot, "plans")); + expect(await stream.exitCode).toBe(0); + } finally { + if (originalRoot === undefined) { + delete process.env.XUM_ROOT; + } else { + process.env.XUM_ROOT = originalRoot; + } + await fs.rm(xumRoot, { recursive: true, force: true }); + } + }); + it("strips mux browser shims and leaked browser env from child shells", async () => { const runtime = new TestLocalRuntime(); const tempBinDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-path-probe-")); diff --git a/src/node/runtime/LocalBaseRuntime.ts b/src/node/runtime/LocalBaseRuntime.ts index 96bb6d0420d..a71d161879c 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -32,7 +32,7 @@ import { } from "./initHook"; import { getErrorMessage } from "@/common/utils/errors"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; -import { buildShellExport, buildShellPathExport } from "./shellEnv"; +import { buildShellExport } from "./shellEnv"; import { sanitizeXumChildEnv } from "./childProcessEnv"; /** @@ -85,8 +85,12 @@ export abstract class LocalBaseRuntime implements Runtime { .map(([key, value]) => buildShellExport(key, value)) .join("\n"); + // Expand host-side with the same semantics as local file I/O: expandTilde + // routes ~/.xum (and legacy homes) through getXumHome, which in-shell $HOME + // expansion would miss (XUM_ROOT, dev suffix), and relative values resolve + // against the exec cwd, matching the shell's $PWD when the exports run. const pathEnvPrelude = Object.entries(options.pathEnv ?? {}) - .map(([key, value]) => buildShellPathExport(key, value)) + .map(([key, value]) => buildShellExport(key, path.resolve(cwd, expandTilde(value)))) .join("\n"); const spawnArgs = ["-c", `${nonInteractivePrelude}\n${pathEnvPrelude}\n${command}`]; diff --git a/src/node/services/backgroundProcessExecutor.test.ts b/src/node/services/backgroundProcessExecutor.test.ts index de0ea9b06e7..949d68f3405 100644 --- a/src/node/services/backgroundProcessExecutor.test.ts +++ b/src/node/services/backgroundProcessExecutor.test.ts @@ -97,6 +97,33 @@ describe("spawnProcess", () => { expect((await fs.readFile(outFile, "utf8")).trim()).toBe(execDir); }); + it("pathEnv values win over colliding caller env in background wrappers", async () => { + const hostDir = await fs.mkdtemp(path.join(os.tmpdir(), "bg-pathenv-collision-")); + const resultDir = await fs.mkdtemp(path.join(os.tmpdir(), "bg-pathenv-result-")); + cleanupDirs.push(hostDir, resultDir); + + const outFile = path.join(resultDir, "value.txt"); + const result = await spawnProcess( + new LocalRuntime(hostDir), + `printf %s "$XUM_TEST_TOOLENV" > ${shellQuote(outFile)}`, + { + cwd: hostDir, + workspaceId: `pathenv-collision-${Date.now()}`, + processId: "collision", + env: { XUM_TEST_TOOLENV: "/wrong/value" }, + pathEnv: { XUM_TEST_TOOLENV: "/right/value" }, + } + ); + + expect(result.success).toBe(true); + if (!result.success) return; + handles.push(result.handle); + cleanupDirs.push(result.outputDir); + + expect(await waitForExit(result.handle)).toBe(0); + expect((await fs.readFile(outFile, "utf8")).trim()).toBe("/right/value"); + }); + it("fails the strict exit probe when the exit marker is a dangling symlink", async () => { const hostDir = await fs.mkdtemp(path.join(os.tmpdir(), "bg-dangling-marker-")); cleanupDirs.push(hostDir); diff --git a/src/node/services/backgroundProcessExecutor.ts b/src/node/services/backgroundProcessExecutor.ts index afe6d5a5b2c..ffcccc0d4ce 100644 --- a/src/node/services/backgroundProcessExecutor.ts +++ b/src/node/services/backgroundProcessExecutor.ts @@ -230,11 +230,19 @@ export async function spawnProcess( }; } + // The outer spawn shell exports the translated pathEnv (and cwd var) before + // the wrapper runs; wrapper-level env exports would overwrite those + // translations, so pathEnv-owned keys are stripped from the wrapper env. + const wrapperEnv: Record = { ...options.env, ...NON_INTERACTIVE_ENV_VARS }; + for (const key of [...Object.keys(options.pathEnv ?? {}), BACKGROUND_CWD_ENV]) { + delete wrapperEnv[key]; + } + const wrapperScript = buildWrapperScript({ exitCodePath, cwd: options.cwd, cwdEnvVar: BACKGROUND_CWD_ENV, - env: { ...options.env, ...NON_INTERACTIVE_ENV_VARS }, + env: wrapperEnv, script, }); From abcc46ee87032b3be1f00facb3a716d3e3256f36 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:56:13 +0000 Subject: [PATCH 11/20] fix: reuse the resolved stream temp dir for ensureDir --- src/node/services/streamManager.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index dc7c273d1f6..5ee4e80a336 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -983,8 +983,8 @@ export class StreamManager extends EventEmitter { const tempDir = `~/.xum-tmp/${streamToken}`; // Resolve ~ in the runtime's context: callers need the canonical absolute - // path as a value. ensureDir canonicalizes internally, so the unresolved - // form is passed there directly. + // path as a value, and reusing it for ensureDir avoids a second remote + // resolution round trip per stream on SSH runtimes. let resolvedPath = (await runtime.resolvePath(tempDir)).trim(); // In the main process, PlatformPaths defaults to POSIX behavior (no navigator), @@ -994,7 +994,7 @@ export class StreamManager extends EventEmitter { } try { - await runtime.ensureDir(tempDir); + await runtime.ensureDir(resolvedPath); } catch (err) { const msg = getErrorMessage(err); throw new Error(`Failed to create temp directory ${resolvedPath}: ${msg}`); From 1bd7a36785d596998098ad2edc6988888c35b305 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:18:34 +0000 Subject: [PATCH 12/20] refactor: extract shared exec file I/O; dedupe test runtime boilerplate; drop dead code --- src/node/runtime/DevcontainerRuntime.ts | 236 +++++---------------- src/node/runtime/LocalBaseRuntime.test.ts | 23 +- src/node/runtime/RemoteRuntime.test.ts | 57 +---- src/node/runtime/RemoteRuntime.ts | 236 +++++---------------- src/node/runtime/execFileIO.ts | 214 +++++++++++++++++++ src/node/runtime/hostGlobalXumHome.test.ts | 54 +---- src/node/runtime/testRemoteRuntime.ts | 61 ++++++ src/node/services/hooks.test.ts | 12 +- src/node/services/hooks.ts | 15 +- src/node/services/tools/testHelpers.ts | 44 +--- src/node/services/tools/xum_agents.test.ts | 141 +----------- src/node/utils/runtime/helpers.ts | 34 --- 12 files changed, 401 insertions(+), 726 deletions(-) create mode 100644 src/node/runtime/execFileIO.ts create mode 100644 src/node/runtime/testRemoteRuntime.ts diff --git a/src/node/runtime/DevcontainerRuntime.ts b/src/node/runtime/DevcontainerRuntime.ts index 5994106ab00..11a743f3eb7 100644 --- a/src/node/runtime/DevcontainerRuntime.ts +++ b/src/node/runtime/DevcontainerRuntime.ts @@ -37,6 +37,13 @@ import { getErrorMessage } from "@/common/utils/errors"; import { log } from "@/node/services/log"; import { isGitRepository, stripTrailingSlashes } from "@/node/utils/pathUtils"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; +import { + ensureDirViaExec, + readFileViaExec, + statViaExec, + writeFileViaExec, + STAT_VIA_EXEC_COMMAND, +} from "./execFileIO"; const FILE_PATH_ENV = "XUM_INTERNAL_FILE_PATH"; const TEMP_FILE_PATH_ENV = "XUM_INTERNAL_TEMP_FILE_PATH"; @@ -282,183 +289,6 @@ export class DevcontainerRuntime extends LocalBaseRuntime { } } - private readFileViaExec(filePath: string, abortSignal?: AbortSignal): ReadableStream { - return new ReadableStream({ - start: async (controller) => { - try { - const stream = await this.exec(`cat "$${FILE_PATH_ENV}"`, { - cwd: this.getContainerBasePath(), - pathEnv: { [FILE_PATH_ENV]: filePath }, - timeout: 300, - abortSignal, - }); - - const reader = stream.stdout.getReader(); - const exitCodePromise = stream.exitCode; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - controller.enqueue(value); - } - - const code = await exitCodePromise; - if (code !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io"); - } - - controller.close(); - } catch (err) { - if (err instanceof RuntimeError) { - controller.error(err); - } else { - controller.error( - new RuntimeError( - `Failed to read file ${filePath}: ${getErrorMessage(err)}`, - "file_io", - err instanceof Error ? err : undefined - ) - ); - } - } - }, - }); - } - - private writeFileViaExec( - filePath: string, - abortSignal?: AbortSignal - ): WritableStream { - const tempPath = getAtomicWriteTempPath(filePath); - const writeCommand = `mkdir -p "$(dirname "$${FILE_PATH_ENV}")" && cat > "$${TEMP_FILE_PATH_ENV}" && mv "$${TEMP_FILE_PATH_ENV}" "$${FILE_PATH_ENV}"`; - - let execPromise: Promise | null = null; - const writeAbortController = new AbortController(); - const abortWrite = () => writeAbortController.abort(); - if (abortSignal?.aborted) { - writeAbortController.abort(); - } else { - abortSignal?.addEventListener("abort", abortWrite, { once: true }); - } - const cleanupAbortForwarder = () => { - abortSignal?.removeEventListener("abort", abortWrite); - }; - - const getExecStream = () => { - execPromise ??= this.exec(writeCommand, { - cwd: this.getContainerBasePath(), - pathEnv: { - [FILE_PATH_ENV]: filePath, - [TEMP_FILE_PATH_ENV]: tempPath, - }, - timeout: 300, - abortSignal: writeAbortController.signal, - }); - return execPromise; - }; - - return new WritableStream({ - write: async (chunk) => { - const stream = await getExecStream(); - const writer = stream.stdin.getWriter(); - try { - await writer.write(chunk); - } finally { - writer.releaseLock(); - } - }, - close: async () => { - try { - const stream = await getExecStream(); - await stream.stdin.close(); - const exitCode = await stream.exitCode; - - if (exitCode !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io"); - } - } finally { - cleanupAbortForwarder(); - } - }, - abort: async (reason?: unknown) => { - writeAbortController.abort(); - if (execPromise) { - try { - const stream = await execPromise; - await stream.stdin.abort(reason).catch(() => undefined); - await stream.exitCode.catch(() => undefined); - } finally { - cleanupAbortForwarder(); - } - } else { - cleanupAbortForwarder(); - } - throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io"); - }, - }); - } - - private async ensureDirViaExec(dirPath: string, abortSignal?: AbortSignal): Promise { - const stream = await this.exec(`mkdir -p "$${FILE_PATH_ENV}"`, { - cwd: this.getContainerBasePath(), - pathEnv: { [FILE_PATH_ENV]: dirPath }, - timeout: 10, - abortSignal, - }); - - await stream.stdin.close(); - - const [stdout, stderr, exitCode] = await Promise.all([ - streamToString(stream.stdout), - streamToString(stream.stderr), - stream.exitCode, - ]); - - if (exitCode !== 0) { - const extra = stderr.trim() || stdout.trim(); - throw new RuntimeError( - `Failed to create directory ${dirPath}: exit code ${exitCode}${extra ? `: ${extra}` : ""}`, - "file_io" - ); - } - } - - private async statViaExec(filePath: string, abortSignal?: AbortSignal): Promise { - // -L follows symlinks so symlinked paths report the target's type - const stream = await this.exec(`stat -L -c '%s %Y %F' "$${FILE_PATH_ENV}"`, { - cwd: this.getContainerBasePath(), - pathEnv: { [FILE_PATH_ENV]: filePath }, - timeout: 10, - abortSignal, - }); - - const [stdout, stderr, exitCode] = await Promise.all([ - streamToString(stream.stdout), - streamToString(stream.stderr), - stream.exitCode, - ]); - - if (exitCode !== 0) { - throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io"); - } - - const parts = stdout.trim().split(" "); - if (parts.length < 3) { - throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io"); - } - - const size = parseInt(parts[0], 10); - const mtime = parseInt(parts[1], 10); - const fileType = parts.slice(2).join(" "); - - return { - size, - modifiedTime: new Date(mtime * 1000), - isDirectory: fileType === "directory", - }; - } private mapPathForExec(filePath: string): string { // Issue #3709: paths embedded in exec scripts must use the container namespace. return this.mapHostPathToContainer(filePath) ?? filePath; @@ -739,7 +569,17 @@ export class DevcontainerRuntime extends LocalBaseRuntime { if (hostPath) { return super.readFile(hostPath, abortSignal); } - return this.readFileViaExec(filePath, abortSignal); + return readFileViaExec( + filePath, + (signal) => + this.exec(`cat "$${FILE_PATH_ENV}"`, { + cwd: this.getContainerBasePath(), + pathEnv: { [FILE_PATH_ENV]: filePath }, + timeout: 300, + abortSignal: signal, + }), + abortSignal + ); } override writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream { @@ -747,23 +587,53 @@ export class DevcontainerRuntime extends LocalBaseRuntime { if (hostPath) { return super.writeFile(hostPath, abortSignal); } - return this.writeFileViaExec(filePath, abortSignal); + return writeFileViaExec( + filePath, + (signal) => + this.exec( + `mkdir -p "$(dirname "$${FILE_PATH_ENV}")" && cat > "$${TEMP_FILE_PATH_ENV}" && mv "$${TEMP_FILE_PATH_ENV}" "$${FILE_PATH_ENV}"`, + { + cwd: this.getContainerBasePath(), + pathEnv: { + [FILE_PATH_ENV]: filePath, + [TEMP_FILE_PATH_ENV]: getAtomicWriteTempPath(filePath), + }, + timeout: 300, + abortSignal: signal, + } + ), + abortSignal + ); } - override async stat(filePath: string, abortSignal?: AbortSignal): Promise { + override stat(filePath: string, abortSignal?: AbortSignal): Promise { const hostPath = this.resolveHostPathForMounted(filePath); if (hostPath) { return super.stat(hostPath, abortSignal); } - return this.statViaExec(filePath, abortSignal); + return statViaExec(filePath, () => + this.exec(`${STAT_VIA_EXEC_COMMAND} "$${FILE_PATH_ENV}"`, { + cwd: this.getContainerBasePath(), + pathEnv: { [FILE_PATH_ENV]: filePath }, + timeout: 10, + abortSignal, + }) + ); } - override async ensureDir(dirPath: string, abortSignal?: AbortSignal): Promise { + override ensureDir(dirPath: string, abortSignal?: AbortSignal): Promise { const hostPath = this.resolveHostPathForMounted(dirPath); if (hostPath) { return super.ensureDir(hostPath, abortSignal); } - return this.ensureDirViaExec(dirPath, abortSignal); + return ensureDirViaExec(dirPath, () => + this.exec(`mkdir -p "$${FILE_PATH_ENV}"`, { + cwd: this.getContainerBasePath(), + pathEnv: { [FILE_PATH_ENV]: dirPath }, + timeout: 10, + abortSignal, + }) + ); } override async resolvePath(filePath: string): Promise { diff --git a/src/node/runtime/LocalBaseRuntime.test.ts b/src/node/runtime/LocalBaseRuntime.test.ts index bf7ead142ed..4720ef97ef2 100644 --- a/src/node/runtime/LocalBaseRuntime.test.ts +++ b/src/node/runtime/LocalBaseRuntime.test.ts @@ -104,33 +104,22 @@ describe("LocalBaseRuntime.resolvePath", () => { }); describe("LocalBaseRuntime.exec PATH handling", () => { - it("canonicalizes pathEnv values before command execution", async () => { - const runtime = new TestLocalRuntime(); - const stream = await runtime.exec('printf "%s" "$XUM_TEST_PATH"', { - cwd: os.tmpdir(), - pathEnv: { XUM_TEST_PATH: "~/runtime-path" }, - timeout: 5, - }); - await stream.stdin.close(); - - expect(await readStreamAsString(stream.stdout)).toBe(path.join(os.homedir(), "runtime-path")); - expect(await stream.exitCode).toBe(0); - }); - - it("expands product-home pathEnv values through getXumHome like file I/O", async () => { + it("canonicalizes pathEnv values with file I/O semantics (tilde and product home)", async () => { const xumRoot = await fs.mkdtemp(path.join(os.tmpdir(), "xum-root-")); const originalRoot = process.env.XUM_ROOT; process.env.XUM_ROOT = xumRoot; try { const runtime = new TestLocalRuntime(); - const stream = await runtime.exec('printf "%s" "$XUM_TEST_PATH"', { + const stream = await runtime.exec('printf "%s\\n%s" "$XUM_TEST_PATH" "$XUM_TEST_HOME"', { cwd: os.tmpdir(), - pathEnv: { XUM_TEST_PATH: "~/.xum/plans" }, + pathEnv: { XUM_TEST_PATH: "~/runtime-path", XUM_TEST_HOME: "~/.xum/plans" }, timeout: 5, }); await stream.stdin.close(); - expect(await readStreamAsString(stream.stdout)).toBe(path.join(xumRoot, "plans")); + expect(await readStreamAsString(stream.stdout)).toBe( + `${path.join(os.homedir(), "runtime-path")}\n${path.join(xumRoot, "plans")}` + ); expect(await stream.exitCode).toBe(0); } finally { if (originalRoot === undefined) { diff --git a/src/node/runtime/RemoteRuntime.test.ts b/src/node/runtime/RemoteRuntime.test.ts index 97852202733..1e442f56bb0 100644 --- a/src/node/runtime/RemoteRuntime.test.ts +++ b/src/node/runtime/RemoteRuntime.test.ts @@ -1,64 +1,15 @@ import { describe, expect, it } from "bun:test"; import type { ExecOptions, ExecStream } from "./Runtime"; -import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; +import type { SpawnResult } from "./RemoteRuntime"; +import { TestRemoteRuntime } from "./testRemoteRuntime"; -class RecordingRemoteRuntime extends RemoteRuntime { +class RecordingRemoteRuntime extends TestRemoteRuntime { spawnCount = 0; - protected readonly commandPrefix = "Recording"; - - protected getBasePath(): string { - return "/workspace"; - } - - protected quoteForRemote(filePath: string): string { - return `'${filePath}'`; - } - - protected cdCommand(cwd: string): string { - return `cd '${cwd}'`; - } - - protected spawnRemoteProcess(): Promise { + protected override spawnRemoteProcess(): Promise { this.spawnCount += 1; throw new Error("spawn should not be called"); } - - resolvePath(filePath: string): Promise { - return Promise.resolve(filePath); - } - - getWorkspacePath(): string { - return "/workspace"; - } - - createWorkspace() { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - initWorkspace() { - return Promise.resolve({ success: true }); - } - - deleteWorkspace() { - return Promise.resolve({ success: true as const, deletedPath: "/workspace" }); - } - - renameWorkspace() { - return Promise.resolve({ - success: true as const, - oldPath: "/workspace", - newPath: "/workspace", - }); - } - - forkWorkspace() { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - ensureReady() { - return Promise.resolve({ ready: true as const }); - } } function createStream(value: string): ReadableStream { diff --git a/src/node/runtime/RemoteRuntime.ts b/src/node/runtime/RemoteRuntime.ts index 9042ab16d4d..db4a1b235b2 100644 --- a/src/node/runtime/RemoteRuntime.ts +++ b/src/node/runtime/RemoteRuntime.ts @@ -36,11 +36,17 @@ import { log } from "@/node/services/log"; import { attachStreamErrorHandler } from "@/node/utils/streamErrors"; import { NON_INTERACTIVE_ENV_VARS } from "@/common/constants/env"; import { DisposableProcess } from "@/node/utils/disposableExec"; -import { streamToString, shescape } from "./streamUtils"; -import { getErrorMessage } from "@/common/utils/errors"; +import { shescape } from "./streamUtils"; import { getAtomicWriteTempPath } from "./atomicWriteTempPath"; import { buildShellExport, buildShellPathExport } from "./shellEnv"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; +import { + ensureDirViaExec, + readFileViaExec, + statViaExec, + writeFileViaExec, + STAT_VIA_EXEC_COMMAND, +} from "./execFileIO"; // Cap for the stderr side-buffer kept purely for error reporting on process // failure. 16KB comfortably covers SSH/launch diagnostics while bounding memory @@ -394,71 +400,18 @@ export abstract class RemoteRuntime implements Runtime { * Read file contents as a stream via exec. */ readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream { - // Internal controller so CANCELLING the returned stream kills the remote - // cat: the eager pump below has no other path to the exec, and without - // it a cancelled wrapper (e.g. mux.load's byte ceiling) left cat blocked - // until its 300s timeout, accumulating remote processes (r18). The - // caller's abortSignal forwards into the same controller. - const readAbort = new AbortController(); - const forwardAbort = () => readAbort.abort(); - if (abortSignal?.aborted) { - readAbort.abort(); - } else { - abortSignal?.addEventListener("abort", forwardAbort, { once: true }); - } - const cleanupAbortForwarder = () => { - abortSignal?.removeEventListener("abort", forwardAbort); - }; - - return new ReadableStream({ - cancel: () => { - readAbort.abort(); - cleanupAbortForwarder(); - }, - start: async (controller: ReadableStreamDefaultController) => { - try { - const resolvedPath = await this.resolveFilePath(filePath, readAbort.signal); - const stream = await this.exec(`cat ${this.quoteForRemote(resolvedPath)}`, { - cwd: this.getBasePath(), - timeout: 300, - abortSignal: readAbort.signal, - }); - - const reader = stream.stdout.getReader(); - const exitCodePromise = stream.exitCode; - - while (true) { - const { done, value } = await reader.read(); - if (done) break; - controller.enqueue(value); - } - - const code = await exitCodePromise; - if (code !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io"); - } - - controller.close(); - } catch (err) { - if (err instanceof RuntimeError) { - controller.error(err); - } else { - controller.error( - new RuntimeError( - `Failed to read file ${filePath}: ${getErrorMessage(err)}`, - "file_io", - err instanceof Error ? err : undefined - ) - ); - } - } finally { - // Natural completion/error: stop listening on the caller's signal - // so long-lived signals don't accumulate forwarders. - cleanupAbortForwarder(); - } + return readFileViaExec( + filePath, + async (signal) => { + const resolvedPath = await this.resolveFilePath(filePath, signal); + return this.exec(`cat ${this.quoteForRemote(resolvedPath)}`, { + cwd: this.getBasePath(), + timeout: 300, + abortSignal: signal, + }); }, - }); + abortSignal + ); } /** @@ -466,75 +419,20 @@ export abstract class RemoteRuntime implements Runtime { * Uses temp file + mv for atomic write. */ writeFile(filePath: string, abortSignal?: AbortSignal): WritableStream { - let execPromise: Promise | null = null; - const writeAbortController = new AbortController(); - const abortWrite = () => writeAbortController.abort(); - if (abortSignal?.aborted) { - writeAbortController.abort(); - } else { - abortSignal?.addEventListener("abort", abortWrite, { once: true }); - } - const cleanupAbortForwarder = () => { - abortSignal?.removeEventListener("abort", abortWrite); - }; - - const getExecStream = () => { - execPromise ??= this.resolveFilePath(filePath, writeAbortController.signal).then( - (resolvedPath) => { - const quotedPath = this.quoteForRemote(resolvedPath); - const tempPath = getAtomicWriteTempPath(resolvedPath); - const quotedTempPath = this.quoteForRemote(tempPath); - const writeCommand = this.buildWriteCommand(quotedPath, quotedTempPath); - return this.exec(writeCommand, { - cwd: this.getBasePath(), - timeout: 300, - abortSignal: writeAbortController.signal, - }); - } - ); - return execPromise; - }; - - return new WritableStream({ - write: async (chunk: Uint8Array) => { - const stream = await getExecStream(); - const writer = stream.stdin.getWriter(); - try { - await writer.write(chunk); - } finally { - writer.releaseLock(); - } - }, - close: async () => { - try { - const stream = await getExecStream(); - await stream.stdin.close(); - const exitCode = await stream.exitCode; - - if (exitCode !== 0) { - const stderr = await streamToString(stream.stderr); - throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io"); - } - } finally { - cleanupAbortForwarder(); - } - }, - abort: async (reason?: unknown) => { - writeAbortController.abort(); - if (execPromise) { - try { - const stream = await execPromise; - await stream.stdin.abort(reason).catch(() => undefined); - await stream.exitCode.catch(() => undefined); - } finally { - cleanupAbortForwarder(); - } - } else { - cleanupAbortForwarder(); - } - throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io"); + return writeFileViaExec( + filePath, + async (signal) => { + const resolvedPath = await this.resolveFilePath(filePath, signal); + const quotedPath = this.quoteForRemote(resolvedPath); + const quotedTempPath = this.quoteForRemote(getAtomicWriteTempPath(resolvedPath)); + return this.exec(this.buildWriteCommand(quotedPath, quotedTempPath), { + cwd: this.getBasePath(), + timeout: 300, + abortSignal: signal, + }); }, - }); + abortSignal + ); } /** @@ -548,67 +446,29 @@ export abstract class RemoteRuntime implements Runtime { /** * Ensure a directory exists (mkdir -p semantics). */ - async ensureDir(dirPath: string, abortSignal?: AbortSignal): Promise { - const resolvedPath = await this.resolveFilePath(dirPath, abortSignal); - const stream = await this.exec(`mkdir -p ${this.quoteForRemote(resolvedPath)}`, { - cwd: "/", - timeout: 10, - abortSignal, + ensureDir(dirPath: string, abortSignal?: AbortSignal): Promise { + return ensureDirViaExec(dirPath, async () => { + const resolvedPath = await this.resolveFilePath(dirPath, abortSignal); + return this.exec(`mkdir -p ${this.quoteForRemote(resolvedPath)}`, { + cwd: "/", + timeout: 10, + abortSignal, + }); }); - - await stream.stdin.close(); - - const [stdout, stderr, exitCode] = await Promise.all([ - streamToString(stream.stdout), - streamToString(stream.stderr), - stream.exitCode, - ]); - - if (exitCode !== 0) { - const extra = stderr.trim() || stdout.trim(); - throw new RuntimeError( - `Failed to create directory ${dirPath}: exit code ${exitCode}${extra ? `: ${extra}` : ""}`, - "file_io" - ); - } } /** * Get file statistics via exec. - * Uses stat -L to follow symlinks (report target's type, not "symbolic link"). */ - async stat(filePath: string, abortSignal?: AbortSignal): Promise { - const resolvedPath = await this.resolveFilePath(filePath, abortSignal); - const stream = await this.exec(`stat -L -c '%s %Y %F' ${this.quoteForRemote(resolvedPath)}`, { - cwd: this.getBasePath(), - timeout: 10, - abortSignal, + stat(filePath: string, abortSignal?: AbortSignal): Promise { + return statViaExec(filePath, async () => { + const resolvedPath = await this.resolveFilePath(filePath, abortSignal); + return this.exec(`${STAT_VIA_EXEC_COMMAND} ${this.quoteForRemote(resolvedPath)}`, { + cwd: this.getBasePath(), + timeout: 10, + abortSignal, + }); }); - - const [stdout, stderr, exitCode] = await Promise.all([ - streamToString(stream.stdout), - streamToString(stream.stderr), - stream.exitCode, - ]); - - if (exitCode !== 0) { - throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io"); - } - - const parts = stdout.trim().split(" "); - if (parts.length < 3) { - throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io"); - } - - const size = parseInt(parts[0], 10); - const mtime = parseInt(parts[1], 10); - const fileType = parts.slice(2).join(" "); - - return { - size, - modifiedTime: new Date(mtime * 1000), - isDirectory: fileType === "directory", - }; } /** diff --git a/src/node/runtime/execFileIO.ts b/src/node/runtime/execFileIO.ts new file mode 100644 index 00000000000..9a7d5afaa86 --- /dev/null +++ b/src/node/runtime/execFileIO.ts @@ -0,0 +1,214 @@ +/** + * Shared exec-backed file I/O for runtimes whose file operations run shell + * commands (RemoteRuntime and DevcontainerRuntime's in-container fallback). + * Callers own command construction and path canonicalization via the + * startExec factory; these helpers own the streaming, abort, and error + * plumbing so all exec-backed runtimes behave identically. + */ + +import type { ExecStream, FileStat } from "./Runtime"; +import { RuntimeError } from "./Runtime"; +import { getErrorMessage } from "@/common/utils/errors"; +import { streamToString } from "./streamUtils"; + +/** Starts the exec for one file operation; must honor the given signal. */ +type StartExec = (abortSignal: AbortSignal) => Promise; + +/** + * Read file contents as a stream via exec. + */ +export function readFileViaExec( + filePath: string, + startExec: StartExec, + abortSignal?: AbortSignal +): ReadableStream { + // Internal controller so CANCELLING the returned stream kills the remote + // cat: the eager pump below has no other path to the exec, and without + // it a cancelled wrapper (e.g. mux.load's byte ceiling) left cat blocked + // until its 300s timeout, accumulating remote processes (r18). The + // caller's abortSignal forwards into the same controller. + const readAbort = new AbortController(); + const forwardAbort = () => readAbort.abort(); + if (abortSignal?.aborted) { + readAbort.abort(); + } else { + abortSignal?.addEventListener("abort", forwardAbort, { once: true }); + } + const cleanupAbortForwarder = () => { + abortSignal?.removeEventListener("abort", forwardAbort); + }; + + return new ReadableStream({ + cancel: () => { + readAbort.abort(); + cleanupAbortForwarder(); + }, + start: async (controller: ReadableStreamDefaultController) => { + try { + const stream = await startExec(readAbort.signal); + const reader = stream.stdout.getReader(); + const exitCodePromise = stream.exitCode; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + controller.enqueue(value); + } + + const code = await exitCodePromise; + if (code !== 0) { + const stderr = await streamToString(stream.stderr); + throw new RuntimeError(`Failed to read file ${filePath}: ${stderr}`, "file_io"); + } + + controller.close(); + } catch (err) { + if (err instanceof RuntimeError) { + controller.error(err); + } else { + controller.error( + new RuntimeError( + `Failed to read file ${filePath}: ${getErrorMessage(err)}`, + "file_io", + err instanceof Error ? err : undefined + ) + ); + } + } finally { + // Natural completion/error: stop listening on the caller's signal + // so long-lived signals don't accumulate forwarders. + cleanupAbortForwarder(); + } + }, + }); +} + +/** + * Write file contents atomically via exec. The exec starts lazily on the + * first write, so an abort before any chunk never spawns a process. + */ +export function writeFileViaExec( + filePath: string, + startExec: StartExec, + abortSignal?: AbortSignal +): WritableStream { + let execPromise: Promise | null = null; + const writeAbortController = new AbortController(); + const abortWrite = () => writeAbortController.abort(); + if (abortSignal?.aborted) { + writeAbortController.abort(); + } else { + abortSignal?.addEventListener("abort", abortWrite, { once: true }); + } + const cleanupAbortForwarder = () => { + abortSignal?.removeEventListener("abort", abortWrite); + }; + + const getExecStream = () => { + execPromise ??= startExec(writeAbortController.signal); + return execPromise; + }; + + return new WritableStream({ + write: async (chunk: Uint8Array) => { + const stream = await getExecStream(); + const writer = stream.stdin.getWriter(); + try { + await writer.write(chunk); + } finally { + writer.releaseLock(); + } + }, + close: async () => { + try { + const stream = await getExecStream(); + await stream.stdin.close(); + const exitCode = await stream.exitCode; + + if (exitCode !== 0) { + const stderr = await streamToString(stream.stderr); + throw new RuntimeError(`Failed to write file ${filePath}: ${stderr}`, "file_io"); + } + } finally { + cleanupAbortForwarder(); + } + }, + abort: async (reason?: unknown) => { + writeAbortController.abort(); + if (execPromise) { + try { + const stream = await execPromise; + await stream.stdin.abort(reason).catch(() => undefined); + await stream.exitCode.catch(() => undefined); + } finally { + cleanupAbortForwarder(); + } + } else { + cleanupAbortForwarder(); + } + throw new RuntimeError(`Failed to write file ${filePath}: ${String(reason)}`, "file_io"); + }, + }); +} + +/** + * Ensure a directory exists (mkdir -p semantics). + */ +export async function ensureDirViaExec( + dirPath: string, + startExec: () => Promise +): Promise { + const stream = await startExec(); + await stream.stdin.close(); + + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(stream.stdout), + streamToString(stream.stderr), + stream.exitCode, + ]); + + if (exitCode !== 0) { + const extra = stderr.trim() || stdout.trim(); + throw new RuntimeError( + `Failed to create directory ${dirPath}: exit code ${exitCode}${extra ? `: ${extra}` : ""}`, + "file_io" + ); + } +} + +// -L follows symlinks so symlinked paths report the target's type. +export const STAT_VIA_EXEC_COMMAND = "stat -L -c '%s %Y %F'"; + +/** + * Get file statistics via exec; parses STAT_VIA_EXEC_COMMAND output. + */ +export async function statViaExec( + filePath: string, + startExec: () => Promise +): Promise { + const stream = await startExec(); + const [stdout, stderr, exitCode] = await Promise.all([ + streamToString(stream.stdout), + streamToString(stream.stderr), + stream.exitCode, + ]); + + if (exitCode !== 0) { + throw new RuntimeError(`Failed to stat ${filePath}: ${stderr}`, "file_io"); + } + + const parts = stdout.trim().split(" "); + if (parts.length < 3) { + throw new RuntimeError(`Failed to parse stat output for ${filePath}: ${stdout}`, "file_io"); + } + + const size = parseInt(parts[0], 10); + const mtime = parseInt(parts[1], 10); + const fileType = parts.slice(2).join(" "); + + return { + size, + modifiedTime: new Date(mtime * 1000), + isDirectory: fileType === "directory", + }; +} diff --git a/src/node/runtime/hostGlobalXumHome.test.ts b/src/node/runtime/hostGlobalXumHome.test.ts index b58261eddde..8877797b0ef 100644 --- a/src/node/runtime/hostGlobalXumHome.test.ts +++ b/src/node/runtime/hostGlobalXumHome.test.ts @@ -1,67 +1,17 @@ import { describe, expect, it } from "bun:test"; import { LEGACY_REMOTE_MUX_HOME } from "@/common/compat/legacyMux"; import { LocalRuntime } from "./LocalRuntime"; -import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; +import { TestRemoteRuntime } from "./testRemoteRuntime"; import { resolveGlobalRuntime, shouldUseHostGlobalXumFallback } from "./hostGlobalXumHome"; -class StubRemoteRuntime extends RemoteRuntime { +class StubRemoteRuntime extends TestRemoteRuntime { constructor(private readonly xumHome: string) { super(); } - protected readonly commandPrefix = "StubRemote"; - - protected getBasePath(): string { - return "/workspace"; - } - - protected quoteForRemote(filePath: string): string { - return `'${filePath}'`; - } - - protected cdCommand(cwd: string): string { - return `cd '${cwd}'`; - } - - protected spawnRemoteProcess(): Promise { - throw new Error("spawn should not be called"); - } - override getXumHome(): string { return this.xumHome; } - - resolvePath(filePath: string): Promise { - return Promise.resolve(filePath); - } - - getWorkspacePath(): string { - return "/workspace"; - } - - createWorkspace() { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - initWorkspace() { - return Promise.resolve({ success: true }); - } - - deleteWorkspace() { - return Promise.resolve({ success: true as const, deletedPath: "/workspace" }); - } - - renameWorkspace() { - return Promise.resolve({ - success: true as const, - oldPath: "/workspace", - newPath: "/workspace", - }); - } - - forkWorkspace() { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } } describe("hostGlobalXumHome", () => { diff --git a/src/node/runtime/testRemoteRuntime.ts b/src/node/runtime/testRemoteRuntime.ts new file mode 100644 index 00000000000..edaa4b9da6b --- /dev/null +++ b/src/node/runtime/testRemoteRuntime.ts @@ -0,0 +1,61 @@ +import { RemoteRuntime, type SpawnResult } from "./RemoteRuntime"; + +/** + * Minimal concrete RemoteRuntime for tests: identity path resolution, throwing + * spawn, and stubbed lifecycle. Subclasses override only what they exercise. + */ +export class TestRemoteRuntime extends RemoteRuntime { + protected readonly commandPrefix: string = "TestRemote"; + + protected getBasePath(): string { + return "/workspace"; + } + + protected quoteForRemote(filePath: string): string { + return `'${filePath.replaceAll("'", "'\\''")}'`; + } + + protected cdCommand(cwd: string): string { + return `cd ${this.quoteForRemote(cwd)}`; + } + + protected spawnRemoteProcess(): Promise { + throw new Error("spawn should not be called"); + } + + resolvePath(filePath: string): Promise { + return Promise.resolve(filePath); + } + + getWorkspacePath(_projectPath: string, _workspaceName: string): string { + return "/workspace"; + } + + createWorkspace() { + return Promise.resolve({ success: false as const, error: "not implemented" }); + } + + initWorkspace() { + return Promise.resolve({ success: true }); + } + + deleteWorkspace() { + return Promise.resolve({ success: true as const, deletedPath: "/workspace" }); + } + + renameWorkspace() { + return Promise.resolve({ + success: true as const, + oldPath: "/workspace", + newPath: "/workspace", + }); + } + + forkWorkspace() { + return Promise.resolve({ success: false as const, error: "not implemented" }); + } + + ensureReady() { + return Promise.resolve({ ready: true as const }); + } +} diff --git a/src/node/services/hooks.test.ts b/src/node/services/hooks.test.ts index fe133492e4a..23b65b05fc7 100644 --- a/src/node/services/hooks.test.ts +++ b/src/node/services/hooks.test.ts @@ -1,4 +1,4 @@ -import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test"; +import { describe, test, expect, beforeEach, afterEach } from "bun:test"; import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; @@ -28,23 +28,17 @@ describe("hooks", () => { }); describe("exec path mapping", () => { - test("returns mapped project hook and tool_env paths after host discovery", async () => { + test("discovery returns host-namespace paths even on exec-mapping runtimes", async () => { const configDir = path.join(tempDir, ".xum"); const hookPath = path.join(configDir, "tool_hook"); const toolEnvPath = path.join(configDir, "tool_env"); - const execPrefix = "/workspaces/project"; await fs.mkdir(configDir, { recursive: true }); await fs.writeFile(hookPath, "#!/bin/bash\necho test"); await fs.writeFile(toolEnvPath, "export FOO=bar"); - const mappingRuntime = new ExecPathMappingRuntime(tempDir, tempDir, execPrefix); - const statSpy = spyOn(mappingRuntime, "stat"); - + const mappingRuntime = new ExecPathMappingRuntime(tempDir, tempDir, "/workspaces/project"); expect(await getHookPath(mappingRuntime, tempDir)).toBe(hookPath); expect(await getToolEnvPath(mappingRuntime, tempDir)).toBe(toolEnvPath); - const statPaths = statSpy.mock.calls.map(([filePath]) => filePath); - expect(statPaths).toContain(hookPath); - expect(statPaths).toContain(toolEnvPath); }); test("hook runners export the mapped project dir as XUM_PROJECT_DIR", async () => { diff --git a/src/node/services/hooks.ts b/src/node/services/hooks.ts index 7e6b8026007..cf733e8246a 100644 --- a/src/node/services/hooks.ts +++ b/src/node/services/hooks.ts @@ -11,6 +11,7 @@ import { withLegacyMuxEnvironmentAliases, } from "@/common/compat/legacyMux"; import { flattenToolHookValueToEnv } from "@/common/utils/tools/toolHookEnv"; +import { shellQuote } from "@/common/utils/shell"; import type { Runtime } from "@/node/runtime/Runtime"; import { log } from "@/node/services/log"; import { execBuffered, writeFileString } from "@/node/utils/runtime/helpers"; @@ -27,12 +28,6 @@ const DEFAULT_HOOK_PHASE_TIMEOUT_MS = 10_000; // 10 seconds const EXEC_MARKER_PREFIX = "MUX_EXEC_"; const HOOK_PATH_ENV = "XUM_INTERNAL_HOOK_PATH"; -/** Shell-escape a string for safe use in bash -c commands */ -function shellEscape(str: string): string { - // Wrap in single quotes and escape any embedded single quotes - return `'${str.replace(/'/g, "'\\''")}'`; -} - function buildHookCommand(): string { return `hook_path="$${HOOK_PATH_ENV}"; unset ${HOOK_PATH_ENV}; "$hook_path"`; } @@ -332,7 +327,7 @@ export async function runWithHook( log.error("[hooks] Failed to spawn hook", { hookPath, error: err }); if (toolInputPath) { try { - await execBuffered(runtime, `rm -f ${shellEscape(toolInputPath)}`, { + await execBuffered(runtime, `rm -f ${shellQuote(toolInputPath)}`, { cwd: context.projectDir, timeout: 5, }); @@ -512,7 +507,7 @@ export async function runWithHook( if (toolInputPath) { try { - await execBuffered(runtime, `rm -f ${shellEscape(toolInputPath)}`, { + await execBuffered(runtime, `rm -f ${shellQuote(toolInputPath)}`, { cwd: context.projectDir, timeout: 5, }); @@ -736,7 +731,7 @@ export async function runPostHook( if (!resultPathForEnv) return; try { - await execBuffered(runtime, `rm -f ${shellEscape(resultPathForEnv)}`, { + await execBuffered(runtime, `rm -f ${shellQuote(resultPathForEnv)}`, { cwd: context.projectDir, timeout: 5, }); @@ -805,7 +800,7 @@ async function prepareToolInput( const cleanup = async () => { if (toolInputPath) { try { - await execBuffered(runtime, `rm -f ${shellEscape(toolInputPath)}`, { + await execBuffered(runtime, `rm -f ${shellQuote(toolInputPath)}`, { cwd: projectDir, timeout: 5, }); diff --git a/src/node/services/tools/testHelpers.ts b/src/node/services/tools/testHelpers.ts index 8687f74cad7..49ebf0970e0 100644 --- a/src/node/services/tools/testHelpers.ts +++ b/src/node/services/tools/testHelpers.ts @@ -4,7 +4,7 @@ import * as path from "path"; import * as os from "os"; import type { ToolExecutionOptions } from "ai"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; -import { RemoteRuntime, type SpawnResult } from "@/node/runtime/RemoteRuntime"; +import { TestRemoteRuntime } from "@/node/runtime/testRemoteRuntime"; import { InitStateManager } from "@/node/services/initStateManager"; import { Config } from "@/node/config"; import type { ToolConfiguration } from "@/common/utils/tools/tools"; @@ -305,7 +305,7 @@ export class RemotePathMappedRuntime extends LocalRuntime { } } -export class TrueRemotePathMappedRuntime extends RemoteRuntime { +export class TrueRemotePathMappedRuntime extends TestRemoteRuntime { private readonly delegate: RemotePathMappedRuntime; private readonly remoteBase: string; @@ -315,24 +315,10 @@ export class TrueRemotePathMappedRuntime extends RemoteRuntime { this.delegate = new RemotePathMappedRuntime(localBase, remoteBase); } - protected readonly commandPrefix = "TestRemoteRuntime"; - - protected spawnRemoteProcess(): Promise { - throw new Error("spawnRemoteProcess should not be called"); - } - - protected getBasePath(): string { + protected override getBasePath(): string { return this.remoteBase; } - protected quoteForRemote(targetPath: string): string { - return `'${targetPath.replaceAll("'", "'\\''")}'`; - } - - protected cdCommand(cwd: string): string { - return `cd ${this.quoteForRemote(cwd)}`; - } - override exec( command: string, options: Parameters[1] @@ -373,30 +359,6 @@ export class TrueRemotePathMappedRuntime extends RemoteRuntime { override ensureDir(dirPath: string): ReturnType { return this.delegate.ensureDir(dirPath); } - - override createWorkspace(_params: Parameters[0]) { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - override initWorkspace(_params: Parameters[0]) { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - override renameWorkspace( - _projectPath: string, - _oldWorkspaceName: string, - _newWorkspaceName: string - ) { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - override deleteWorkspace(_projectPath: string, _workspaceName: string, _deleteBranch: boolean) { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - override forkWorkspace(_params: Parameters[0]) { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } } let testConfig: Config | null = null; diff --git a/src/node/services/tools/xum_agents.test.ts b/src/node/services/tools/xum_agents.test.ts index e741cbfaf1c..6a55c70e18a 100644 --- a/src/node/services/tools/xum_agents.test.ts +++ b/src/node/services/tools/xum_agents.test.ts @@ -4,7 +4,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import type { ToolExecutionOptions } from "ai"; -import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { LocalRuntime } from "@/node/runtime/LocalRuntime"; const GLOBAL_WORKSPACE_ID = "workspace-global"; const GLOBAL_WORKSPACE_NAME = "global-scope"; const GLOBAL_WORKSPACE_TITLE = "Global Scope"; @@ -14,7 +14,7 @@ import { FILE_EDIT_DIFF_OMITTED_MESSAGE } from "@/common/types/tools"; import { resolveAgentsPathOnRuntime } from "./xum_agents_path"; import { createXumAgentsReadTool } from "./xum_agents_read"; import { createXumAgentsWriteTool } from "./xum_agents_write"; -import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import { TestTempDir, createTestToolConfig, RemotePathMappedRuntime } from "./testHelpers"; const mockToolCallOptions: ToolExecutionOptions = { toolCallId: "test-call-id", @@ -108,143 +108,6 @@ function mockAgentsPathProbe( }); } -class RemotePathMappedRuntime extends LocalRuntime { - private readonly localWorkspaceRoot: string; - private readonly remoteWorkspaceRoot: string; - private readonly localHomeForTildeRoot: string | null; - - constructor(localWorkspaceRoot: string, remoteWorkspaceRoot: string) { - super(localWorkspaceRoot); - this.localWorkspaceRoot = path.resolve(localWorkspaceRoot); - this.remoteWorkspaceRoot = - remoteWorkspaceRoot === "/" ? remoteWorkspaceRoot : remoteWorkspaceRoot.replace(/\/+$/u, ""); - - if (this.remoteWorkspaceRoot === "~") { - this.localHomeForTildeRoot = this.localWorkspaceRoot; - } else if (this.remoteWorkspaceRoot.startsWith("~/")) { - const homeRelativeSuffix = this.remoteWorkspaceRoot.slice(1); - const normalizedLocalRoot = this.localWorkspaceRoot.replaceAll("\\", "/"); - if (normalizedLocalRoot.endsWith(homeRelativeSuffix)) { - const derivedHome = normalizedLocalRoot.slice( - 0, - normalizedLocalRoot.length - homeRelativeSuffix.length - ); - this.localHomeForTildeRoot = derivedHome.length > 0 ? derivedHome : "/"; - } else { - this.localHomeForTildeRoot = null; - } - } else { - this.localHomeForTildeRoot = null; - } - } - - private usesTildeWorkspaceRoot(): boolean { - return this.remoteWorkspaceRoot === "~" || this.remoteWorkspaceRoot.startsWith("~/"); - } - - private toLocalPath(runtimePath: string): string { - const normalizedRuntimePath = runtimePath.replaceAll("\\", "/"); - - if (normalizedRuntimePath === this.remoteWorkspaceRoot) { - return this.localWorkspaceRoot; - } - - if (normalizedRuntimePath.startsWith(`${this.remoteWorkspaceRoot}/`)) { - const suffix = normalizedRuntimePath.slice(this.remoteWorkspaceRoot.length + 1); - return path.join(this.localWorkspaceRoot, ...suffix.split("/")); - } - - return runtimePath; - } - - private toRemotePath(localPath: string): string { - const resolvedLocalPath = path.resolve(localPath); - - if (resolvedLocalPath === this.localWorkspaceRoot) { - return this.remoteWorkspaceRoot; - } - - const localPrefix = `${this.localWorkspaceRoot}${path.sep}`; - if (resolvedLocalPath.startsWith(localPrefix)) { - const suffix = resolvedLocalPath.slice(localPrefix.length).split(path.sep).join("/"); - return `${this.remoteWorkspaceRoot}/${suffix}`; - } - - return localPath.replaceAll("\\", "/"); - } - - private translateCommandToLocal(command: string): string { - return command - .split(this.remoteWorkspaceRoot) - .join(this.localWorkspaceRoot.replaceAll("\\", "/")); - } - - override normalizePath(targetPath: string, basePath: string): string { - const normalizedBasePath = this.toRemotePath(basePath); - const normalizedTargetPath = targetPath.replaceAll("\\", "/"); - - if (normalizedBasePath === "~" || normalizedBasePath.startsWith("~/")) { - if ( - normalizedTargetPath === "~" || - normalizedTargetPath.startsWith("~/") || - normalizedTargetPath.startsWith("/") - ) { - return normalizedTargetPath; - } - return path.posix.normalize(path.posix.join(normalizedBasePath, normalizedTargetPath)); - } - - return path.posix.resolve(normalizedBasePath, normalizedTargetPath); - } - - override async resolvePath(filePath: string): Promise { - const resolvedLocalPath = await super.resolvePath(this.toLocalPath(filePath)); - return this.toRemotePath(resolvedLocalPath); - } - - override exec( - command: string, - options: Parameters[1] - ): ReturnType { - const usesTildeRoot = this.usesTildeWorkspaceRoot(); - const localHomeForTildeRoot = - this.localHomeForTildeRoot ?? process.env.HOME ?? this.localWorkspaceRoot; - - return super.exec(usesTildeRoot ? command : this.translateCommandToLocal(command), { - ...options, - cwd: this.toLocalPath(options.cwd), - env: usesTildeRoot - ? { - ...(options.env ?? {}), - HOME: localHomeForTildeRoot, - } - : options.env, - }); - } - - override stat(filePath: string, abortSignal?: AbortSignal): ReturnType { - return super.stat(this.toLocalPath(filePath), abortSignal); - } - - override readFile( - filePath: string, - abortSignal?: AbortSignal - ): ReturnType { - return super.readFile(this.toLocalPath(filePath), abortSignal); - } - - override writeFile( - filePath: string, - abortSignal?: AbortSignal - ): ReturnType { - return super.writeFile(this.toLocalPath(filePath), abortSignal); - } - - override ensureDir(dirPath: string): ReturnType { - return super.ensureDir(this.toLocalPath(dirPath)); - } -} - /** Simulates BSD/macOS where readlink doesn't support -f */ class NoReadlinkFRemoteRuntime extends RemotePathMappedRuntime { override exec( diff --git a/src/node/utils/runtime/helpers.ts b/src/node/utils/runtime/helpers.ts index ef613069127..985fc71811a 100644 --- a/src/node/utils/runtime/helpers.ts +++ b/src/node/utils/runtime/helpers.ts @@ -238,40 +238,6 @@ export async function movePlanFile( } } -/** - * Copy a plan file from one workspace to another (e.g., during fork). - * Checks both new path format and legacy path format for the source. - * Silently succeeds if source file doesn't exist at either location. - */ -export async function copyPlanFile( - runtime: Runtime, - sourceWorkspaceName: string, - sourceWorkspaceId: string, - targetWorkspaceName: string, - projectName: string -): Promise { - const xumHome = runtime.getXumHome(); - const sourcePath = getPlanFilePath(sourceWorkspaceName, projectName, xumHome); - const legacySourcePath = getLegacyPlanFilePath(sourceWorkspaceId, xumHome); - const targetPath = getPlanFilePath(targetWorkspaceName, projectName, xumHome); - - // Prefer the new layout, but fall back to the legacy layout. - // - // Note: we intentionally use runtime file I/O instead of `cp` because: - // 1) bash doesn't expand ~ inside quotes - // 2) the target per-project plan directory may not exist yet - // 3) runtime.writeFile() already handles directory creation + tilde expansion - for (const candidatePath of [sourcePath, legacySourcePath]) { - try { - const content = await readFileString(runtime, candidatePath); - await writeFileString(runtime, targetPath, content); - return; - } catch { - // Try next candidate - } - } -} - /** * Copy a plan file across runtimes (e.g., during fork where source/target may be * different containers). Uses separate runtime handles to avoid the identity mutation From ffe21c470273c134452207f1e971edac4af68aa4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:24:25 +0000 Subject: [PATCH 13/20] refactor(tools): deepen tool definition catalog --- src/common/utils/tools/toolDefinitions.ts | 2338 +++++++++---------- src/common/utils/tools/tools.ts | 2 +- src/node/services/ptc/toolBridge.ts | 34 +- src/node/services/ptc/typeGenerator.test.ts | 2 +- src/node/services/ptc/typeGenerator.ts | 9 +- 5 files changed, 1177 insertions(+), 1208 deletions(-) diff --git a/src/common/utils/tools/toolDefinitions.ts b/src/common/utils/tools/toolDefinitions.ts index f4f5cb52bc0..6a6f89ff5f8 100644 --- a/src/common/utils/tools/toolDefinitions.ts +++ b/src/common/utils/tools/toolDefinitions.ts @@ -1786,162 +1786,590 @@ const BashMonitorSchema = z * Tool definitions: single source of truth * Key = tool name, Value = { description, schema } */ -export const TOOL_DEFINITIONS = { - bash: { - description: - "Execute a bash command with a configurable timeout. " + - `Output is strictly limited to ${BASH_HARD_MAX_LINES} lines, ${BASH_MAX_LINE_BYTES} bytes per line, and ${BASH_MAX_TOTAL_BYTES} bytes total. ` + - "Commands that exceed these limits will FAIL with an error (no partial output returned). " + - "Be conservative: use 'head', 'tail', 'grep', or other filters to limit output before running commands. " + - "Large outputs may be automatically filtered; when this happens, the result includes a note explaining what was kept and (if available) where the full output was saved.\n" + - "On Windows this runs in Git Bash; to discard output use `>/dev/null` (not `>nul`). " + - "Background commands can include a monitor block with a regex filter; matching complete output lines wake this workspace, including after the current response, so no polling is required. Terminate monitors that are no longer relevant before finishing.", - schema: z.preprocess( - (value) => { - // Compatibility shims for models that emit alias fields: - // - some models emit `command` instead of `script` - // - DeepSeek v4 emits `description` instead of `display_name` - // Normalize both so downstream code (tool runner + UI) sees canonical args. - // Aliases are intentionally undocumented in the public schema; we don't - // want to invite other models to use the wrong field. - if (typeof value !== "object" || value === null || Array.isArray(value)) return value; +// ----------------------------------------------------------------------------- +// Result Schemas for Bridgeable Tools (PTC Type Generation) +// ----------------------------------------------------------------------------- +// These Zod schemas define the result types for tools exposed in the PTC sandbox. +// They serve as single source of truth for both: +// 1. TypeScript types in tools.ts (via z.infer<>) +// 2. Runtime type generation for PTC (via Zod → JSON Schema → TypeScript string) - let obj = value as Record; - obj = renameAliasField(obj, "command", "script"); - obj = renameAliasField(obj, "description", "display_name"); - return obj; - }, - z - .object({ - script: z.string().describe("The bash script/command to execute"), - model_intent: z - .string() - .nullish() - .describe( - "Optional. Short user-facing purpose for this command, shown next to the command in collapsed chat. " + - "Use a present-participle phrase in plain English, under 100 characters. " + - "Do not repeat the command or include duration, because Xum appends those. " + - "Examples: 'Running the unit tests', 'Checking repository state', 'Inspecting build output'." - ), - timeout_secs: z - .number() - .positive() - .describe( - "Timeout in seconds. For foreground: max execution time before kill. " + - "For background: max lifetime before auto-termination. " + - "Start small and increase on retry; avoid large initial values to keep UX responsive" - ), - run_in_background: z - .boolean() - .default(false) - .describe( - "Run this command in the background without blocking. " + - "Use for processes running >5s (dev servers, builds, file watchers). " + - "Do NOT use for quick commands (<5s), interactive processes (no stdin support), " + - "or processes requiring real-time output (use foreground with larger timeout instead). " + - "Returns immediately with a taskId (bash:) and backgroundProcessId. " + - "Read output with task_await (returns only new output since last check). " + - "Stop with task_stop using the taskId. " + - "List active tasks with task_list. " + - "Process persists until timeout_secs expires, terminated, or workspace is removed." + - "\\n\\nFor long-running tasks like builds or compilations, prefer background mode to continue productive work in parallel. " + - "Without a monitor, raw background bash does not automatically wake the parent workspace when it prints output or exits. " + - "With monitor, matching complete output lines wake this workspace, including after your current response, and the workspace is also woken when the process settles (exit, kill, timeout) unless wake_on_exit is false, the monitor was retired by max_events, or the task was explicitly cancelled (task_stop / terminate); use task_await only if you need surrounding/full output. " + - "Before finishing, terminate monitored tasks that are no longer relevant so stale output cannot trigger a follow-up turn. " + - "Do not call task_await in the same parallel tool-call batch; wait for the returned taskId first. " + - "When you actually need the output, read it with task_await; do not poll task_await just because the process is still running." - ), - monitor: BashMonitorSchema.nullish().describe( - "Wake-on-match monitor. Valid only with run_in_background=true. Matching complete output lines wake this workspace without polling, even after the current response, and the workspace also wakes when the monitored process settles (exit, kill, timeout) unless wake_on_exit is false, the monitor was retired by max_events, or the task was explicitly cancelled (task_stop / terminate); terminate it before finishing if future wakes are no longer useful." - ), - display_name: z - .string() - .describe( - "Human-readable name for the process (e.g., 'Dev Server', 'TypeCheck Watch'). " + - "Required for all bash invocations since any process can be sent to background." - ), - }) - .refine((args) => args.monitor == null || args.run_in_background === true, { - path: ["monitor"], - message: "monitor requires run_in_background=true", - }) - ), - }, - file_read: { - description: - "Read the contents of a file from the file system. Read as little as possible to complete the task. " + - "Content is returned with line numbers prepended in the format '\\t'. " + - "These line numbers are NOT part of the actual file content and must not be included when editing files.", - schema: z.preprocess( - normalizeFilePath, +/** + * Truncation info returned when output exceeds limits. + */ +const TruncatedInfoSchema = z.object({ + reason: z.string(), + totalLines: z.number(), +}); + +/** + * Bash tool result - success, background spawn, or failure. + */ +const BashToolSuccessSchema = z + .object({ + success: z.literal(true), + output: z.string(), + exitCode: z.literal(0), + wall_duration_ms: z.number(), + note: z.string().optional(), + truncated: TruncatedInfoSchema.optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema); + +const BashToolMonitorResultSchema = z + .object({ + filter: z.string(), + filter_exclude: z.boolean(), + cooldown_ms: z.number(), + max_events: z.number().optional(), + // Optional (not required) so persisted results written before this field existed still parse. + wake_on_exit: z.boolean().optional(), + }) + .strict(); + +const BashToolBackgroundSchema = z + .object({ + success: z.literal(true), + output: z.string(), + exitCode: z.literal(0), + wall_duration_ms: z.number(), + monitor: BashToolMonitorResultSchema.optional(), + taskId: z.string(), + backgroundProcessId: z.string(), + }) + .extend(ToolOutputUiOnlyFieldSchema); + +const BashToolFailureSchema = z + .object({ + success: z.literal(false), + output: z.string().optional(), + exitCode: z.number(), + error: z.string(), + wall_duration_ms: z.number(), + note: z.string().optional(), + truncated: TruncatedInfoSchema.optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema); + +export const BashToolResultSchema = z.union([ + // Foreground success + BashToolSuccessSchema, + // Background spawn success + BashToolBackgroundSchema, + // Failure + BashToolFailureSchema, +]); + +/** + * Bash output tool result - process status and incremental output. + */ +export const BashOutputToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + status: z.enum(["running", "exited", "killed", "failed", "interrupted"]), + output: z.string(), + exitCode: z.number().optional(), + note: z.string().optional(), + elapsed_ms: z.number(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +/** + * Bash background list tool result - all background processes. + */ +export const BashBackgroundListResultSchema = z.union([ + z.object({ + success: z.literal(true), + processes: z.array( z.object({ - path: z.string().describe("The path to the file to read (absolute or relative)"), - offset: z - .number() - .int() - .positive() - .nullish() - .describe("1-based starting line number (optional, defaults to 1)"), - limit: z - .number() - .int() - .positive() - .nullish() - .describe( - "Number of lines to return from offset (optional, returns all if not specified)" - ), + process_id: z.string(), + status: z.enum(["running", "exited", "killed", "failed"]), + script: z.string(), + uptime_ms: z.number(), + exitCode: z.number().optional(), + display_name: z.string().optional(), }) ), - }, - memory: { - description: - "Manage your persistent memory directory (experiment). " + - "MEMORY PROTOCOL: check relevant memories before acting on a task; record durable facts, preferences, and lessons as you learn them; update or delete memories that turn out to be wrong or stale.\n" + - "Scopes (all paths are virtual):\n" + - "- /memories/global/... — personal, permanent, shared across all projects\n" + - "- /memories/project/... — private notes about this project; host-local, never committed, survives workspaces\n" + - "- /memories/workspace/... — scratch state for this workspace; deleted with the workspace\n" + - "Commands:\n" + - "- view: list a directory (up to 2 levels, dotfiles excluded) or show a file with line numbers (offset/limit supported)\n" + - "- create: create a new file; ERRORS if the file already exists (to overwrite: delete first, then create)\n" + - "- str_replace: replace a unique occurrence of old_str with new_str (errors with matching line numbers when ambiguous)\n" + - "- insert: insert insert_text after line insert_line (0 = top of file)\n" + - "- delete: delete a file or directory (recursive)\n" + - "- rename: move old_path to new_path within the same scope\n" + - "Files are Markdown; optional YAML frontmatter with a one-line `description:` is surfaced in your memory index.", - schema: z.preprocess( - (value) => { - // Compatibility shims (same mechanism as bash command->script): models - // trained on our file tools may emit file tool field names. - const normalized = normalizeFilePath(value); // file_path/filePath -> path - if (typeof normalized !== "object" || normalized === null || Array.isArray(normalized)) { - return normalized; - } - let obj = normalized as Record; - obj = renameAliasField(obj, "content", "file_text"); - obj = renameAliasField(obj, "old_string", "old_str"); - obj = renameAliasField(obj, "new_string", "new_str"); - return obj; - }, - z.object({ - command: z - .enum(["view", "create", "str_replace", "insert", "delete", "rename"]) - .describe("The memory operation to perform."), - path: z - .string() - .nullish() - .describe( - "Virtual memory path (e.g. /memories/global/notes.md). Required for every command except rename." - ), - file_text: z.string().nullish().describe("create: full contents of the new file."), - old_str: z - .string() - .nullish() - .describe("str_replace: exact text to replace (must be unique in the file)."), - new_str: z.string().nullish().describe("str_replace: replacement text."), - insert_line: z - .number() - .int() + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +/** + * Bash background terminate tool result. + */ +export const BashBackgroundTerminateResultSchema = z.union([ + z.object({ + success: z.literal(true), + message: z.string(), + display_name: z.string().optional(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +/** + * xum_agents_read tool result. + */ +export const XumAgentsReadToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + content: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +/** + * xum_agents_write tool result. + */ +export const XumAgentsWriteToolResultSchema = z.union([ + z + .object({ + success: z.literal(true), + diff: z.string(), + }) + .extend(ToolOutputUiOnlyFieldSchema), + z + .object({ + success: z.literal(false), + error: z.string(), + }) + .extend(ToolOutputUiOnlyFieldSchema), +]); + +/** + * xum_config_read tool result. + */ +export const XumConfigReadToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + file: z.string(), + data: z.unknown(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +const XumConfigWriteValidationIssueSchema = z.object({ + path: z.array(z.union([z.string(), z.number()])), + message: z.string(), +}); + +/** + * xum_config_write tool result. + */ +export const XumConfigWriteToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + file: z.string(), + appliedOps: z.number(), + summary: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + validationIssues: z.array(XumConfigWriteValidationIssueSchema).optional(), + }), +]); + +/** + * File read tool result - content or error. + */ +export const FileReadToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + file_size: z.number(), + modifiedTime: z.string(), + lines_read: z.number(), + content: z + .string() + .describe( + "File content with line numbers prepended as '\\t'. " + + "Line numbers are not part of the actual file content." + ), + warning: z.string().optional(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +const AttachFileToolTextPartSchema = z + .object({ + type: z.literal("text"), + text: z.string(), + }) + .strict(); + +const AttachFileToolMediaPartSchema = z + .object({ + type: z.literal("media"), + data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), + }) + .strict(); + +const AttachFileToolDisplayFilePartSchema = z + .object({ + type: z.literal("display_file"), + data: z.string(), + mediaType: z.string(), + filename: z.string().optional(), + providerOptions: z + .object({ + mux: z + .object({ + displayOnly: z.literal(true), + size: z.number().int().nonnegative(), + }) + .strict() + .optional(), + }) + .strict() + .optional(), + }) + .strict(); + +const AttachFileToolSuccessResultSchema = z + .object({ + type: z.literal("content"), + value: z.union([ + z.tuple([AttachFileToolTextPartSchema, AttachFileToolMediaPartSchema]), + z.tuple([AttachFileToolTextPartSchema, AttachFileToolDisplayFilePartSchema]), + ]), + }) + .strict(); + +export const AttachFileToolResultSchema = z.union([ + AttachFileToolSuccessResultSchema, + z + .object({ + success: z.literal(false), + error: z.string(), + }) + .strict(), +]); + +/** + * Agent Skill read tool result - full SKILL.md package or error. + */ +export const AgentSkillReadToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + skill: AgentSkillPackageSchema, + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +/** + * Agent Skill read_file tool result. + * Uses the same shape/limits as file_read. + */ +export const AgentSkillReadFileToolResultSchema = FileReadToolResultSchema; + +/** + * MCP prompt get tool result - flattened prompt text or error. + */ +export const MCPPromptGetToolResultSchema = z.union([ + z + .object({ + success: z.literal(true), + text: z.string(), + description: z.string().optional(), + }) + .strict(), + z + .object({ + success: z.literal(false), + error: z.string(), + }) + .strict(), +]); + +/** + * File edit insert tool result - diff or error. + */ +export const FileEditInsertToolResultSchema = z.union([ + z + .object({ + success: z.literal(true), + diff: z.string(), + warning: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), + z + .object({ + success: z.literal(false), + error: z.string(), + note: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), +]); + +/** + * File edit replace string tool result - diff with edit count or error. + */ +export const FileEditReplaceStringToolResultSchema = z.union([ + z + .object({ + success: z.literal(true), + diff: z.string(), + edits_applied: z.number(), + warning: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), + z + .object({ + success: z.literal(false), + error: z.string(), + note: z.string().optional(), + }) + .extend(ToolOutputUiOnlyFieldSchema), +]); + +/** + * Web fetch tool result - parsed content or error. + */ +export const WebFetchToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + title: z.string(), + content: z.string(), + url: z.string(), + byline: z.string().optional(), + length: z.number(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + content: z.string().optional(), + }), +]); + +export const HeartbeatToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + action: HeartbeatToolActionSchema, + configured: z.boolean(), + settings: WorkspaceHeartbeatSettingsSchema.nullable(), + summary: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +// `recorded: false` means TimelineService throttled the note (duplicate description or too +// many agent events in a short window) and nothing was added to the timeline. +export const TimelineEventToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + recorded: z.boolean(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +export const MemoryToolResultSchema = z.union([ + z.object({ + success: z.literal(true), + output: z.string(), + }), + z.object({ + success: z.literal(false), + error: z.string(), + }), +]); + +interface ToolDefinition { + description: string; + schema: z.ZodType; + internal?: boolean; + resultSchema?: z.ZodType; + ptcExcluded?: string; +} + +export const TOOL_DEFINITIONS = { + bash: { + resultSchema: BashToolResultSchema, + description: + "Execute a bash command with a configurable timeout. " + + `Output is strictly limited to ${BASH_HARD_MAX_LINES} lines, ${BASH_MAX_LINE_BYTES} bytes per line, and ${BASH_MAX_TOTAL_BYTES} bytes total. ` + + "Commands that exceed these limits will FAIL with an error (no partial output returned). " + + "Be conservative: use 'head', 'tail', 'grep', or other filters to limit output before running commands. " + + "Large outputs may be automatically filtered; when this happens, the result includes a note explaining what was kept and (if available) where the full output was saved.\n" + + "On Windows this runs in Git Bash; to discard output use `>/dev/null` (not `>nul`). " + + "Background commands can include a monitor block with a regex filter; matching complete output lines wake this workspace, including after the current response, so no polling is required. Terminate monitors that are no longer relevant before finishing.", + schema: z.preprocess( + (value) => { + // Compatibility shims for models that emit alias fields: + // - some models emit `command` instead of `script` + // - DeepSeek v4 emits `description` instead of `display_name` + // Normalize both so downstream code (tool runner + UI) sees canonical args. + // Aliases are intentionally undocumented in the public schema; we don't + // want to invite other models to use the wrong field. + if (typeof value !== "object" || value === null || Array.isArray(value)) return value; + + let obj = value as Record; + obj = renameAliasField(obj, "command", "script"); + obj = renameAliasField(obj, "description", "display_name"); + return obj; + }, + z + .object({ + script: z.string().describe("The bash script/command to execute"), + model_intent: z + .string() + .nullish() + .describe( + "Optional. Short user-facing purpose for this command, shown next to the command in collapsed chat. " + + "Use a present-participle phrase in plain English, under 100 characters. " + + "Do not repeat the command or include duration, because Xum appends those. " + + "Examples: 'Running the unit tests', 'Checking repository state', 'Inspecting build output'." + ), + timeout_secs: z + .number() + .positive() + .describe( + "Timeout in seconds. For foreground: max execution time before kill. " + + "For background: max lifetime before auto-termination. " + + "Start small and increase on retry; avoid large initial values to keep UX responsive" + ), + run_in_background: z + .boolean() + .default(false) + .describe( + "Run this command in the background without blocking. " + + "Use for processes running >5s (dev servers, builds, file watchers). " + + "Do NOT use for quick commands (<5s), interactive processes (no stdin support), " + + "or processes requiring real-time output (use foreground with larger timeout instead). " + + "Returns immediately with a taskId (bash:) and backgroundProcessId. " + + "Read output with task_await (returns only new output since last check). " + + "Stop with task_stop using the taskId. " + + "List active tasks with task_list. " + + "Process persists until timeout_secs expires, terminated, or workspace is removed." + + "\\n\\nFor long-running tasks like builds or compilations, prefer background mode to continue productive work in parallel. " + + "Without a monitor, raw background bash does not automatically wake the parent workspace when it prints output or exits. " + + "With monitor, matching complete output lines wake this workspace, including after your current response, and the workspace is also woken when the process settles (exit, kill, timeout) unless wake_on_exit is false, the monitor was retired by max_events, or the task was explicitly cancelled (task_stop / terminate); use task_await only if you need surrounding/full output. " + + "Before finishing, terminate monitored tasks that are no longer relevant so stale output cannot trigger a follow-up turn. " + + "Do not call task_await in the same parallel tool-call batch; wait for the returned taskId first. " + + "When you actually need the output, read it with task_await; do not poll task_await just because the process is still running." + ), + monitor: BashMonitorSchema.nullish().describe( + "Wake-on-match monitor. Valid only with run_in_background=true. Matching complete output lines wake this workspace without polling, even after the current response, and the workspace also wakes when the monitored process settles (exit, kill, timeout) unless wake_on_exit is false, the monitor was retired by max_events, or the task was explicitly cancelled (task_stop / terminate); terminate it before finishing if future wakes are no longer useful." + ), + display_name: z + .string() + .describe( + "Human-readable name for the process (e.g., 'Dev Server', 'TypeCheck Watch'). " + + "Required for all bash invocations since any process can be sent to background." + ), + }) + .refine((args) => args.monitor == null || args.run_in_background === true, { + path: ["monitor"], + message: "monitor requires run_in_background=true", + }) + ), + }, + file_read: { + resultSchema: FileReadToolResultSchema, + description: + "Read the contents of a file from the file system. Read as little as possible to complete the task. " + + "Content is returned with line numbers prepended in the format '\\t'. " + + "These line numbers are NOT part of the actual file content and must not be included when editing files.", + schema: z.preprocess( + normalizeFilePath, + z.object({ + path: z.string().describe("The path to the file to read (absolute or relative)"), + offset: z + .number() + .int() + .positive() + .nullish() + .describe("1-based starting line number (optional, defaults to 1)"), + limit: z + .number() + .int() + .positive() + .nullish() + .describe( + "Number of lines to return from offset (optional, returns all if not specified)" + ), + }) + ), + }, + memory: { + resultSchema: MemoryToolResultSchema, + ptcExcluded: "Top-level presence supplies the memory index and hot-set context", + description: + "Manage your persistent memory directory (experiment). " + + "MEMORY PROTOCOL: check relevant memories before acting on a task; record durable facts, preferences, and lessons as you learn them; update or delete memories that turn out to be wrong or stale.\n" + + "Scopes (all paths are virtual):\n" + + "- /memories/global/... — personal, permanent, shared across all projects\n" + + "- /memories/project/... — private notes about this project; host-local, never committed, survives workspaces\n" + + "- /memories/workspace/... — scratch state for this workspace; deleted with the workspace\n" + + "Commands:\n" + + "- view: list a directory (up to 2 levels, dotfiles excluded) or show a file with line numbers (offset/limit supported)\n" + + "- create: create a new file; ERRORS if the file already exists (to overwrite: delete first, then create)\n" + + "- str_replace: replace a unique occurrence of old_str with new_str (errors with matching line numbers when ambiguous)\n" + + "- insert: insert insert_text after line insert_line (0 = top of file)\n" + + "- delete: delete a file or directory (recursive)\n" + + "- rename: move old_path to new_path within the same scope\n" + + "Files are Markdown; optional YAML frontmatter with a one-line `description:` is surfaced in your memory index.", + schema: z.preprocess( + (value) => { + // Compatibility shims (same mechanism as bash command->script): models + // trained on our file tools may emit file tool field names. + const normalized = normalizeFilePath(value); // file_path/filePath -> path + if (typeof normalized !== "object" || normalized === null || Array.isArray(normalized)) { + return normalized; + } + let obj = normalized as Record; + obj = renameAliasField(obj, "content", "file_text"); + obj = renameAliasField(obj, "old_string", "old_str"); + obj = renameAliasField(obj, "new_string", "new_str"); + return obj; + }, + z.object({ + command: z + .enum(["view", "create", "str_replace", "insert", "delete", "rename"]) + .describe("The memory operation to perform."), + path: z + .string() + .nullish() + .describe( + "Virtual memory path (e.g. /memories/global/notes.md). Required for every command except rename." + ), + file_text: z.string().nullish().describe("create: full contents of the new file."), + old_str: z + .string() + .nullish() + .describe("str_replace: exact text to replace (must be unique in the file)."), + new_str: z.string().nullish().describe("str_replace: replacement text."), + insert_line: z + .number() + .int() .nonnegative() .nullish() .describe("insert: line number to insert after (0 = top of file)."), @@ -1964,6 +2392,7 @@ export const TOOL_DEFINITIONS = { ), }, attach_file: { + resultSchema: AttachFileToolResultSchema, description: "Attach a file from the filesystem so later model steps receive it as a real attachment instead of a huge base64 JSON blob. " + "Accepts absolute or relative paths, including files outside the workspace. Accepts any file type. " + @@ -2133,6 +2562,7 @@ export const TOOL_DEFINITIONS = { .strict(), }, agent_skill_read: { + resultSchema: AgentSkillReadToolResultSchema, description: "Load an Agent Skill's SKILL.md (YAML frontmatter + markdown body) by name. " + "Skills are discovered from /.xum/skills//SKILL.md, /.agents/skills//SKILL.md, ~/.xum/skills//SKILL.md, and ~/.agents/skills//SKILL.md.", @@ -2143,6 +2573,7 @@ export const TOOL_DEFINITIONS = { .strict(), }, agent_skill_read_file: { + resultSchema: AgentSkillReadFileToolResultSchema, description: "Read a file within an Agent Skill directory. " + "filePath must be relative to the skill directory (no absolute paths, no ~, no .. traversal). " + @@ -2262,6 +2693,7 @@ export const TOOL_DEFINITIONS = { }, file_edit_replace_string: { + resultSchema: FileEditReplaceStringToolResultSchema, description: "⚠️ CRITICAL: Always check tool results - edits WILL fail if old_string is not found or unique. Do not proceed with dependent operations (commits, pushes, builds) until confirming success.\n\n" + "Apply one or more edits to a file by replacing exact text matches. All edits are applied sequentially. Each old_string must be unique in the file unless replace_count > 1 or replace_count is -1.", @@ -2308,6 +2740,7 @@ export const TOOL_DEFINITIONS = { ), }, file_edit_insert: { + resultSchema: FileEditInsertToolResultSchema, description: "Insert content into a file using substring guards. " + "Provide exactly one of insert_before or insert_after to anchor the operation when editing an existing file. " + @@ -2343,1102 +2776,651 @@ export const TOOL_DEFINITIONS = { ), }, advisor: { + ptcExcluded: "Top-level presence supplies proactive advisor guidance", description: ADVISOR_TOOL_DESCRIPTION, schema: AdvisorToolInputSchema, }, ask_user_question: { + ptcExcluded: "Requires UI interaction", description: "Ask 1–4 multiple-choice questions (with optional multi-select) and wait for the user's answers. " + "This tool is intended for plan mode. " + - "Use it ONLY for genuinely balanced decisions that hinge on user-specific context, preference, or information not present in the conversation or repo. " + - "Do NOT use it when you already have a reasonable recommendation: if one option is clearly best, proceed with it (stating the assumption) instead of asking — surfacing a question you can answer yourself defeats the purpose. " + - "When you do ask, keep the options genuinely open; do not steer toward a single 'recommended' choice. " + - "Do not output a list of open questions; ask them via this tool instead. " + - "Each question must include 2–4 options; an 'Other' choice is provided automatically.", - schema: AskUserQuestionToolArgsSchema, - }, - // `internal` tools are excluded from user-facing tool docs (hooks/tools.mdx - // env-var tables) because users can't write hooks for them — they run via - // bespoke streamText paths in their own services, not the standard tool - // execution pipeline. See gen_docs.ts. - propose_name: { - description: - "Propose a workspace name and title. You MUST call this tool exactly once with your chosen name and title. " + - "Do not emit a text response; call this tool immediately.", - schema: ProposeNameToolArgsSchema, - internal: true, - }, - propose_status: { - description: - "Propose a short sidebar status (emoji + 2-6 word verb-led phrase) summarizing what the agent is currently doing. " + - "You MUST call this tool exactly once. Do not emit a text response; call this tool immediately.", - schema: ProposeStatusToolArgsSchema, - internal: true, - }, - propose_plan: { - description: - "Signal that your plan is complete and ready for user approval. " + - "This tool reads the plan from the plan file you wrote. " + - "You must write your plan to the plan file before calling this tool. " + - "After calling this tool, do not paste the plan contents or mention the plan file path; the UI already shows the full plan.", - schema: z.object({}), - }, - task: { - description: buildTaskToolDescription(undefined), - schema: TaskToolArgsSchema, - }, - task_apply_git_patch: { - description: - "Apply a completed sub-agent task's git-format-patch artifact to the current workspace using `git am`. " + - "This is an explicit integration step: Xum will not auto-apply patches.", - schema: TaskApplyGitPatchToolArgsSchema, - }, - task_await: { - description: - "Wait for one or more tasks or workflow runs to produce output. " + - "\n\nWHEN TO USE: only call task_await when the current user request depends on a task's output, or when synthesis/integration of a previously-spawned task is the next logical step. " + - "Do not call task_await solely because active tasks exist; for unrelated user messages, respond directly and let tasks continue in the background. " + - "If a synthetic/system follow-up explicitly says active background tasks or workflow runs block your turn, treat that as a dependency and await the listed IDs. " + - "When a terminal wake-up says a sub-agent report or failure is already injected into context, integrate it directly — do NOT call task_await for it. When a wake-up asks you to retrieve a workspace turn's terminal output, call task_await with the listed IDs and timeout_secs: 0 (a one-shot retrieval, not a wait). " + - "\n\nIMPORTANT: Do not call task_await in the same parallel tool-call batch as task, bash, or workflow_run — " + - "the taskId/runId is not available until the spawning tool returns. " + - "Always wait for the task/bash/workflow_run tool result first, then call task_await in a subsequent step. " + - "When omitting task_ids to await active tasks/workflows, ensure at least one background task or workflow was already spawned in a prior step. Omitted task_ids discover top-level workflow runs only and exclude workflow-owned sub-agents/background bash tasks because those results are consumed through parent workflow runs. " + - "\n\nAgent tasks and workflow runs return reports when completed. " + - "Completed reports are persisted on disk and survive context compaction: calling task_await on an already-completed task/workflow run ID (timeout_secs: 0 for non-blocking) re-fetches the full report instead of re-running the work. " + - "Bash tasks return incremental output while running and a final reportMarkdown when they exit. " + - "For bash tasks, you may optionally pass filter/filter_exclude to include/exclude output lines by regex. " + - "WARNING: when using filter, non-matching lines are permanently discarded. " + - "Use this tool to WAIT; do not poll task_list in a loop to wait for task completion (that is misuse and wastes tool calls). " + - "\n\nBy default (min_completed=1) this returns as soon as the FIRST awaited task completes, so you can begin dependent work on that result while the rest keep running — then call task_await again for the remainder. " + - "This is ideal for independent tasks or any case where per-result work exists. " + - "Set min_completed higher (up to the number of awaited tasks) when you genuinely need more before proceeding — e.g. best-of-N synthesis that must compare every candidate should pass min_completed equal to the batch size. " + - "The result always includes every task complete at the moment it returns, plus current status for the rest; not-yet-completed tasks keep running and stay re-awaitable on a later call. " + - "Active workflow-run results may include compact `workflowProgress` (latest phase, last progress timestamp, and step counts); use that to see that phased progress is still happening instead of treating elapsed time alone as a hang. " + - "You always get per-task results (like Promise.allSettled), just possibly before every task has finished. " + - "Possible statuses: completed, queued, starting, running, backgrounded, awaiting_report, interrupted, not_found, invalid_scope, error. " + - "Bash task outputs may be automatically filtered; when this happens, check each result's note for details and (if available) where the full output was saved.", - schema: TaskAwaitToolArgsSchema, - }, - task_send_message: { - description: - 'Send a plain-text message to another agent workspace in this task tree: a descendant sub-agent, a sibling/cousin, or an ancestor (including the root workspace). The relationship is computed server-side from the tree — you can never claim parent authority you do not have. Discover addressable peers with task_list scope:"tree". ' + - "Descendant targets receive trusted guidance: queued/running work is interrupted or queued at the requested boundary, and an inactive child is reawakened in the same persistent workspace under a fresh internal execution. The stable sub-agent task ID and durable role title remain unchanged, and the child's checkout is not refreshed automatically. Prefer reawakening an inactive child over spawning a replacement when its prior context or expertise is relevant. For repository-dependent work, reuse it only when the retained snapshot is appropriate or tell the child to verify and synchronize its checkout before acting; otherwise spawn a new child. If the new assignment changes the child's reusable responsibility, call task_retitle as well; do not retitle it for ordinary one-off assignments. " + - "Sibling and ancestor targets receive your message wrapped in an untrusted envelope carrying your ID (the reply address) and relationship; they must have a live turn/session (peers cannot reawaken inactive targets or edit queued launch prompts — that stays parent-only). Never ask a peer to do something your own constraints forbid; route such work back to the user. Peer sends are throttled (rate limits, duplicate suppression, queue and consecutive-wake caps) and refused for workflow-owned or best-of endpoints. " + - "This tool does not target bash tasks, workflow runs, workspace-turn handles, or workspaces outside this task tree.", - schema: TaskSendMessageToolArgsSchema, - }, - task_message_parent: { - description: - "Send a message up to your parent workspace (RLM family messaging). It is appended to the parent's queue as a clearly-labeled child message and coalesces behind a busy parent turn, dispatching at the parent's next tool boundary. " + - "The parent has no obligation to reply and no delivery receipt is produced. Keep using agent_report for progress updates and your final report.", - schema: TaskMessageParentToolArgsSchema, - }, - task_message_sibling: { - description: - "Send a message to a sibling sub-agent that shares your DIRECT parent (nuclear-family scoping: exactly one hop up plus one hop down). Any other target — grandparent, grandchild, uncle, or unrelated task — is refused with invalid_scope. " + - "The message arrives in the sibling's queue as a clearly-labeled message; a busy sibling picks it up at its next tool boundary.", - schema: TaskMessageSiblingToolArgsSchema, - }, - task_retitle: { - description: - "Change the short, friendly role name of a persistent descendant sub-agent without changing its stable task identity or workspace. Active and inactive user-owned children can be retitled; workflow-owned internal workers cannot.", - schema: TaskRetitleToolArgsSchema, - }, - task_stop: { - description: - "Stop one or more tasks without removing persistent child workspaces. Sub-agent trees are stopped leaf-first and unfinished children become interrupted; workspace turns and workflow runs are interrupted; bash processes are terminated. Use this to cancel or abandon work, not to mark useful progress as completed—ask a child to finalize with task_send_message and await its report instead. Stopping an already-inactive task is idempotent.", - schema: TaskStopToolArgsSchema, - }, - task_remove: { - description: - "Irreversibly remove inactive child task workspaces owned by the current workspace. Use it to prune completed grouped candidates after their results and artifacts are consumed, consolidate substantially overlapping standalone roles, restore the bounded reusable bench, honor an explicit user request, or discard clearly obsolete context. Do not use it for a blanket end-of-turn cleanup: retain a small bench of distinct useful roles. Removed sub-agents cannot be restored or reawakened. Active targets are rejected; descendants must be removed first, so nested batches are processed deepest-first.", - schema: TaskRemoveToolArgsSchema, - }, - task_workspace_lifecycle: { - description: - 'Reversibly archive or unarchive full workspaces that the current workspace created via task(kind="workspace"). ' + - "Scoped by durable workspace-turn ownership records: it cannot act on arbitrary user workspaces or sub-agent children (non-wst_ task IDs are invalid_scope). " + - 'Use action="archive" when a peer workspace\'s work is complete; archived targets refuse task(kind="workspace", mode="existing") follow-ups until unarchived. ' + - "Active workspace turns involving the target (delegated to it, or owned by it for nested delegation) are refused unless interrupt_active is true (archive only; unarchive never interrupts). " + - "Live user activity in the target (a manual stream, terminal, or desktop session) also refuses archive and is never interrupted by this tool. " + - "Archive may return requires_confirmation with untracked paths when a snapshot would be lossy — the confirmation is checked before any interruption; re-call with acknowledged_untracked_paths to confirm. " + - 'Archive of a managed-worktree target is refused while the "Delete checkout" worktree archive behavior is configured, because that policy deletes the checkout without user confirmation; targets the worktree policy cannot delete (SSH/Coder, Docker, project-dir local, or shared isolation-none checkouts) stay archivable. ' + - "For irreversible removal of inactive sub-agent children, use task_remove instead.", - schema: TaskWorkspaceLifecycleToolInputSchema, - }, - task_list: { - description: - "List descendant tasks for the current workspace, including status + metadata. " + - "This includes sub-agent tasks, background bash tasks, and top-level workflow runs, but omits workflow-owned sub-agents/background bash tasks whose reports are consumed through parent workflow runs. " + - "Use this after compaction, interruptions, workflow_run errors/aborts, or an app restart to rediscover active tasks, inactive persistent sub-agents, and resumable workflow runs. Sub-agent rows from grouped runs include `bestOf` metadata so they can be distinguished from the standalone reusable bench. The default statuses find unfinished work; request `reported` explicitly for completed persistent sub-agents. " + - "When recovering an uncertain workflow_run, omit statuses first or include pending/running/backgrounded as well as interrupted/failed/completed; terminal-only filters can hide unfinished workflow runs. Pending runs may need workflow_resume because no runner may be active yet. " + - "Workflow rows may include compact `workflowProgress` so callers can see the latest phase before deciding whether to await, resume, or leave the run alone. " + - 'Pass scope:"tree" to list every agent workspace in this task tree instead — ancestors, siblings/cousins, descendants, and the root workspace row (status "workspace") — each tagged with its relationship to you. Tree rows are addressable via task_send_message except your own "self" row, best-of candidate rows (`bestOf` metadata, refused to keep candidates independent), and non-descendant rows in terminal states (peers cannot reactivate an inactive task — only its parent can); the root row is included by default and filtered like any other row when explicit statuses are passed. ' + - "The legacy includeArchived option only affects archived workspace-turn and bash records; sub-agents remain one inactive/active task identity. " + - "This is a discovery tool, NOT a waiting mechanism. If the current request actually depends on a task's output, call task_await with the specific task IDs you need; do not await all active tasks just because they appear here.", - schema: TaskListToolArgsSchema, - }, - workflow_run: { - // Prefer foreground workflows so callers do not waste a turn polling when no other work can proceed. - description: - "Start a durable workflow run from exactly one launch source: script_path for a JavaScript file/skill workflow, or script_source for compact one-off inline workflow source. Workflows coordinate delegated agent tasks and preserve run state for replay/resume. " + - "An active run of the same script in this workspace blocks a duplicate start unless allow_concurrent=true; reattach to the reported run with task_await or workflow_resume instead of relaunching it. " + - "Prefer script_path for reusable, reviewable, shared, slash/CLI-invokable, or skill-packaged workflows; use script_source for one-off conductors whose exact source should be snapshotted into the durable run. " + - "When a skill, instruction block, or plan describes a multi-phase, looping, or multi-agent process in prose and ships no packaged workflow script, prefer codifying that process as a one-off script_source workflow over executing every phase in-context: " + - "the conductor follows the documented phases more faithfully and gains durable checkpoints, resume, and fresh delegated context per phase. " + - "Use agent_skill_read / agent_skill_read_file to discover and inspect skill-packaged workflows; non-skill workflow files must be addressed by an explicit known path and can be inspected with normal file tools. " + - "Prefer the default foreground mode (`run_in_background` omitted or false) so completed workflows return their result without an extra task_await round-trip. " + - "If workflow_run returns status=running or status=backgrounded, await the returned runId with task_await before using or reporting the workflow output. " + - "After a previous workflow_run error, abort, timeout, or uncertain result, do not start a fresh run until you rediscover existing workflow runs: either omit task_list statuses first, or query pending/running/backgrounded/interrupted/failed/completed together. " + - "Use task_await for running/backgrounded runs, workflow_resume for pending/interrupted runs, workflow_resume({ mode: 'retry_from_checkpoint' }) only for eligible failed runs, and inspect/refetch completed results instead of rerunning. " + - "Use background mode only when you intend to start another workflow/task or do independent work while the workflow runs; a background run is non-blocking and Xum wakes this workspace with the terminal workflow result, so call task_await only when the current request depends on the output before you can answer.", - schema: WorkflowRunToolArgsSchema, - }, - workflow_resume: { - description: - "Resume an existing durable workflow run by run ID (wfr_...). Use this for runs that were interrupted (by the user, task_stop, or an app crash/restart) — " + - "resume replays the durable event log and continues from the last checkpoint without re-executing completed steps. " + - "Discover resumable runs with task_list (statuses pending/interrupted/failed). Pending runs left by post-create aborts and interrupted runs can be resumed in default mode; running/backgrounded workflows do not need resume, await them with task_await. " + - "For failed runs, pass mode='retry_from_checkpoint' explicitly; it re-executes work after the last checkpoint, so only use it when that is acceptable, and start a fresh workflow_run when it is rejected as unsafe. " + - "Calling this on a completed run returns its existing result without re-running anything. " + - "Prefer foreground mode (run_in_background omitted or false) to get the final result directly; " + - "if the returned status is running or backgrounded, await the runId with task_await before using the result.", - schema: WorkflowResumeToolArgsSchema, - }, - agent_report: { - description: - "Send an incremental update from a sub-agent to its parent workspace and wake the parent. " + - "Call this whenever the parent should see important progress or a finding before the task is complete; it may be called multiple times. " + - "Do not use it for the final result—the final assistant message completes the sub-agent task.", - schema: AgentReportToolArgsSchema, - }, - timeline_event: { - description: - "Record one notable step on the durable workspace timeline, which is a birds-eye record of the work rather than a tool log. " + - "Call it when: a notable implementation step landed; work was committed, pushed, or opened as a PR; " + - "external input was picked up, such as a review comment, CI failure, or issue; " + - "the approach changed, including why; a blocker was hit or resolved; work was handed off. " + - "Describe what happened in one plain sentence. " + - "Prompts, goals, heartbeats, sub-agents, and workflows are already recorded automatically, so do not restate them or narrate routine tool use.", - schema: z - .object({ - description: z.string().min(1).max(300).describe("One sentence describing what happened."), - category: z - .enum(["picked_up", "milestone", "decision", "blocker", "handoff"]) - .nullish() - .describe("Optional event category."), - }) - .strict(), - }, - set_goal: { - description: - "Create or replace a durable goal for this current parent workspace when the user explicitly asks for multi-turn, verifiable work. " + - "Do not use this for one-shot questions. Objectives must be concrete, measurable, and verifiable. " + - "Omitted or null budget/turn fields use the effective workspace goal defaults; model-created goals must resolve to at least one budget or turn bound. " + - "Do not replace an active, paused, or budget-limited goal unless the user explicitly asked to replace it; when replacing, first call get_goal and pass replaceExistingGoal=true with the current expectedGoalId. " + - "After setting a goal during your own turn, let subsequent automatic continuation turns do the substantial goal work, then call complete_goal only after verification.", - schema: z - .object({ - objective: z - .string() - .trim() - .min(1) - .describe("Concrete, measurable objective to pursue over automatic goal continuations."), - budgetCents: z - .number() - .int() - .positive() - .nullish() - .describe( - "Optional positive budget in cents. Omit/null to apply the effective workspace goal default." - ), - turnCap: z - .number() - .int() - .positive() - .nullish() - .describe( - "Optional positive maximum automatic continuation turns. Omit/null to apply the effective workspace goal default." - ), - replaceExistingGoal: z - .boolean() - .nullish() - .describe("Set true only when the user explicitly asked to replace the current goal."), - expectedGoalId: z - .string() - .uuid() - .nullish() - .describe( - "Optimistic-concurrency token required when replacing an active, paused, or budget-limited goal. Use the goalId from get_goal." - ), - }) - .strict(), + "Use it ONLY for genuinely balanced decisions that hinge on user-specific context, preference, or information not present in the conversation or repo. " + + "Do NOT use it when you already have a reasonable recommendation: if one option is clearly best, proceed with it (stating the assumption) instead of asking — surfacing a question you can answer yourself defeats the purpose. " + + "When you do ask, keep the options genuinely open; do not steer toward a single 'recommended' choice. " + + "Do not output a list of open questions; ask them via this tool instead. " + + "Each question must include 2–4 options; an 'Other' choice is provided automatically.", + schema: AskUserQuestionToolArgsSchema, }, - get_goal: { + // `internal` tools are excluded from user-facing tool docs (hooks/tools.mdx + // env-var tables) because users can't write hooks for them — they run via + // bespoke streamText paths in their own services, not the standard tool + // execution pipeline. See gen_docs.ts. + propose_name: { description: - "Read the current workspace goal. Returns null when no goal is available in this turn.", - schema: z.object({}).strict(), + "Propose a workspace name and title. You MUST call this tool exactly once with your chosen name and title. " + + "Do not emit a text response; call this tool immediately.", + schema: ProposeNameToolArgsSchema, + internal: true, }, - complete_goal: { + propose_status: { description: - "Mark the current workspace goal complete with a concise 1-2 sentence summary of why the goal is done. " + - "This tool only completes goals; it cannot pause, resume, replace, or change goal budgets. " + - "Pass the `goalId` returned by `get_goal` so the completion is rejected with a typed conflict " + - "error if the user clears or replaces the goal mid-stream rather than throwing a confusing " + - "validation error.", - schema: z - .object({ - summary: z - .string() - .trim() - .min(1) - .describe("Required 1-2 sentence justification for completing the current goal."), - goalId: z - .string() - .nullish() - .describe( - "Optional optimistic-concurrency token. Pass the `goalId` returned by `get_goal` to " + - "ensure the completion is rejected with a typed conflict error if the user clears " + - "or replaces the goal mid-stream." - ), - }) - .strict(), + "Propose a short sidebar status (emoji + 2-6 word verb-led phrase) summarizing what the agent is currently doing. " + + "You MUST call this tool exactly once. Do not emit a text response; call this tool immediately.", + schema: ProposeStatusToolArgsSchema, + internal: true, }, - - heartbeat: { + propose_plan: { + ptcExcluded: "Mode-specific, call directly", description: - "Read or change this workspace's scheduled heartbeat. " + - "The tool only affects the current workspace; it does not accept a workspaceId. " + - "Use action='set' to enable or configure the heartbeat interval, custom message, context mode, trigger, when-busy behavior, or enabled flag. " + - "trigger chooses the countdown anchor: 'idle' (default) fires only after the workspace has been quiet for a full interval; 'interval' fires on a fixed wall-clock cadence. " + - "whenBusy chooses what happens when a heartbeat fires while the workspace is busy: 'skip' misses the slot, 'tool-end'/'turn-end' queue the heartbeat for the matching boundary. " + - "Unset whenBusy defaults to 'skip' for trigger 'idle' and 'turn-end' for trigger 'interval'. " + - "Use action='unset' to remove this workspace's heartbeat settings entirely. " + - "Use action='get' before changing settings when you need to preserve existing values.", - schema: HeartbeatToolArgsSchema, + "Signal that your plan is complete and ready for user approval. " + + "This tool reads the plan from the plan file you wrote. " + + "You must write your plan to the plan file before calling this tool. " + + "After calling this tool, do not paste the plan contents or mention the plan file path; the UI already shows the full plan.", + schema: z.object({}), }, - todo_write: { + task: { + resultSchema: TaskToolResultSchema, + description: buildTaskToolDescription(undefined), + schema: TaskToolArgsSchema, + }, + task_apply_git_patch: { + resultSchema: TaskApplyGitPatchToolResultSchema, description: - "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + - "The TODO list is displayed to the user at all times. " + - "Replace the entire list on each call - the AI tracks which tasks are completed.\n" + - "\n" + - "Mark tasks as in_progress when actively being worked on (multiple allowed for parallel work). " + - "Order tasks as: completed first, then in_progress, then pending last. " + - "Use appropriate tense in content: past tense for completed (e.g., 'Added tests'), " + - "present progressive for in_progress (e.g., 'Adding tests'), " + - "and imperative/infinitive for pending (e.g., 'Add tests').\n" + - "\n" + - "If you hit the 7-item limit, summarize older completed items into one line " + - "(e.g., 'Completed initial setup (3 tasks)').\n" + - "\n" + - "Update the list as work progresses. If work fails or the approach changes, update " + - "the list to reflect reality - only mark tasks complete when they actually succeed.", - schema: z.object({ - todos: z.array( - z.object({ - content: z - .string() - .describe( - "Task description with tense matching status: past for completed, present progressive for in_progress, imperative for pending" - ), - status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), - }) - ), - }), + "Apply a completed sub-agent task's git-format-patch artifact to the current workspace using `git am`. " + + "This is an explicit integration step: Xum will not auto-apply patches.", + schema: TaskApplyGitPatchToolArgsSchema, }, - todo_read: { - description: "Read the current todo list", - schema: z.object({}), + task_await: { + resultSchema: TaskAwaitToolResultSchema, + description: + "Wait for one or more tasks or workflow runs to produce output. " + + "\n\nWHEN TO USE: only call task_await when the current user request depends on a task's output, or when synthesis/integration of a previously-spawned task is the next logical step. " + + "Do not call task_await solely because active tasks exist; for unrelated user messages, respond directly and let tasks continue in the background. " + + "If a synthetic/system follow-up explicitly says active background tasks or workflow runs block your turn, treat that as a dependency and await the listed IDs. " + + "When a terminal wake-up says a sub-agent report or failure is already injected into context, integrate it directly — do NOT call task_await for it. When a wake-up asks you to retrieve a workspace turn's terminal output, call task_await with the listed IDs and timeout_secs: 0 (a one-shot retrieval, not a wait). " + + "\n\nIMPORTANT: Do not call task_await in the same parallel tool-call batch as task, bash, or workflow_run — " + + "the taskId/runId is not available until the spawning tool returns. " + + "Always wait for the task/bash/workflow_run tool result first, then call task_await in a subsequent step. " + + "When omitting task_ids to await active tasks/workflows, ensure at least one background task or workflow was already spawned in a prior step. Omitted task_ids discover top-level workflow runs only and exclude workflow-owned sub-agents/background bash tasks because those results are consumed through parent workflow runs. " + + "\n\nAgent tasks and workflow runs return reports when completed. " + + "Completed reports are persisted on disk and survive context compaction: calling task_await on an already-completed task/workflow run ID (timeout_secs: 0 for non-blocking) re-fetches the full report instead of re-running the work. " + + "Bash tasks return incremental output while running and a final reportMarkdown when they exit. " + + "For bash tasks, you may optionally pass filter/filter_exclude to include/exclude output lines by regex. " + + "WARNING: when using filter, non-matching lines are permanently discarded. " + + "Use this tool to WAIT; do not poll task_list in a loop to wait for task completion (that is misuse and wastes tool calls). " + + "\n\nBy default (min_completed=1) this returns as soon as the FIRST awaited task completes, so you can begin dependent work on that result while the rest keep running — then call task_await again for the remainder. " + + "This is ideal for independent tasks or any case where per-result work exists. " + + "Set min_completed higher (up to the number of awaited tasks) when you genuinely need more before proceeding — e.g. best-of-N synthesis that must compare every candidate should pass min_completed equal to the batch size. " + + "The result always includes every task complete at the moment it returns, plus current status for the rest; not-yet-completed tasks keep running and stay re-awaitable on a later call. " + + "Active workflow-run results may include compact `workflowProgress` (latest phase, last progress timestamp, and step counts); use that to see that phased progress is still happening instead of treating elapsed time alone as a hang. " + + "You always get per-task results (like Promise.allSettled), just possibly before every task has finished. " + + "Possible statuses: completed, queued, starting, running, backgrounded, awaiting_report, interrupted, not_found, invalid_scope, error. " + + "Bash task outputs may be automatically filtered; when this happens, check each result's note for details and (if available) where the full output was saved.", + schema: TaskAwaitToolArgsSchema, }, - review_pane_update: { + task_send_message: { + resultSchema: TaskSendMessageToolResultSchema, description: - "Flag specific code regions in the Review pane for the user to review next. " + - "Use this to draw the user's attention to critical changes you want reviewed first. " + - "Each hunk references a project-relative file path with an optional inclusive line " + - 'range using familiar syntax: "src/foo.ts" (whole file), "src/foo.ts:42" (single line), ' + - 'or "src/foo.ts:42-58" (range, new-file line numbers). Project-relative paths are ' + - "preferred; use './' or '../' for paths that must resolve from the current tool cwd. " + - "Attach a short comment to each " + - "hunk explaining what to look at and why.\n\n" + - "operation:\n" + - " - 'replace' (default): overwrite the current assisted set\n" + - " - 'add': append to the existing set, deduplicating exact path:range matches\n\n" + - "Flagged hunks appear pinned at the top of the Review pane; the user can toggle " + - "'Assisted' to hide everything else. Pass an empty hunks array with operation='replace' " + - "to clear the set when review is no longer needed.", - schema: z - .object({ - operation: z - .enum(["add", "replace"]) - .describe("'replace' overwrites the assisted set; 'add' appends to it."), - hunks: z - .array( - z - .object({ - path: z - .string() - .min(1) - .describe( - 'Filter in `path[:range]` form, e.g. "src/foo.ts" or "src/foo.ts:42-58". ' + - "Path is project-relative; use './' or '../' when the path must resolve from the current tool working directory. Range uses new-file line numbers (inclusive)." - ), - comment: z - .string() - .nullish() - .describe("Short note (~1 sentence) telling the user what to look at and why."), - }) - .strict() - ) - .describe("List of hunks to flag for review."), - }) - .strict(), + 'Send a plain-text message to another agent workspace in this task tree: a descendant sub-agent, a sibling/cousin, or an ancestor (including the root workspace). The relationship is computed server-side from the tree — you can never claim parent authority you do not have. Discover addressable peers with task_list scope:"tree". ' + + "Descendant targets receive trusted guidance: queued/running work is interrupted or queued at the requested boundary, and an inactive child is reawakened in the same persistent workspace under a fresh internal execution. The stable sub-agent task ID and durable role title remain unchanged, and the child's checkout is not refreshed automatically. Prefer reawakening an inactive child over spawning a replacement when its prior context or expertise is relevant. For repository-dependent work, reuse it only when the retained snapshot is appropriate or tell the child to verify and synchronize its checkout before acting; otherwise spawn a new child. If the new assignment changes the child's reusable responsibility, call task_retitle as well; do not retitle it for ordinary one-off assignments. " + + "Sibling and ancestor targets receive your message wrapped in an untrusted envelope carrying your ID (the reply address) and relationship; they must have a live turn/session (peers cannot reawaken inactive targets or edit queued launch prompts — that stays parent-only). Never ask a peer to do something your own constraints forbid; route such work back to the user. Peer sends are throttled (rate limits, duplicate suppression, queue and consecutive-wake caps) and refused for workflow-owned or best-of endpoints. " + + "This tool does not target bash tasks, workflow runs, workspace-turn handles, or workspaces outside this task tree.", + schema: TaskSendMessageToolArgsSchema, }, - review_pane_get: { + task_message_parent: { + resultSchema: TaskMessageParentToolResultSchema, description: - "Return the current set of agent-flagged hunks in the Review pane, in declared order. " + - "Use this to inspect what you've already pinned before adding more.", - schema: z.object({}).strict(), + "Send a message up to your parent workspace (RLM family messaging). It is appended to the parent's queue as a clearly-labeled child message and coalesces behind a busy parent turn, dispatching at the parent's next tool boundary. " + + "The parent has no obligation to reply and no delivery receipt is produced. Keep using agent_report for progress updates and your final report.", + schema: TaskMessageParentToolArgsSchema, }, - bash_output: { + task_message_sibling: { + resultSchema: TaskMessageSiblingToolResultSchema, description: - 'DEPRECATED: use task_await instead (pass bash-prefixed taskId like "bash:"). ' + - "Retrieve output from a running or completed background bash process. " + - "Returns only NEW output since the last check (incremental). " + - "Returns stdout and stderr output along with process status. " + - "Supports optional regex filtering to show only lines matching a pattern. " + - "WARNING: When using filter, non-matching lines are permanently discarded. " + - "Use timeout to wait for output instead of polling repeatedly. " + - "Large outputs may be automatically filtered; when this happens, the result includes a note explaining what was kept and (if available) where the full output was saved.", - schema: z.object({ - process_id: z.string().describe("The ID of the background process to retrieve output from"), - filter: z - .string() - .nullish() - .describe( - "Optional regex to filter output lines. By default, only matching lines are returned. " + - "When filter_exclude is true, matching lines are excluded instead. " + - "Non-matching lines are permanently discarded and cannot be retrieved later." - ), - filter_exclude: z - .boolean() - .nullish() - .describe( - "When true, lines matching 'filter' are excluded instead of kept. " + - "Key behavior: excluded lines do NOT cause early return from timeout - " + - "waiting continues until non-excluded output arrives or process exits. " + - "Use to avoid busy polling on progress spam (e.g., filter='⏳|waiting|\\.\\.\\.' with filter_exclude=true " + - "lets you set a long timeout and only wake on meaningful output). " + - "Requires 'filter' to be set." - ), - timeout_secs: z - .number() - .min(0) - .describe( - "Seconds to wait for new output. " + - "If no output is immediately available and process is still running, " + - "blocks up to this duration. Returns early when output arrives or process exits. " + - "Only use long timeouts (>15s) when no other useful work can be done in parallel." - ), - }), + "Send a message to a sibling sub-agent that shares your DIRECT parent (nuclear-family scoping: exactly one hop up plus one hop down). Any other target — grandparent, grandchild, uncle, or unrelated task — is refused with invalid_scope. " + + "The message arrives in the sibling's queue as a clearly-labeled message; a busy sibling picks it up at its next tool boundary.", + schema: TaskMessageSiblingToolArgsSchema, }, - bash_background_list: { + task_retitle: { + resultSchema: TaskRetitleToolResultSchema, description: - "DEPRECATED: use task_list instead. " + - "List all background processes started with bash(run_in_background=true). " + - "Returns process_id, status, script for each process. " + - "Use to find process_id for termination or check output with bash_output.", - schema: z.object({}), + "Change the short, friendly role name of a persistent descendant sub-agent without changing its stable task identity or workspace. Active and inactive user-owned children can be retitled; workflow-owned internal workers cannot.", + schema: TaskRetitleToolArgsSchema, + }, + task_stop: { + resultSchema: TaskStopToolResultSchema, + description: + "Stop one or more tasks without removing persistent child workspaces. Sub-agent trees are stopped leaf-first and unfinished children become interrupted; workspace turns and workflow runs are interrupted; bash processes are terminated. Use this to cancel or abandon work, not to mark useful progress as completed—ask a child to finalize with task_send_message and await its report instead. Stopping an already-inactive task is idempotent.", + schema: TaskStopToolArgsSchema, + }, + task_remove: { + resultSchema: TaskRemoveToolResultSchema, + description: + "Irreversibly remove inactive child task workspaces owned by the current workspace. Use it to prune completed grouped candidates after their results and artifacts are consumed, consolidate substantially overlapping standalone roles, restore the bounded reusable bench, honor an explicit user request, or discard clearly obsolete context. Do not use it for a blanket end-of-turn cleanup: retain a small bench of distinct useful roles. Removed sub-agents cannot be restored or reawakened. Active targets are rejected; descendants must be removed first, so nested batches are processed deepest-first.", + schema: TaskRemoveToolArgsSchema, + }, + task_workspace_lifecycle: { + resultSchema: TaskWorkspaceLifecycleToolResultSchema, + description: + 'Reversibly archive or unarchive full workspaces that the current workspace created via task(kind="workspace"). ' + + "Scoped by durable workspace-turn ownership records: it cannot act on arbitrary user workspaces or sub-agent children (non-wst_ task IDs are invalid_scope). " + + 'Use action="archive" when a peer workspace\'s work is complete; archived targets refuse task(kind="workspace", mode="existing") follow-ups until unarchived. ' + + "Active workspace turns involving the target (delegated to it, or owned by it for nested delegation) are refused unless interrupt_active is true (archive only; unarchive never interrupts). " + + "Live user activity in the target (a manual stream, terminal, or desktop session) also refuses archive and is never interrupted by this tool. " + + "Archive may return requires_confirmation with untracked paths when a snapshot would be lossy — the confirmation is checked before any interruption; re-call with acknowledged_untracked_paths to confirm. " + + 'Archive of a managed-worktree target is refused while the "Delete checkout" worktree archive behavior is configured, because that policy deletes the checkout without user confirmation; targets the worktree policy cannot delete (SSH/Coder, Docker, project-dir local, or shared isolation-none checkouts) stay archivable. ' + + "For irreversible removal of inactive sub-agent children, use task_remove instead.", + schema: TaskWorkspaceLifecycleToolInputSchema, }, - bash_background_terminate: { + task_list: { + resultSchema: TaskListToolResultSchema, description: - "DEPRECATED: use task_stop instead. " + - "Terminate a background process started with bash(run_in_background=true). " + - "Use process_id from the original bash response or from bash_background_list. " + - "Sends SIGTERM, waits briefly, then SIGKILL if needed. " + - "Output remains available via bash_output after termination.", - schema: z.object({ - process_id: z.string().describe("Background process ID to terminate"), - }), - }, - analytics_query: { - description: `Execute a DuckDB SQL query against Xum analytics tables and optionally provide visualization hints. -Use read-only SELECT queries over analytics data. - -DuckDB SQL guidelines: -- Use SELECT queries only; do not write, alter, or drop tables. -- Prefer explicit column lists and aliases so result sets are easy to understand. -- Use ORDER BY and LIMIT for exploratory queries over large datasets. -- Use DuckDB date/time helpers (for example date_trunc, CAST(... AS DATE), and interval arithmetic) for time series. - -Available tables: - -CREATE TABLE IF NOT EXISTS events ( - workspace_id VARCHAR NOT NULL, - project_path VARCHAR, - project_name VARCHAR, - workspace_name VARCHAR, - parent_workspace_id VARCHAR, - agent_id VARCHAR, - timestamp BIGINT, - date DATE, - model VARCHAR, - thinking_level VARCHAR, - input_tokens INTEGER DEFAULT 0, - output_tokens INTEGER DEFAULT 0, - reasoning_tokens INTEGER DEFAULT 0, - cached_tokens INTEGER DEFAULT 0, - cache_create_tokens INTEGER DEFAULT 0, - input_cost_usd DOUBLE DEFAULT 0, - output_cost_usd DOUBLE DEFAULT 0, - reasoning_cost_usd DOUBLE DEFAULT 0, - cached_cost_usd DOUBLE DEFAULT 0, - total_cost_usd DOUBLE DEFAULT 0, - duration_ms DOUBLE, - ttft_ms DOUBLE, - streaming_ms DOUBLE, - tool_execution_ms DOUBLE, - output_tps DOUBLE, - response_index INTEGER, - is_sub_agent BOOLEAN DEFAULT false -) - -CREATE TABLE IF NOT EXISTS delegation_rollups ( - parent_workspace_id VARCHAR NOT NULL, - child_workspace_id VARCHAR NOT NULL, - project_path VARCHAR, - project_name VARCHAR, - agent_type VARCHAR, - model VARCHAR, - total_tokens INTEGER DEFAULT 0, - context_tokens INTEGER DEFAULT 0, - input_tokens INTEGER DEFAULT 0, - output_tokens INTEGER DEFAULT 0, - reasoning_tokens INTEGER DEFAULT 0, - cached_tokens INTEGER DEFAULT 0, - cache_create_tokens INTEGER DEFAULT 0, - report_token_estimate INTEGER DEFAULT 0, - total_cost_usd DOUBLE DEFAULT 0, - rolled_up_at_ms BIGINT, - date DATE, - PRIMARY KEY (parent_workspace_id, child_workspace_id) -)`, - schema: z.object({ - sql: z.string().min(1).describe("DuckDB SQL query to execute"), - visualization: z - .enum(["table", "bar", "line", "pie", "area", "stacked_bar"]) - .nullish() - .describe("Optional visualization type for rendering the query result"), - title: z.string().nullish().describe("Optional chart title"), - x_axis: z.string().nullish().describe("Optional column name for the visualization X axis"), - y_axis: z - .array(z.string()) - .nullish() - .describe("Optional column name(s) for the visualization Y axis"), - }), + "List descendant tasks for the current workspace, including status + metadata. " + + "This includes sub-agent tasks, background bash tasks, and top-level workflow runs, but omits workflow-owned sub-agents/background bash tasks whose reports are consumed through parent workflow runs. " + + "Use this after compaction, interruptions, workflow_run errors/aborts, or an app restart to rediscover active tasks, inactive persistent sub-agents, and resumable workflow runs. Sub-agent rows from grouped runs include `bestOf` metadata so they can be distinguished from the standalone reusable bench. The default statuses find unfinished work; request `reported` explicitly for completed persistent sub-agents. " + + "When recovering an uncertain workflow_run, omit statuses first or include pending/running/backgrounded as well as interrupted/failed/completed; terminal-only filters can hide unfinished workflow runs. Pending runs may need workflow_resume because no runner may be active yet. " + + "Workflow rows may include compact `workflowProgress` so callers can see the latest phase before deciding whether to await, resume, or leave the run alone. " + + 'Pass scope:"tree" to list every agent workspace in this task tree instead — ancestors, siblings/cousins, descendants, and the root workspace row (status "workspace") — each tagged with its relationship to you. Tree rows are addressable via task_send_message except your own "self" row, best-of candidate rows (`bestOf` metadata, refused to keep candidates independent), and non-descendant rows in terminal states (peers cannot reactivate an inactive task — only its parent can); the root row is included by default and filtered like any other row when explicit statuses are passed. ' + + "The legacy includeArchived option only affects archived workspace-turn and bash records; sub-agents remain one inactive/active task identity. " + + "This is a discovery tool, NOT a waiting mechanism. If the current request actually depends on a task's output, call task_await with the specific task IDs you need; do not await all active tasks just because they appear here.", + schema: TaskListToolArgsSchema, }, - web_fetch: { + workflow_run: { + // Prefer foreground workflows so callers do not waste a turn polling when no other work can proceed. description: - `Fetch a web page and extract its main content as clean markdown. ` + - `Uses the workspace's network context (requests originate from the workspace, not Xum host). ` + - `Requires curl to be installed in the workspace. ` + - `Output is truncated to ${Math.floor(WEB_FETCH_MAX_OUTPUT_BYTES / 1024)}KB.`, - schema: z.object({ - url: z.string().url().describe("The URL to fetch (http or https)"), - }), + "Start a durable workflow run from exactly one launch source: script_path for a JavaScript file/skill workflow, or script_source for compact one-off inline workflow source. Workflows coordinate delegated agent tasks and preserve run state for replay/resume. " + + "An active run of the same script in this workspace blocks a duplicate start unless allow_concurrent=true; reattach to the reported run with task_await or workflow_resume instead of relaunching it. " + + "Prefer script_path for reusable, reviewable, shared, slash/CLI-invokable, or skill-packaged workflows; use script_source for one-off conductors whose exact source should be snapshotted into the durable run. " + + "When a skill, instruction block, or plan describes a multi-phase, looping, or multi-agent process in prose and ships no packaged workflow script, prefer codifying that process as a one-off script_source workflow over executing every phase in-context: " + + "the conductor follows the documented phases more faithfully and gains durable checkpoints, resume, and fresh delegated context per phase. " + + "Use agent_skill_read / agent_skill_read_file to discover and inspect skill-packaged workflows; non-skill workflow files must be addressed by an explicit known path and can be inspected with normal file tools. " + + "Prefer the default foreground mode (`run_in_background` omitted or false) so completed workflows return their result without an extra task_await round-trip. " + + "If workflow_run returns status=running or status=backgrounded, await the returned runId with task_await before using or reporting the workflow output. " + + "After a previous workflow_run error, abort, timeout, or uncertain result, do not start a fresh run until you rediscover existing workflow runs: either omit task_list statuses first, or query pending/running/backgrounded/interrupted/failed/completed together. " + + "Use task_await for running/backgrounded runs, workflow_resume for pending/interrupted runs, workflow_resume({ mode: 'retry_from_checkpoint' }) only for eligible failed runs, and inspect/refetch completed results instead of rerunning. " + + "Use background mode only when you intend to start another workflow/task or do independent work while the workflow runs; a background run is non-blocking and Xum wakes this workspace with the terminal workflow result, so call task_await only when the current request depends on the output before you can answer.", + schema: WorkflowRunToolArgsSchema, }, - code_execution: { + workflow_resume: { description: - "Execute JavaScript code in a sandboxed environment with access to Xum tools. " + - "Available for multi-tool workflows when PTC experiment is enabled.", - schema: z.object({ - code: z.string().min(1).describe("JavaScript code to execute in the PTC sandbox"), - }), + "Resume an existing durable workflow run by run ID (wfr_...). Use this for runs that were interrupted (by the user, task_stop, or an app crash/restart) — " + + "resume replays the durable event log and continues from the last checkpoint without re-executing completed steps. " + + "Discover resumable runs with task_list (statuses pending/interrupted/failed). Pending runs left by post-create aborts and interrupted runs can be resumed in default mode; running/backgrounded workflows do not need resume, await them with task_await. " + + "For failed runs, pass mode='retry_from_checkpoint' explicitly; it re-executes work after the last checkpoint, so only use it when that is acceptable, and start a fresh workflow_run when it is rejected as unsafe. " + + "Calling this on a completed run returns its existing result without re-running anything. " + + "Prefer foreground mode (run_in_background omitted or false) to get the final result directly; " + + "if the returned status is running or backgrounded, await the runId with task_await before using the result.", + schema: WorkflowResumeToolArgsSchema, }, - refinement_rollback: { + agent_report: { + ptcExcluded: "Must be top-level for taskService to read args from history", description: - "Roll back a journaled harness self-modification (a memory or skill edit) by its refinement row id, " + - "restoring the exact prior file contents recorded in the session's refinement journal. " + - "The rollback is journaled as a refinement row of its own, so it can be rolled back again. " + - "Refuses rows that were already rolled back and rows whose files changed since (divergence). " + - "Available only in RLM mode.", - schema: z - .object({ - id: z.string().min(1).describe("Refinement row id (envelope id) to roll back"), - reason: z - .string() - .min(1) - .describe("Why this refinement is being rolled back (recorded in the journal)"), - }) - .strict(), + "Send an incremental update from a sub-agent to its parent workspace and wake the parent. " + + "Call this whenever the parent should see important progress or a finding before the task is complete; it may be called multiple times. " + + "Do not use it for the final result—the final assistant message completes the sub-agent task.", + schema: AgentReportToolArgsSchema, }, - // #region NOTIFY_DOCS - notify: { + timeline_event: { description: - "Send a system notification to the user. Use this to alert the user about important events that require their attention, such as long-running task completion, errors requiring intervention, or questions. " + - "Notifications appear as OS-native notifications (macOS Notification Center, Windows Toast, Linux). " + - "Infer whether to send notifications from user instructions. If no instructions provided, reserve notifications for major wins or blocking issues. Do not use for routine progress updates — keep the todo list current instead.", + "Record one notable step on the durable workspace timeline, which is a birds-eye record of the work rather than a tool log. " + + "Call it when: a notable implementation step landed; work was committed, pushed, or opened as a PR; " + + "external input was picked up, such as a review comment, CI failure, or issue; " + + "the approach changed, including why; a blocker was hit or resolved; work was handed off. " + + "Describe what happened in one plain sentence. " + + "Prompts, goals, heartbeats, sub-agents, and workflows are already recorded automatically, so do not restate them or narrate routine tool use.", schema: z .object({ - title: z - .string() - .min(1) - .max(64) - .describe("Short notification title (max 64 chars). Should be concise and actionable."), - message: z - .string() - .max(200) + description: z.string().min(1).max(300).describe("One sentence describing what happened."), + category: z + .enum(["picked_up", "milestone", "decision", "blocker", "handoff"]) .nullish() - .describe( - "Optional notification body with more details (max 200 chars). " + - "Keep it brief - users may only see a preview." - ), + .describe("Optional event category."), }) .strict(), }, - // #endregion NOTIFY_DOCS - tool_catalog_search: { + set_goal: { description: - "Search the catalog of deferred tools. Some tools (provided by MCP servers) are deferred: " + - "they exist but are not currently visible in your tool list. " + - "Call tool_catalog_search with task/capability keywords to discover them; matched tools become available on the next step. " + - "Returns matched tool names and descriptions plus the total number of deferred tools (there may be more undiscovered — refine the query to find them).", + "Create or replace a durable goal for this current parent workspace when the user explicitly asks for multi-turn, verifiable work. " + + "Do not use this for one-shot questions. Objectives must be concrete, measurable, and verifiable. " + + "Omitted or null budget/turn fields use the effective workspace goal defaults; model-created goals must resolve to at least one budget or turn bound. " + + "Do not replace an active, paused, or budget-limited goal unless the user explicitly asked to replace it; when replacing, first call get_goal and pass replaceExistingGoal=true with the current expectedGoalId. " + + "After setting a goal during your own turn, let subsequent automatic continuation turns do the substantial goal work, then call complete_goal only after verification.", schema: z .object({ - query: z + objective: z .string() + .trim() .min(1) + .describe("Concrete, measurable objective to pursue over automatic goal continuations."), + budgetCents: z + .number() + .int() + .positive() + .nullish() .describe( - "Task or capability keywords to search for (matched against tool names, descriptions, and parameter names)" + "Optional positive budget in cents. Omit/null to apply the effective workspace goal default." ), - limit: z + turnCap: z .number() .int() - .min(1) - .max(25) + .positive() .nullish() - .describe("Maximum number of matches to return (default 10, max 25)"), - }) - .strict(), - }, - mcp_prompt_get: { - description: - "Fetch a prompt template from a connected MCP server, expanded with the given arguments. " + - "MCP prompts are reusable instructions or workflows the user has made available through MCP servers. " + - "The result contains the prompt text; follow it as task guidance in the current conversation. " + - "Available prompts are listed in this description when connected servers advertise them.", - schema: z - .object({ - name: z + .describe( + "Optional positive maximum automatic continuation turns. Omit/null to apply the effective workspace goal default." + ), + replaceExistingGoal: z + .boolean() + .nullish() + .describe("Set true only when the user explicitly asked to replace the current goal."), + expectedGoalId: z .string() - .min(1) - .describe('Prompt name from the available list, e.g. "mcp__server__prompt"'), - arguments: z - .record(z.string(), z.string()) + .uuid() .nullish() .describe( - "Prompt argument values by argument name. Arguments marked with ? are optional; all others are required." + "Optimistic-concurrency token required when replacing an active, paused, or budget-limited goal. Use the goalId from get_goal." ), - list_offset: z - .number() - .int() - .min(0) + }) + .strict(), + }, + get_goal: { + description: + "Read the current workspace goal. Returns null when no goal is available in this turn.", + schema: z.object({}).strict(), + }, + complete_goal: { + description: + "Mark the current workspace goal complete with a concise 1-2 sentence summary of why the goal is done. " + + "This tool only completes goals; it cannot pause, resume, replace, or change goal budgets. " + + "Pass the `goalId` returned by `get_goal` so the completion is rejected with a typed conflict " + + "error if the user clears or replaces the goal mid-stream rather than throwing a confusing " + + "validation error.", + schema: z + .object({ + summary: z + .string() + .trim() + .min(1) + .describe("Required 1-2 sentence justification for completing the current goal."), + goalId: z + .string() .nullish() .describe( - "When an unknown-name error truncates the prompt listing, repeat the call with the suggested list_offset to page through the remaining prompt names." + "Optional optimistic-concurrency token. Pass the `goalId` returned by `get_goal` to " + + "ensure the completion is rejected with a typed conflict error if the user clears " + + "or replaces the goal mid-stream." ), }) .strict(), }, -} as const; - -// ----------------------------------------------------------------------------- -// Result Schemas for Bridgeable Tools (PTC Type Generation) -// ----------------------------------------------------------------------------- -// These Zod schemas define the result types for tools exposed in the PTC sandbox. -// They serve as single source of truth for both: -// 1. TypeScript types in tools.ts (via z.infer<>) -// 2. Runtime type generation for PTC (via Zod → JSON Schema → TypeScript string) - -/** - * Truncation info returned when output exceeds limits. - */ -const TruncatedInfoSchema = z.object({ - reason: z.string(), - totalLines: z.number(), -}); - -/** - * Bash tool result - success, background spawn, or failure. - */ -const BashToolSuccessSchema = z - .object({ - success: z.literal(true), - output: z.string(), - exitCode: z.literal(0), - wall_duration_ms: z.number(), - note: z.string().optional(), - truncated: TruncatedInfoSchema.optional(), - }) - .extend(ToolOutputUiOnlyFieldSchema); - -const BashToolMonitorResultSchema = z - .object({ - filter: z.string(), - filter_exclude: z.boolean(), - cooldown_ms: z.number(), - max_events: z.number().optional(), - // Optional (not required) so persisted results written before this field existed still parse. - wake_on_exit: z.boolean().optional(), - }) - .strict(); - -const BashToolBackgroundSchema = z - .object({ - success: z.literal(true), - output: z.string(), - exitCode: z.literal(0), - wall_duration_ms: z.number(), - monitor: BashToolMonitorResultSchema.optional(), - taskId: z.string(), - backgroundProcessId: z.string(), - }) - .extend(ToolOutputUiOnlyFieldSchema); - -const BashToolFailureSchema = z - .object({ - success: z.literal(false), - output: z.string().optional(), - exitCode: z.number(), - error: z.string(), - wall_duration_ms: z.number(), - note: z.string().optional(), - truncated: TruncatedInfoSchema.optional(), - }) - .extend(ToolOutputUiOnlyFieldSchema); - -export const BashToolResultSchema = z.union([ - // Foreground success - BashToolSuccessSchema, - // Background spawn success - BashToolBackgroundSchema, - // Failure - BashToolFailureSchema, -]); - -/** - * Bash output tool result - process status and incremental output. - */ -export const BashOutputToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - status: z.enum(["running", "exited", "killed", "failed", "interrupted"]), - output: z.string(), - exitCode: z.number().optional(), - note: z.string().optional(), - elapsed_ms: z.number(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -/** - * Bash background list tool result - all background processes. - */ -export const BashBackgroundListResultSchema = z.union([ - z.object({ - success: z.literal(true), - processes: z.array( - z.object({ - process_id: z.string(), - status: z.enum(["running", "exited", "killed", "failed"]), - script: z.string(), - uptime_ms: z.number(), - exitCode: z.number().optional(), - display_name: z.string().optional(), - }) - ), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -/** - * Bash background terminate tool result. - */ -export const BashBackgroundTerminateResultSchema = z.union([ - z.object({ - success: z.literal(true), - message: z.string(), - display_name: z.string().optional(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -/** - * xum_agents_read tool result. - */ -export const XumAgentsReadToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - content: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -/** - * xum_agents_write tool result. - */ -export const XumAgentsWriteToolResultSchema = z.union([ - z - .object({ - success: z.literal(true), - diff: z.string(), - }) - .extend(ToolOutputUiOnlyFieldSchema), - z - .object({ - success: z.literal(false), - error: z.string(), - }) - .extend(ToolOutputUiOnlyFieldSchema), -]); - -/** - * xum_config_read tool result. - */ -export const XumConfigReadToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - file: z.string(), - data: z.unknown(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -const XumConfigWriteValidationIssueSchema = z.object({ - path: z.array(z.union([z.string(), z.number()])), - message: z.string(), -}); - -/** - * xum_config_write tool result. - */ -export const XumConfigWriteToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - file: z.string(), - appliedOps: z.number(), - summary: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - validationIssues: z.array(XumConfigWriteValidationIssueSchema).optional(), - }), -]); -/** - * File read tool result - content or error. - */ -export const FileReadToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - file_size: z.number(), - modifiedTime: z.string(), - lines_read: z.number(), - content: z - .string() - .describe( - "File content with line numbers prepended as '\\t'. " + - "Line numbers are not part of the actual file content." + heartbeat: { + resultSchema: HeartbeatToolResultSchema, + description: + "Read or change this workspace's scheduled heartbeat. " + + "The tool only affects the current workspace; it does not accept a workspaceId. " + + "Use action='set' to enable or configure the heartbeat interval, custom message, context mode, trigger, when-busy behavior, or enabled flag. " + + "trigger chooses the countdown anchor: 'idle' (default) fires only after the workspace has been quiet for a full interval; 'interval' fires on a fixed wall-clock cadence. " + + "whenBusy chooses what happens when a heartbeat fires while the workspace is busy: 'skip' misses the slot, 'tool-end'/'turn-end' queue the heartbeat for the matching boundary. " + + "Unset whenBusy defaults to 'skip' for trigger 'idle' and 'turn-end' for trigger 'interval'. " + + "Use action='unset' to remove this workspace's heartbeat settings entirely. " + + "Use action='get' before changing settings when you need to preserve existing values.", + schema: HeartbeatToolArgsSchema, + }, + todo_write: { + ptcExcluded: "UI-specific", + description: + "Create or update the todo list for tracking multi-step tasks (limit: 7 items). " + + "The TODO list is displayed to the user at all times. " + + "Replace the entire list on each call - the AI tracks which tasks are completed.\n" + + "\n" + + "Mark tasks as in_progress when actively being worked on (multiple allowed for parallel work). " + + "Order tasks as: completed first, then in_progress, then pending last. " + + "Use appropriate tense in content: past tense for completed (e.g., 'Added tests'), " + + "present progressive for in_progress (e.g., 'Adding tests'), " + + "and imperative/infinitive for pending (e.g., 'Add tests').\n" + + "\n" + + "If you hit the 7-item limit, summarize older completed items into one line " + + "(e.g., 'Completed initial setup (3 tasks)').\n" + + "\n" + + "Update the list as work progresses. If work fails or the approach changes, update " + + "the list to reflect reality - only mark tasks complete when they actually succeed.", + schema: z.object({ + todos: z.array( + z.object({ + content: z + .string() + .describe( + "Task description with tense matching status: past for completed, present progressive for in_progress, imperative for pending" + ), + status: z.enum(["pending", "in_progress", "completed"]).describe("Task status"), + }) ), - warning: z.string().optional(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -const AttachFileToolTextPartSchema = z - .object({ - type: z.literal("text"), - text: z.string(), - }) - .strict(); - -const AttachFileToolMediaPartSchema = z - .object({ - type: z.literal("media"), - data: z.string(), - mediaType: z.string(), - filename: z.string().optional(), - }) - .strict(); - -const AttachFileToolDisplayFilePartSchema = z - .object({ - type: z.literal("display_file"), - data: z.string(), - mediaType: z.string(), - filename: z.string().optional(), - providerOptions: z + }), + }, + todo_read: { + ptcExcluded: "UI-specific", + description: "Read the current todo list", + schema: z.object({}), + }, + review_pane_update: { + description: + "Flag specific code regions in the Review pane for the user to review next. " + + "Use this to draw the user's attention to critical changes you want reviewed first. " + + "Each hunk references a project-relative file path with an optional inclusive line " + + 'range using familiar syntax: "src/foo.ts" (whole file), "src/foo.ts:42" (single line), ' + + 'or "src/foo.ts:42-58" (range, new-file line numbers). Project-relative paths are ' + + "preferred; use './' or '../' for paths that must resolve from the current tool cwd. " + + "Attach a short comment to each " + + "hunk explaining what to look at and why.\n\n" + + "operation:\n" + + " - 'replace' (default): overwrite the current assisted set\n" + + " - 'add': append to the existing set, deduplicating exact path:range matches\n\n" + + "Flagged hunks appear pinned at the top of the Review pane; the user can toggle " + + "'Assisted' to hide everything else. Pass an empty hunks array with operation='replace' " + + "to clear the set when review is no longer needed.", + schema: z .object({ - mux: z - .object({ - displayOnly: z.literal(true), - size: z.number().int().nonnegative(), - }) - .strict() - .optional(), + operation: z + .enum(["add", "replace"]) + .describe("'replace' overwrites the assisted set; 'add' appends to it."), + hunks: z + .array( + z + .object({ + path: z + .string() + .min(1) + .describe( + 'Filter in `path[:range]` form, e.g. "src/foo.ts" or "src/foo.ts:42-58". ' + + "Path is project-relative; use './' or '../' when the path must resolve from the current tool working directory. Range uses new-file line numbers (inclusive)." + ), + comment: z + .string() + .nullish() + .describe("Short note (~1 sentence) telling the user what to look at and why."), + }) + .strict() + ) + .describe("List of hunks to flag for review."), }) - .strict() - .optional(), - }) - .strict(); - -const AttachFileToolSuccessResultSchema = z - .object({ - type: z.literal("content"), - value: z.union([ - z.tuple([AttachFileToolTextPartSchema, AttachFileToolMediaPartSchema]), - z.tuple([AttachFileToolTextPartSchema, AttachFileToolDisplayFilePartSchema]), - ]), - }) - .strict(); - -export const AttachFileToolResultSchema = z.union([ - AttachFileToolSuccessResultSchema, - z - .object({ - success: z.literal(false), - error: z.string(), - }) - .strict(), -]); - -/** - * Agent Skill read tool result - full SKILL.md package or error. - */ -export const AgentSkillReadToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - skill: AgentSkillPackageSchema, - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); - -/** - * Agent Skill read_file tool result. - * Uses the same shape/limits as file_read. - */ -export const AgentSkillReadFileToolResultSchema = FileReadToolResultSchema; - -/** - * MCP prompt get tool result - flattened prompt text or error. - */ -export const MCPPromptGetToolResultSchema = z.union([ - z - .object({ - success: z.literal(true), - text: z.string(), - description: z.string().optional(), - }) - .strict(), - z - .object({ - success: z.literal(false), - error: z.string(), - }) - .strict(), -]); - -/** - * File edit insert tool result - diff or error. - */ -export const FileEditInsertToolResultSchema = z.union([ - z - .object({ - success: z.literal(true), - diff: z.string(), - warning: z.string().optional(), - }) - .extend(ToolOutputUiOnlyFieldSchema), - z - .object({ - success: z.literal(false), - error: z.string(), - note: z.string().optional(), - }) - .extend(ToolOutputUiOnlyFieldSchema), -]); - -/** - * File edit replace string tool result - diff with edit count or error. - */ -export const FileEditReplaceStringToolResultSchema = z.union([ - z - .object({ - success: z.literal(true), - diff: z.string(), - edits_applied: z.number(), - warning: z.string().optional(), - }) - .extend(ToolOutputUiOnlyFieldSchema), - z - .object({ - success: z.literal(false), - error: z.string(), - note: z.string().optional(), - }) - .extend(ToolOutputUiOnlyFieldSchema), -]); + .strict(), + }, + review_pane_get: { + description: + "Return the current set of agent-flagged hunks in the Review pane, in declared order. " + + "Use this to inspect what you've already pinned before adding more.", + schema: z.object({}).strict(), + }, + bash_output: { + resultSchema: BashOutputToolResultSchema, + description: + 'DEPRECATED: use task_await instead (pass bash-prefixed taskId like "bash:"). ' + + "Retrieve output from a running or completed background bash process. " + + "Returns only NEW output since the last check (incremental). " + + "Returns stdout and stderr output along with process status. " + + "Supports optional regex filtering to show only lines matching a pattern. " + + "WARNING: When using filter, non-matching lines are permanently discarded. " + + "Use timeout to wait for output instead of polling repeatedly. " + + "Large outputs may be automatically filtered; when this happens, the result includes a note explaining what was kept and (if available) where the full output was saved.", + schema: z.object({ + process_id: z.string().describe("The ID of the background process to retrieve output from"), + filter: z + .string() + .nullish() + .describe( + "Optional regex to filter output lines. By default, only matching lines are returned. " + + "When filter_exclude is true, matching lines are excluded instead. " + + "Non-matching lines are permanently discarded and cannot be retrieved later." + ), + filter_exclude: z + .boolean() + .nullish() + .describe( + "When true, lines matching 'filter' are excluded instead of kept. " + + "Key behavior: excluded lines do NOT cause early return from timeout - " + + "waiting continues until non-excluded output arrives or process exits. " + + "Use to avoid busy polling on progress spam (e.g., filter='⏳|waiting|\\.\\.\\.' with filter_exclude=true " + + "lets you set a long timeout and only wake on meaningful output). " + + "Requires 'filter' to be set." + ), + timeout_secs: z + .number() + .min(0) + .describe( + "Seconds to wait for new output. " + + "If no output is immediately available and process is still running, " + + "blocks up to this duration. Returns early when output arrives or process exits. " + + "Only use long timeouts (>15s) when no other useful work can be done in parallel." + ), + }), + }, + bash_background_list: { + resultSchema: BashBackgroundListResultSchema, + description: + "DEPRECATED: use task_list instead. " + + "List all background processes started with bash(run_in_background=true). " + + "Returns process_id, status, script for each process. " + + "Use to find process_id for termination or check output with bash_output.", + schema: z.object({}), + }, + bash_background_terminate: { + resultSchema: BashBackgroundTerminateResultSchema, + description: + "DEPRECATED: use task_stop instead. " + + "Terminate a background process started with bash(run_in_background=true). " + + "Use process_id from the original bash response or from bash_background_list. " + + "Sends SIGTERM, waits briefly, then SIGKILL if needed. " + + "Output remains available via bash_output after termination.", + schema: z.object({ + process_id: z.string().describe("Background process ID to terminate"), + }), + }, + analytics_query: { + description: `Execute a DuckDB SQL query against Xum analytics tables and optionally provide visualization hints. +Use read-only SELECT queries over analytics data. -/** - * Web fetch tool result - parsed content or error. - */ -export const WebFetchToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - title: z.string(), - content: z.string(), - url: z.string(), - byline: z.string().optional(), - length: z.number(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - content: z.string().optional(), - }), -]); +DuckDB SQL guidelines: +- Use SELECT queries only; do not write, alter, or drop tables. +- Prefer explicit column lists and aliases so result sets are easy to understand. +- Use ORDER BY and LIMIT for exploratory queries over large datasets. +- Use DuckDB date/time helpers (for example date_trunc, CAST(... AS DATE), and interval arithmetic) for time series. -export const HeartbeatToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - action: HeartbeatToolActionSchema, - configured: z.boolean(), - settings: WorkspaceHeartbeatSettingsSchema.nullable(), - summary: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); +Available tables: -// `recorded: false` means TimelineService throttled the note (duplicate description or too -// many agent events in a short window) and nothing was added to the timeline. -export const TimelineEventToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - recorded: z.boolean(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); +CREATE TABLE IF NOT EXISTS events ( + workspace_id VARCHAR NOT NULL, + project_path VARCHAR, + project_name VARCHAR, + workspace_name VARCHAR, + parent_workspace_id VARCHAR, + agent_id VARCHAR, + timestamp BIGINT, + date DATE, + model VARCHAR, + thinking_level VARCHAR, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + cached_tokens INTEGER DEFAULT 0, + cache_create_tokens INTEGER DEFAULT 0, + input_cost_usd DOUBLE DEFAULT 0, + output_cost_usd DOUBLE DEFAULT 0, + reasoning_cost_usd DOUBLE DEFAULT 0, + cached_cost_usd DOUBLE DEFAULT 0, + total_cost_usd DOUBLE DEFAULT 0, + duration_ms DOUBLE, + ttft_ms DOUBLE, + streaming_ms DOUBLE, + tool_execution_ms DOUBLE, + output_tps DOUBLE, + response_index INTEGER, + is_sub_agent BOOLEAN DEFAULT false +) -export const MemoryToolResultSchema = z.union([ - z.object({ - success: z.literal(true), - output: z.string(), - }), - z.object({ - success: z.literal(false), - error: z.string(), - }), -]); +CREATE TABLE IF NOT EXISTS delegation_rollups ( + parent_workspace_id VARCHAR NOT NULL, + child_workspace_id VARCHAR NOT NULL, + project_path VARCHAR, + project_name VARCHAR, + agent_type VARCHAR, + model VARCHAR, + total_tokens INTEGER DEFAULT 0, + context_tokens INTEGER DEFAULT 0, + input_tokens INTEGER DEFAULT 0, + output_tokens INTEGER DEFAULT 0, + reasoning_tokens INTEGER DEFAULT 0, + cached_tokens INTEGER DEFAULT 0, + cache_create_tokens INTEGER DEFAULT 0, + report_token_estimate INTEGER DEFAULT 0, + total_cost_usd DOUBLE DEFAULT 0, + rolled_up_at_ms BIGINT, + date DATE, + PRIMARY KEY (parent_workspace_id, child_workspace_id) +)`, + schema: z.object({ + sql: z.string().min(1).describe("DuckDB SQL query to execute"), + visualization: z + .enum(["table", "bar", "line", "pie", "area", "stacked_bar"]) + .nullish() + .describe("Optional visualization type for rendering the query result"), + title: z.string().nullish().describe("Optional chart title"), + x_axis: z.string().nullish().describe("Optional column name for the visualization X axis"), + y_axis: z + .array(z.string()) + .nullish() + .describe("Optional column name(s) for the visualization Y axis"), + }), + }, + web_fetch: { + resultSchema: WebFetchToolResultSchema, + description: + `Fetch a web page and extract its main content as clean markdown. ` + + `Uses the workspace's network context (requests originate from the workspace, not Xum host). ` + + `Requires curl to be installed in the workspace. ` + + `Output is truncated to ${Math.floor(WEB_FETCH_MAX_OUTPUT_BYTES / 1024)}KB.`, + schema: z.object({ + url: z.string().url().describe("The URL to fetch (http or https)"), + }), + }, + code_execution: { + ptcExcluded: "Prevent recursive sandbox creation", + description: + "Execute JavaScript code in a sandboxed environment with access to Xum tools. " + + "Available for multi-tool workflows when PTC experiment is enabled.", + schema: z.object({ + code: z.string().min(1).describe("JavaScript code to execute in the PTC sandbox"), + }), + }, + refinement_rollback: { + description: + "Roll back a journaled harness self-modification (a memory or skill edit) by its refinement row id, " + + "restoring the exact prior file contents recorded in the session's refinement journal. " + + "The rollback is journaled as a refinement row of its own, so it can be rolled back again. " + + "Refuses rows that were already rolled back and rows whose files changed since (divergence). " + + "Available only in RLM mode.", + schema: z + .object({ + id: z.string().min(1).describe("Refinement row id (envelope id) to roll back"), + reason: z + .string() + .min(1) + .describe("Why this refinement is being rolled back (recorded in the journal)"), + }) + .strict(), + }, + // #region NOTIFY_DOCS + notify: { + description: + "Send a system notification to the user. Use this to alert the user about important events that require their attention, such as long-running task completion, errors requiring intervention, or questions. " + + "Notifications appear as OS-native notifications (macOS Notification Center, Windows Toast, Linux). " + + "Infer whether to send notifications from user instructions. If no instructions provided, reserve notifications for major wins or blocking issues. Do not use for routine progress updates — keep the todo list current instead.", + schema: z + .object({ + title: z + .string() + .min(1) + .max(64) + .describe("Short notification title (max 64 chars). Should be concise and actionable."), + message: z + .string() + .max(200) + .nullish() + .describe( + "Optional notification body with more details (max 200 chars). " + + "Keep it brief - users may only see a preview." + ), + }) + .strict(), + }, + // #endregion NOTIFY_DOCS + tool_catalog_search: { + description: + "Search the catalog of deferred tools. Some tools (provided by MCP servers) are deferred: " + + "they exist but are not currently visible in your tool list. " + + "Call tool_catalog_search with task/capability keywords to discover them; matched tools become available on the next step. " + + "Returns matched tool names and descriptions plus the total number of deferred tools (there may be more undiscovered — refine the query to find them).", + schema: z + .object({ + query: z + .string() + .min(1) + .describe( + "Task or capability keywords to search for (matched against tool names, descriptions, and parameter names)" + ), + limit: z + .number() + .int() + .min(1) + .max(25) + .nullish() + .describe("Maximum number of matches to return (default 10, max 25)"), + }) + .strict(), + }, + mcp_prompt_get: { + resultSchema: MCPPromptGetToolResultSchema, + description: + "Fetch a prompt template from a connected MCP server, expanded with the given arguments. " + + "MCP prompts are reusable instructions or workflows the user has made available through MCP servers. " + + "The result contains the prompt text; follow it as task guidance in the current conversation. " + + "Available prompts are listed in this description when connected servers advertise them.", + schema: z + .object({ + name: z + .string() + .min(1) + .describe('Prompt name from the available list, e.g. "mcp__server__prompt"'), + arguments: z + .record(z.string(), z.string()) + .nullish() + .describe( + "Prompt argument values by argument name. Arguments marked with ? are optional; all others are required." + ), + list_offset: z + .number() + .int() + .min(0) + .nullish() + .describe( + "When an unknown-name error truncates the prompt listing, repeat the call with the suggested list_offset to page through the remaining prompt names." + ), + }) + .strict(), + }, +} as const satisfies Record; -/** - * Names of tools that are bridgeable to PTC sandbox. - * If adding a new tool here, you must also add its result schema below. - */ -export type BridgeableToolName = - | "bash" - | "bash_output" - | "bash_background_list" - | "bash_background_terminate" - | "file_read" - | "attach_file" - | "agent_skill_read" - | "agent_skill_read_file" - | "file_edit_insert" - | "file_edit_replace_string" - // Note: for Anthropic models, web_fetch is replaced by a provider-native tool - // (webFetch_20250910) that has no execute(). ToolBridge's hasExecute filter will drop it - // from the PTC sandbox for those sessions. That silent absence is intentional and accepted. - | "web_fetch" - | "task" - | "task_await" - | "task_apply_git_patch" - | "task_list" - | "task_send_message" - // Family messaging tools are bridged when the RLM experiment enables them; - // registering their result schemas keeps generateXumTypes from declaring - // them as returning unknown inside the kernel. - | "task_message_parent" - | "task_message_sibling" - | "task_retitle" - | "task_stop" - | "task_remove" - | "task_workspace_lifecycle" - | "heartbeat" - | "memory" - | "mcp_prompt_get"; +export type ToolName = keyof typeof TOOL_DEFINITIONS; -/** - * Lookup map for result schemas by tool name. - * Used by PTC type generator to get result types for bridgeable tools. - * - * Type-level enforcement ensures all BridgeableToolName entries have schemas. - */ -export const RESULT_SCHEMAS: Record = { - bash: BashToolResultSchema, - bash_output: BashOutputToolResultSchema, - bash_background_list: BashBackgroundListResultSchema, - bash_background_terminate: BashBackgroundTerminateResultSchema, - file_read: FileReadToolResultSchema, - attach_file: AttachFileToolResultSchema, - agent_skill_read: AgentSkillReadToolResultSchema, - agent_skill_read_file: AgentSkillReadFileToolResultSchema, - file_edit_insert: FileEditInsertToolResultSchema, - file_edit_replace_string: FileEditReplaceStringToolResultSchema, - web_fetch: WebFetchToolResultSchema, - task: TaskToolResultSchema, - task_await: TaskAwaitToolResultSchema, - task_apply_git_patch: TaskApplyGitPatchToolResultSchema, - task_list: TaskListToolResultSchema, - task_send_message: TaskSendMessageToolResultSchema, - task_message_parent: TaskMessageParentToolResultSchema, - task_message_sibling: TaskMessageSiblingToolResultSchema, - task_retitle: TaskRetitleToolResultSchema, - task_stop: TaskStopToolResultSchema, - task_remove: TaskRemoveToolResultSchema, - task_workspace_lifecycle: TaskWorkspaceLifecycleToolResultSchema, - heartbeat: HeartbeatToolResultSchema, - memory: MemoryToolResultSchema, - mcp_prompt_get: MCPPromptGetToolResultSchema, -}; +export type BridgeableToolName = { + [K in ToolName]: (typeof TOOL_DEFINITIONS)[K] extends { resultSchema: z.ZodType } ? K : never; +}[ToolName]; + +export function getToolResultSchema(toolName: string): z.ZodType | undefined { + if (!Object.hasOwn(TOOL_DEFINITIONS, toolName)) return undefined; + const definition = TOOL_DEFINITIONS[toolName as ToolName]; + return "resultSchema" in definition ? definition.resultSchema : undefined; +} /** * Get tool definition schemas for token counting diff --git a/src/common/utils/tools/tools.ts b/src/common/utils/tools/tools.ts index fc286a097c6..d8d0a95dbe6 100644 --- a/src/common/utils/tools/tools.ts +++ b/src/common/utils/tools/tools.ts @@ -920,7 +920,7 @@ export async function getToolsForModel( // // Known limitations when the native override is active: // - Cannot reach private/localhost URLs (Anthropic's servers can't see workspace network). - // - Not bridgeable in the PTC sandbox (no execute()); see BridgeableToolName comment. + // - Not bridgeable in the PTC sandbox because provider-native tools have no execute(). // - Tool hooks (.xum/tool_pre/.xum/tool_post) are skipped because withHooks() returns // early when execute() is absent — same limitation as web_search (provider-native). if (supportsAnthropicNativeWebFetch(capabilityModelId)) { diff --git a/src/node/services/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index 2ab16c14d66..c897be51cce 100644 --- a/src/node/services/ptc/toolBridge.ts +++ b/src/node/services/ptc/toolBridge.ts @@ -22,6 +22,7 @@ import { type CapabilityGrants, } from "@/common/types/capabilityGrants"; import { isToolContentResult } from "@/common/utils/tools/toolContentResult"; +import { TOOL_DEFINITIONS } from "@/common/utils/tools/toolDefinitions"; import { isSupportedAttachmentMediaType } from "@/common/utils/attachments/supportedAttachmentMediaTypes"; import { isDisplayOnlyFilePart } from "@/common/utils/attachments/displayOnlyFileParts"; import { @@ -149,27 +150,14 @@ function parseLoadArgs(args: unknown): { path: string; key: string } { return { path, key }; } -/** Tools excluded from sandbox - UI-specific or would cause recursion */ -const EXCLUDED_TOOLS = new Set([ - "code_execution", // Prevent recursive sandbox creation - "ask_user_question", // Requires UI interaction - "propose_plan", // Mode-specific, call directly - "todo_write", // UI-specific - "todo_read", // UI-specific - "status_set", // UI-specific - "agent_report", // Must be top-level for taskService to read args from history - // Context-coupled tools: AIService keys system-prompt context off their - // top-level presence (memory index / hot-set block for `memory`, proactive - // guidance for `advisor`). Bridging them would silently drop that context - // in the exclusive posture. - "memory", - "advisor", - // Media-producing built-ins (attach_file, desktop_screenshot) are - // deliberately bridgeable: stripAttachmentParts removes their base64 from - // sandbox-visible values and the code_execution attachments carrier delivers - // the real bytes to request-time extraction, so guest code like - // xum.attach_file(...) works without retaining media in QuickJS memory. -]); +const ptcExcludedTools = new Set( + Object.entries(TOOL_DEFINITIONS).flatMap(([name, definition]) => + "ptcExcluded" in definition ? [name] : [] + ) +); + +// Media-producing built-ins (attach_file, desktop_screenshot) are deliberately +// bridgeable because attachment bytes stay outside QuickJS memory. /** * Bridge that exposes Xum tools in the QuickJS sandbox under canonical `xum.*` and legacy `mux.*` namespaces. @@ -204,7 +192,9 @@ export class ToolBridge { // code_execution is the tool that uses the bridge, not a candidate for bridging if (name === "code_execution") continue; - const isBridgeable = !EXCLUDED_TOOLS.has(name) && this.hasExecute(tool); + // status_set is dynamic and UI-specific, so it has no catalog entry. + const isBridgeable = + name !== "status_set" && !ptcExcludedTools.has(name) && this.hasExecute(tool); if (!isBridgeable) { this.nonBridgeableTools.set(name, tool); } else if (isBridgeToolGranted(this.grants, name)) { diff --git a/src/node/services/ptc/typeGenerator.test.ts b/src/node/services/ptc/typeGenerator.test.ts index 7f305ae7368..3dd4b98e039 100644 --- a/src/node/services/ptc/typeGenerator.test.ts +++ b/src/node/services/ptc/typeGenerator.test.ts @@ -109,7 +109,7 @@ describe("generateXumTypes", () => { task_message_sibling: createMockTool(z.object({ task_id: z.string(), message: z.string() })), }); - // Both tools must resolve through RESULT_SCHEMAS so the kernel sees their + // Both tools must resolve through catalog result schemas so the kernel sees their // status discriminants instead of an opaque unknown return type. expect(types).toContain( "function task_message_parent(args: TaskMessageParentArgs): TaskMessageParentResult" diff --git a/src/node/services/ptc/typeGenerator.ts b/src/node/services/ptc/typeGenerator.ts index f48c9fb1014..8db94e39685 100644 --- a/src/node/services/ptc/typeGenerator.ts +++ b/src/node/services/ptc/typeGenerator.ts @@ -14,7 +14,7 @@ import { createHash } from "crypto"; import { z } from "zod"; import { compile } from "json-schema-to-typescript"; import type { Tool } from "ai"; -import { RESULT_SCHEMAS, type BridgeableToolName } from "@/common/utils/tools/toolDefinitions"; +import { getToolResultSchema } from "@/common/utils/tools/toolDefinitions"; import { TASK_TERMINAL_EVENT_TYPE } from "@/constants/sandboxEvents"; /** Options for mux type generation. */ @@ -204,11 +204,8 @@ async function getResultTypeString(toolName: string): Promise { return cache.resultTypes.get(toolName)!; } - // Check if this is a bridgeable tool with a known result schema - if (!(toolName in RESULT_SCHEMAS)) { - return null; - } - const schema = RESULT_SCHEMAS[toolName as BridgeableToolName]; + const schema = getToolResultSchema(toolName); + if (!schema) return null; // Convert Zod → JSON Schema → TypeScript const jsonSchema = z.toJSONSchema(schema); From c9857f9a48d2e8f47ef4a00ef2017d7e0b9daf47 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:24:31 +0000 Subject: [PATCH 14/20] refactor(tools): collapse presentation metadata --- .../Tools/Shared/getToolComponent.test.ts | 157 ++-------- .../features/Tools/Shared/getToolComponent.ts | 269 +++++------------- src/cli/toolFormatters.ts | 88 +++--- 3 files changed, 133 insertions(+), 381 deletions(-) diff --git a/src/browser/features/Tools/Shared/getToolComponent.test.ts b/src/browser/features/Tools/Shared/getToolComponent.test.ts index 5233206e50f..755751025c9 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.test.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.test.ts @@ -1,161 +1,42 @@ import { describe, expect, test } from "bun:test"; import { AgentReportToolCall } from "../AgentReportToolCall"; -import { AgentSkillListToolCall } from "../AgentSkillListToolCall"; -import { AgentSkillReadFileToolCall } from "../AgentSkillReadFileToolCall"; -import { AgentSkillReadToolCall } from "../AgentSkillReadToolCall"; -import { CompleteGoalToolCall } from "../CompleteGoalToolCall"; -import { DesktopActionToolCall } from "../DesktopActionToolCall"; -import { DesktopScreenshotToolCall } from "../DesktopScreenshotToolCall"; import { GenericToolCall } from "../GenericToolCall"; import { GoogleSearchToolCall } from "../GoogleSearchToolCall"; -import { SetGoalToolCall } from "../SetGoalToolCall"; -import { WorkflowResumeToolCall, WorkflowRunToolCall } from "../WorkflowRunToolCall"; -import { GetGoalToolCall } from "../GetGoalToolCall"; -import { HeartbeatToolCall } from "../HeartbeatToolCall"; -import { TaskRemoveToolCall, TaskRetitleToolCall, TaskStopToolCall } from "../TaskToolCall"; import { ToolSearchToolCall } from "../ToolSearchToolCall"; import { getToolComponent } from "./getToolComponent"; describe("getToolComponent", () => { - test("falls back to generic rendering for removed workflow discovery tools", () => { + test("falls back to generic rendering for removed or unknown tools", () => { expect(getToolComponent("workflow_list", {})).toBe(GenericToolCall); - expect(getToolComponent("workflow_read", { name: "deep-research" })).toBe(GenericToolCall); + expect(getToolComponent("unknown_tool", {})).toBe(GenericToolCall); }); - test("routes the simplified task lifecycle tools", () => { - expect(getToolComponent("task_retitle", { task_id: "child", title: "Reviewer" })).toBe( - TaskRetitleToolCall - ); - expect(getToolComponent("task_stop", { task_ids: ["child"] })).toBe(TaskStopToolCall); - expect(getToolComponent("task_remove", { task_ids: ["child"] })).toBe(TaskRemoveToolCall); - }); - - test("returns WorkflowRunToolCall for workflow_run", () => { - const component = getToolComponent("workflow_run", { - script_path: "skill://deep-research/workflow.js", - }); - expect(component).toBe(WorkflowRunToolCall); - }); - - test("returns WorkflowResumeToolCall for workflow_resume", () => { - const component = getToolComponent("workflow_resume", { run_id: "wfr_123" }); - expect(component).toBe(WorkflowResumeToolCall); - }); - - test("returns AgentReportToolCall for agent_report", () => { - const component = getToolComponent("agent_report", { reportMarkdown: "# Hello" }); - expect(component).toBe(AgentReportToolCall); - }); - - test("returns AgentReportToolCall for legacy file-backed agent_report transcripts", () => { - const component = getToolComponent("agent_report", { - reportMarkdownPath: "report.md", - structuredOutputPath: "structured-output.json", - title: null, - }); - expect(component).toBe(AgentReportToolCall); - }); - - test("returns AgentReportToolCall for empty legacy file-backed agent_report input", () => { + test("renders legacy file-backed agent_report transcripts", () => { + expect( + getToolComponent("agent_report", { + reportMarkdownPath: "report.md", + structuredOutputPath: "structured-output.json", + title: null, + }) + ).toBe(AgentReportToolCall); expect(getToolComponent("agent_report", {})).toBe(AgentReportToolCall); }); - test("returns AgentSkillReadToolCall for agent_skill_read", () => { - const component = getToolComponent("agent_skill_read", { name: "react-effects" }); - expect(component).toBe(AgentSkillReadToolCall); - }); - - test("returns AgentSkillReadFileToolCall for agent_skill_read_file", () => { - const component = getToolComponent("agent_skill_read_file", { - name: "react-effects", - filePath: "references/README.md", - }); - expect(component).toBe(AgentSkillReadFileToolCall); - }); - - test("returns AgentSkillListToolCall for agent_skill_list", () => { - expect(getToolComponent("agent_skill_list", {})).toBe(AgentSkillListToolCall); - expect(getToolComponent("agent_skill_list", { includeUnadvertised: true })).toBe( - AgentSkillListToolCall - ); - }); - - test("agent_skill_list falls back to GenericToolCall when args don't conform", () => { - // includeUnadvertised is boolean.nullish(); a string fails the schema. + test("falls back when catalog schema validation fails", () => { expect(getToolComponent("agent_skill_list", { includeUnadvertised: "yes" })).toBe( GenericToolCall ); + expect(getToolComponent("agent_report", { reportMarkdown: "" })).toBe(GenericToolCall); }); - test("returns DesktopScreenshotToolCall for desktop_screenshot", () => { - const component = getToolComponent("desktop_screenshot", { scaledWidth: 640 }); - expect(component).toBe(DesktopScreenshotToolCall); - }); - - test("returns DesktopActionToolCall for desktop_click", () => { - const component = getToolComponent("desktop_click", { x: 12, y: 34 }); - expect(component).toBe(DesktopActionToolCall); - }); - - test("returns SetGoalToolCall for set_goal", () => { - const component = getToolComponent("set_goal", { objective: "Ship it" }); - expect(component).toBe(SetGoalToolCall); - }); - - test("returns GetGoalToolCall for get_goal", () => { - const component = getToolComponent("get_goal", {}); - expect(component).toBe(GetGoalToolCall); - }); - - test("returns CompleteGoalToolCall for complete_goal", () => { - const component = getToolComponent("complete_goal", { summary: "Done." }); - expect(component).toBe(CompleteGoalToolCall); - }); - - test("complete_goal falls back to GenericToolCall when summary is empty (zod min(1) fails)", () => { - const component = getToolComponent("complete_goal", { summary: "" }); - expect(component).toBe(GenericToolCall); - }); - - test("returns HeartbeatToolCall for heartbeat", () => { - expect(getToolComponent("heartbeat", { action: "get" })).toBe(HeartbeatToolCall); - expect(getToolComponent("heartbeat", { action: "set", intervalMs: 30 * 60_000 })).toBe( - HeartbeatToolCall - ); - }); - - test("heartbeat falls back to GenericToolCall when intervalMs is out of range", () => { - // 30s is below HEARTBEAT_MIN_INTERVAL_MS (5min); the schema's .min() rejects it. - expect(getToolComponent("heartbeat", { action: "set", intervalMs: 30_000 })).toBe( - GenericToolCall - ); - }); - - test("falls back to GenericToolCall when args validation fails", () => { - const component = getToolComponent("agent_report", { reportMarkdown: "" }); - expect(component).toBe(GenericToolCall); - }); - - test("returns GoogleSearchToolCall for server:GOOGLE_SEARCH_WEB", () => { + test("keeps provider-executed Google search calls visible while arguments stream", () => { expect(getToolComponent("server:GOOGLE_SEARCH_WEB", { queries: ["gemini 3 pricing"] })).toBe( GoogleSearchToolCall ); - // Streaming/pending args (not yet parsed) must not bounce to the generic renderer. expect(getToolComponent("server:GOOGLE_SEARCH_WEB", {})).toBe(GoogleSearchToolCall); - }); - - test("server:GOOGLE_SEARCH_WEB falls back to GenericToolCall when args don't conform", () => { - const component = getToolComponent("server:GOOGLE_SEARCH_WEB", { queries: "not-an-array" }); - expect(component).toBe(GenericToolCall); - }); - - test("returns ToolSearchToolCall for tool_catalog_search with valid args", () => { - expect(getToolComponent("tool_catalog_search", { query: "send slack message" })).toBe( - ToolSearchToolCall - ); - expect(getToolComponent("tool_catalog_search", { query: "send slack message", limit: 5 })).toBe( - ToolSearchToolCall + expect(getToolComponent("server:GOOGLE_SEARCH_WEB", { queries: "not-an-array" })).toBe( + GenericToolCall ); }); @@ -165,13 +46,7 @@ describe("getToolComponent", () => { ); }); - test("tool_catalog_search falls back to GenericToolCall when args don't conform", () => { - expect(getToolComponent("tool_catalog_search", { query: 42 })).toBe(GenericToolCall); - }); - - test("Object.prototype member names fall back to GenericToolCall instead of throwing", () => { - // toolName flows verbatim from persisted transcripts; inherited members of the - // registry object must not be treated as entries (self-healing invariant). + test("Object.prototype member names fall back instead of throwing", () => { expect(getToolComponent("constructor", {})).toBe(GenericToolCall); expect(getToolComponent("__proto__", {})).toBe(GenericToolCall); expect(getToolComponent("toString", {})).toBe(GenericToolCall); diff --git a/src/browser/features/Tools/Shared/getToolComponent.ts b/src/browser/features/Tools/Shared/getToolComponent.ts index ac38aef0381..6404c70bdd8 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.ts @@ -8,8 +8,8 @@ import type { ComponentType } from "react"; import { z, type ZodSchema } from "zod"; import { TaskTerminateToolArgsSchema, - TaskWorkspaceLifecycleToolArgsSchema, TOOL_DEFINITIONS, + type ToolName, } from "@/common/utils/tools/toolDefinitions"; import { AnalyticsQueryToolCall } from "../analyticsQuery/AnalyticsQueryToolCall"; @@ -69,20 +69,64 @@ import { CompleteGoalToolCall } from "../CompleteGoalToolCall"; // eslint-disable-next-line @typescript-eslint/no-explicit-any type AnyToolComponent = ComponentType; -interface ToolRegistryEntry { - component: AnyToolComponent; - schema: ZodSchema; -} +/** Component bindings stay separate because UI components are browser-only. */ +const TOOL_REGISTRY: Record = { + bash: BashToolCall, + file_read: FileReadToolCall, + memory: MemoryToolCall, + attach_file: AttachFileToolCall, + desktop_screenshot: DesktopScreenshotToolCall, + desktop_move_mouse: DesktopActionToolCall, + desktop_click: DesktopActionToolCall, + desktop_double_click: DesktopActionToolCall, + desktop_drag: DesktopActionToolCall, + desktop_scroll: DesktopActionToolCall, + desktop_type: DesktopActionToolCall, + desktop_key_press: DesktopActionToolCall, + agent_skill_read: AgentSkillReadToolCall, + agent_skill_read_file: AgentSkillReadFileToolCall, + agent_skill_list: AgentSkillListToolCall, + file_edit_replace_string: FileEditToolCall, + file_edit_replace_lines: FileEditToolCall, + file_edit_insert: FileEditToolCall, + ask_user_question: AskUserQuestionToolCall, + propose_plan: ProposePlanToolCall, + todo_write: TodoToolCall, + status_set: StatusSetToolCall, + notify: NotifyToolCall, + tool_catalog_search: ToolSearchToolCall, + tool_search: ToolSearchToolCall, + analytics_query: AnalyticsQueryToolCall, + advisor: AdvisorToolCall, + web_fetch: WebFetchToolCall, + bash_background_list: BashBackgroundListToolCall, + bash_background_terminate: BashBackgroundTerminateToolCall, + bash_output: BashOutputToolCall, + code_execution: CodeExecutionToolCall, + task: TaskToolCall, + task_await: TaskAwaitToolCall, + task_list: TaskListToolCall, + task_send_message: TaskSendMessageToolCall, + task_retitle: TaskRetitleToolCall, + task_stop: TaskStopToolCall, + task_remove: TaskRemoveToolCall, + task_terminate: TaskTerminateToolCall, + task_apply_git_patch: TaskApplyGitPatchToolCall, + task_workspace_lifecycle: WorkspaceLifecycleToolCall, + workflow_run: WorkflowRunToolCall, + workflow_resume: WorkflowResumeToolCall, + agent_report: AgentReportToolCall, + set_goal: SetGoalToolCall, + get_goal: GetGoalToolCall, + complete_goal: CompleteGoalToolCall, + heartbeat: HeartbeatToolCall, + timeline_event: TimelineEventToolCall, + review_pane_update: ReviewPaneUpdateToolCall, + review_pane_get: ReviewPaneGetToolCall, + web_search: WebSearchToolCall, + "server:GOOGLE_SEARCH_WEB": GoogleSearchToolCall, +}; -/** - * Registry mapping tool names to their components and validation schemas. - * Adding a new tool: add one line here. - * - * Note: Some tools (ask_user_question, propose_plan, todo_write) require - * props like workspaceId/toolCallId that aren't available in nested context. This is - * fine because the backend excludes these from code_execution sandbox (see EXCLUDED_TOOLS - * in src/node/services/ptc/toolBridge.ts). They can never appear in nested tool calls. - */ const legacyStatusSetSchema = z.object({ emoji: z.string(), message: z.string(), @@ -97,177 +141,19 @@ const legacyAgentReportFileArgsSchema = z }) .strict(); -const agentReportRenderSchema = z.union([ - TOOL_DEFINITIONS.agent_report.schema, - legacyAgentReportFileArgsSchema, -]); - -const TOOL_REGISTRY: Record = { - bash: { component: BashToolCall, schema: TOOL_DEFINITIONS.bash.schema }, - file_read: { component: FileReadToolCall, schema: TOOL_DEFINITIONS.file_read.schema }, - memory: { component: MemoryToolCall, schema: TOOL_DEFINITIONS.memory.schema }, - attach_file: { component: AttachFileToolCall, schema: TOOL_DEFINITIONS.attach_file.schema }, - desktop_screenshot: { - component: DesktopScreenshotToolCall, - schema: TOOL_DEFINITIONS.desktop_screenshot.schema, - }, - desktop_move_mouse: { - component: DesktopActionToolCall, - schema: TOOL_DEFINITIONS.desktop_move_mouse.schema, - }, - desktop_click: { - component: DesktopActionToolCall, - schema: TOOL_DEFINITIONS.desktop_click.schema, - }, - desktop_double_click: { - component: DesktopActionToolCall, - schema: TOOL_DEFINITIONS.desktop_double_click.schema, - }, - desktop_drag: { component: DesktopActionToolCall, schema: TOOL_DEFINITIONS.desktop_drag.schema }, - desktop_scroll: { - component: DesktopActionToolCall, - schema: TOOL_DEFINITIONS.desktop_scroll.schema, - }, - desktop_type: { component: DesktopActionToolCall, schema: TOOL_DEFINITIONS.desktop_type.schema }, - desktop_key_press: { - component: DesktopActionToolCall, - schema: TOOL_DEFINITIONS.desktop_key_press.schema, - }, - agent_skill_read: { - component: AgentSkillReadToolCall, - schema: TOOL_DEFINITIONS.agent_skill_read.schema, - }, - agent_skill_read_file: { - component: AgentSkillReadFileToolCall, - schema: TOOL_DEFINITIONS.agent_skill_read_file.schema, - }, - agent_skill_list: { - component: AgentSkillListToolCall, - schema: TOOL_DEFINITIONS.agent_skill_list.schema, - }, - file_edit_replace_string: { - component: FileEditToolCall, - schema: TOOL_DEFINITIONS.file_edit_replace_string.schema, - }, - file_edit_replace_lines: { - component: FileEditToolCall, - schema: TOOL_DEFINITIONS.file_edit_replace_lines.schema, - }, - file_edit_insert: { - component: FileEditToolCall, - schema: TOOL_DEFINITIONS.file_edit_insert.schema, - }, - ask_user_question: { - component: AskUserQuestionToolCall, - schema: TOOL_DEFINITIONS.ask_user_question.schema, - }, - propose_plan: { - component: ProposePlanToolCall, - schema: TOOL_DEFINITIONS.propose_plan.schema, - }, - todo_write: { component: TodoToolCall, schema: TOOL_DEFINITIONS.todo_write.schema }, - // Legacy-only transcript renderer for historical status_set calls. - status_set: { component: StatusSetToolCall, schema: legacyStatusSetSchema }, - notify: { component: NotifyToolCall, schema: TOOL_DEFINITIONS.notify.schema }, - tool_catalog_search: { - component: ToolSearchToolCall, - schema: TOOL_DEFINITIONS.tool_catalog_search.schema, - }, - // Legacy-only transcript renderer from before AI SDK 7 reserved tool_search. - tool_search: { - component: ToolSearchToolCall, - schema: TOOL_DEFINITIONS.tool_catalog_search.schema, - }, - analytics_query: { - component: AnalyticsQueryToolCall, - schema: TOOL_DEFINITIONS.analytics_query.schema, - }, - advisor: { component: AdvisorToolCall, schema: TOOL_DEFINITIONS.advisor.schema }, - web_fetch: { component: WebFetchToolCall, schema: TOOL_DEFINITIONS.web_fetch.schema }, - bash_background_list: { - component: BashBackgroundListToolCall, - schema: TOOL_DEFINITIONS.bash_background_list.schema, - }, - bash_background_terminate: { - component: BashBackgroundTerminateToolCall, - schema: TOOL_DEFINITIONS.bash_background_terminate.schema, - }, - bash_output: { component: BashOutputToolCall, schema: TOOL_DEFINITIONS.bash_output.schema }, - code_execution: { - component: CodeExecutionToolCall, - schema: TOOL_DEFINITIONS.code_execution.schema, - }, - task: { component: TaskToolCall, schema: TOOL_DEFINITIONS.task.schema }, - task_await: { component: TaskAwaitToolCall, schema: TOOL_DEFINITIONS.task_await.schema }, - task_list: { component: TaskListToolCall, schema: TOOL_DEFINITIONS.task_list.schema }, - task_send_message: { - component: TaskSendMessageToolCall, - schema: TOOL_DEFINITIONS.task_send_message.schema, - }, - task_retitle: { - component: TaskRetitleToolCall, - schema: TOOL_DEFINITIONS.task_retitle.schema, - }, - task_stop: { - component: TaskStopToolCall, - schema: TOOL_DEFINITIONS.task_stop.schema, - }, - task_remove: { - component: TaskRemoveToolCall, - schema: TOOL_DEFINITIONS.task_remove.schema, - }, - task_terminate: { - component: TaskTerminateToolCall, - schema: TaskTerminateToolArgsSchema, - }, - task_apply_git_patch: { - component: TaskApplyGitPatchToolCall, - schema: TOOL_DEFINITIONS.task_apply_git_patch.schema, - }, - task_workspace_lifecycle: { - component: WorkspaceLifecycleToolCall, - schema: TaskWorkspaceLifecycleToolArgsSchema, - }, - workflow_run: { - component: WorkflowRunToolCall, - schema: TOOL_DEFINITIONS.workflow_run.schema, - }, - workflow_resume: { - component: WorkflowResumeToolCall, - schema: TOOL_DEFINITIONS.workflow_resume.schema, - }, - agent_report: { - component: AgentReportToolCall, - schema: agentReportRenderSchema, - }, - set_goal: { component: SetGoalToolCall, schema: TOOL_DEFINITIONS.set_goal.schema }, - get_goal: { component: GetGoalToolCall, schema: TOOL_DEFINITIONS.get_goal.schema }, - complete_goal: { - component: CompleteGoalToolCall, - schema: TOOL_DEFINITIONS.complete_goal.schema, - }, - heartbeat: { component: HeartbeatToolCall, schema: TOOL_DEFINITIONS.heartbeat.schema }, - timeline_event: { - component: TimelineEventToolCall, - schema: TOOL_DEFINITIONS.timeline_event.schema, - }, - review_pane_update: { - component: ReviewPaneUpdateToolCall, - schema: TOOL_DEFINITIONS.review_pane_update.schema, - }, - review_pane_get: { - component: ReviewPaneGetToolCall, - schema: TOOL_DEFINITIONS.review_pane_get.schema, - }, - // Provider-defined tool (Anthropic/OpenAI) - no TOOL_DEFINITIONS entry - // Anthropic: args.query, OpenAI: args={}, query in result.action.query - web_search: { component: WebSearchToolCall, schema: z.object({ query: z.string().optional() }) }, - // Google native search grounding (Gemini 3+), provider-executed — name comes from the wire. - // queries stays optional so streaming/pending args don't bounce to GenericToolCall. - "server:GOOGLE_SEARCH_WEB": { - component: GoogleSearchToolCall, - schema: z.object({ queries: z.array(z.string()).optional() }), - }, +const TOOL_SCHEMA_OVERRIDES: Record = { + // Legacy file-backed reports remain renderable from persisted transcripts. + agent_report: z.union([TOOL_DEFINITIONS.agent_report.schema, legacyAgentReportFileArgsSchema]), + // status_set is a removed dynamic tool that still appears in history. + status_set: legacyStatusSetSchema, + // tool_search is the historical wire name for tool_catalog_search. + tool_search: TOOL_DEFINITIONS.tool_catalog_search.schema, + // task_terminate is retained only for historical task transcripts. + task_terminate: TaskTerminateToolArgsSchema, + // Provider-executed web search tools have no catalog definition. + web_search: z.object({ query: z.string().optional() }), + // Pending Google search arguments can arrive before queries are parsed. + "server:GOOGLE_SEARCH_WEB": z.object({ queries: z.array(z.string()).optional() }), }; /** @@ -279,9 +165,12 @@ export function getToolComponent(toolName: string, args: unknown): AnyToolCompon // A bare index lookup returns truthy inherited members for names like "constructor", // which would then throw on .schema and brick the workspace view instead of degrading // to the generic renderer (self-healing invariant). - const entry = Object.hasOwn(TOOL_REGISTRY, toolName) ? TOOL_REGISTRY[toolName] : undefined; - if (!entry?.schema.safeParse(args).success) { - return GenericToolCall; - } - return entry.component; + const component = Object.hasOwn(TOOL_REGISTRY, toolName) ? TOOL_REGISTRY[toolName] : undefined; + const schema = Object.hasOwn(TOOL_SCHEMA_OVERRIDES, toolName) + ? TOOL_SCHEMA_OVERRIDES[toolName] + : Object.hasOwn(TOOL_DEFINITIONS, toolName) + ? TOOL_DEFINITIONS[toolName as ToolName].schema + : undefined; + if (!component || !schema?.safeParse(args).success) return GenericToolCall; + return component; } diff --git a/src/cli/toolFormatters.ts b/src/cli/toolFormatters.ts index 2c3606d921b..721d5c1448a 100644 --- a/src/cli/toolFormatters.ts +++ b/src/cli/toolFormatters.ts @@ -27,17 +27,6 @@ import type { type ToolStartFormatter = (toolName: string, args: unknown) => string | null; type ToolEndFormatter = (toolName: string, args: unknown, result: unknown) => string | null; -/** Tools that should have their result on a new line (multi-line results) */ -const MULTILINE_RESULT_TOOLS = new Set([ - "file_edit_replace_string", - "file_edit_replace_lines", - "file_edit_insert", - "bash", - "task", - "task_await", - "code_execution", -]); - // ============================================================================ // Utilities // ============================================================================ @@ -421,42 +410,41 @@ function formatSimpleSuccessEnd(_toolName: string, _args: unknown, result: unkno // Registry and Public API // ============================================================================ -const startFormatters: Record = { - file_edit_replace_string: formatFileEditStart, - file_edit_replace_lines: formatFileEditStart, - file_edit_insert: formatFileEditStart, - file_read: formatFileReadStart, - bash: formatBashStart, - task: formatTaskStart, - web_fetch: formatWebFetchStart, - web_search: formatWebSearchStart, - todo_write: formatTodoStart, - notify: formatNotifyStart, - status_set: formatStatusSetStart, - set_exit_code: formatSetExitCodeStart, - agent_skill_read: formatAgentSkillReadStart, - agent_skill_read_file: formatAgentSkillReadStart, - code_execution: formatCodeExecutionStart, -}; - -const endFormatters: Record = { - file_edit_replace_string: formatFileEditEnd, - file_edit_replace_lines: formatFileEditEnd, - file_edit_insert: formatFileEditEnd, - file_read: formatFileReadEnd, - bash: formatBashEnd, - task: formatTaskEnd, - task_await: formatTaskEnd, - web_fetch: formatWebFetchEnd, - code_execution: formatCodeExecutionEnd, - // Inline tools with simple success markers (prevents generic fallback) - web_search: formatSimpleSuccessEnd, - todo_write: formatSimpleSuccessEnd, - notify: formatSimpleSuccessEnd, - status_set: formatSimpleSuccessEnd, - set_exit_code: formatSimpleSuccessEnd, - agent_skill_read: formatSimpleSuccessEnd, - agent_skill_read_file: formatSimpleSuccessEnd, +interface ToolFormatterBinding { + start?: ToolStartFormatter; + end?: ToolEndFormatter; + multilineResult?: true; +} + +const toolFormatters: Record = { + file_edit_replace_string: { + start: formatFileEditStart, + end: formatFileEditEnd, + multilineResult: true, + }, + file_edit_replace_lines: { + start: formatFileEditStart, + end: formatFileEditEnd, + multilineResult: true, + }, + file_edit_insert: { start: formatFileEditStart, end: formatFileEditEnd, multilineResult: true }, + file_read: { start: formatFileReadStart, end: formatFileReadEnd }, + bash: { start: formatBashStart, end: formatBashEnd, multilineResult: true }, + task: { start: formatTaskStart, end: formatTaskEnd, multilineResult: true }, + task_await: { end: formatTaskEnd, multilineResult: true }, + web_fetch: { start: formatWebFetchStart, end: formatWebFetchEnd }, + web_search: { start: formatWebSearchStart, end: formatSimpleSuccessEnd }, + todo_write: { start: formatTodoStart, end: formatSimpleSuccessEnd }, + notify: { start: formatNotifyStart, end: formatSimpleSuccessEnd }, + status_set: { start: formatStatusSetStart, end: formatSimpleSuccessEnd }, + set_exit_code: { start: formatSetExitCodeStart, end: formatSimpleSuccessEnd }, + agent_skill_read: { start: formatAgentSkillReadStart, end: formatSimpleSuccessEnd }, + agent_skill_read_file: { start: formatAgentSkillReadStart, end: formatSimpleSuccessEnd }, + code_execution: { + start: formatCodeExecutionStart, + end: formatCodeExecutionEnd, + multilineResult: true, + }, }; /** @@ -464,7 +452,7 @@ const endFormatters: Record = { * Returns formatted string, or null to use generic fallback. */ export function formatToolStart(payload: ToolCallStartEvent): string | null { - const formatter = startFormatters[payload.toolName]; + const formatter = toolFormatters[payload.toolName]?.start; if (!formatter) return null; try { @@ -479,7 +467,7 @@ export function formatToolStart(payload: ToolCallStartEvent): string | null { * Returns formatted string, or null to use generic fallback. */ export function formatToolEnd(payload: ToolCallEndEvent, startArgs?: unknown): string | null { - const formatter = endFormatters[payload.toolName]; + const formatter = toolFormatters[payload.toolName]?.end; if (!formatter) return null; try { @@ -519,5 +507,5 @@ export function formatGenericToolEnd(payload: ToolCallEndEvent): string { * For single-line results (file_read, web_fetch, etc.), result appears inline. */ export function isMultilineResultTool(toolName: string): boolean { - return MULTILINE_RESULT_TOOLS.has(toolName); + return toolFormatters[toolName]?.multilineResult === true; } From ef30fa29f54f3a192a13cf8e2e153d709154dd43 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:10:35 +0000 Subject: [PATCH 15/20] fix(tools): preserve historical lifecycle rendering --- .../features/Tools/Shared/getToolComponent.test.ts | 11 +++++++++++ src/browser/features/Tools/Shared/getToolComponent.ts | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/browser/features/Tools/Shared/getToolComponent.test.ts b/src/browser/features/Tools/Shared/getToolComponent.test.ts index 755751025c9..2cc14ab1501 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.test.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.test.ts @@ -4,6 +4,7 @@ import { AgentReportToolCall } from "../AgentReportToolCall"; import { GenericToolCall } from "../GenericToolCall"; import { GoogleSearchToolCall } from "../GoogleSearchToolCall"; import { ToolSearchToolCall } from "../ToolSearchToolCall"; +import { WorkspaceLifecycleToolCall } from "../WorkspaceLifecycleToolCall"; import { getToolComponent } from "./getToolComponent"; describe("getToolComponent", () => { @@ -23,6 +24,16 @@ describe("getToolComponent", () => { expect(getToolComponent("agent_report", {})).toBe(AgentReportToolCall); }); + test("renders historical workspace lifecycle actions", () => { + expect( + getToolComponent("task_workspace_lifecycle", { + action: "remove", + targets: [{ workspaceId: "workspace-id" }], + force: true, + }) + ).toBe(WorkspaceLifecycleToolCall); + }); + test("falls back when catalog schema validation fails", () => { expect(getToolComponent("agent_skill_list", { includeUnadvertised: "yes" })).toBe( GenericToolCall diff --git a/src/browser/features/Tools/Shared/getToolComponent.ts b/src/browser/features/Tools/Shared/getToolComponent.ts index 6404c70bdd8..cc0b8129bf4 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.ts @@ -8,6 +8,7 @@ import type { ComponentType } from "react"; import { z, type ZodSchema } from "zod"; import { TaskTerminateToolArgsSchema, + TaskWorkspaceLifecycleToolArgsSchema, TOOL_DEFINITIONS, type ToolName, } from "@/common/utils/tools/toolDefinitions"; @@ -150,6 +151,8 @@ const TOOL_SCHEMA_OVERRIDES: Record = { tool_search: TOOL_DEFINITIONS.tool_catalog_search.schema, // task_terminate is retained only for historical task transcripts. task_terminate: TaskTerminateToolArgsSchema, + // Historical lifecycle transcripts include actions removed from the live input schema. + task_workspace_lifecycle: TaskWorkspaceLifecycleToolArgsSchema, // Provider-executed web search tools have no catalog definition. web_search: z.object({ query: z.string().optional() }), // Pending Google search arguments can arrive before queries are parsed. From 3279377c55af2ef4ee230c385f62fccb1658d498 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:02:12 +0000 Subject: [PATCH 16/20] refactor(services): cut task workspace cycle --- src/node/services/coreServices.ts | 2 +- src/node/services/heartbeatService.test.ts | 9 +- src/node/services/taskService.test.ts | 14 +- src/node/services/taskService.ts | 38 +-- .../services/taskWorkspaceSeam.testUtils.ts | 26 ++ src/node/services/taskWorkspaceSeam.ts | 233 ++++++++++++++++ src/node/services/workspaceService.test.ts | 262 +++++++++++------- src/node/services/workspaceService.ts | 136 ++++----- 8 files changed, 509 insertions(+), 211 deletions(-) create mode 100644 src/node/services/taskWorkspaceSeam.testUtils.ts create mode 100644 src/node/services/taskWorkspaceSeam.ts diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 270d57e0cc1..dcf4dbc4a02 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -289,7 +289,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { workspaceGoalService ); aiService.setTaskService(taskService); - workspaceService.setTaskService(taskService); + workspaceService.setAgentTaskIntegration(taskService); // Goal continuation bridge lives at the core scope so every codepath that // uses createCoreServices (xum run, xum server via ServiceContainer, tests) diff --git a/src/node/services/heartbeatService.test.ts b/src/node/services/heartbeatService.test.ts index 0aed21ebcc8..39b02c0dd2d 100644 --- a/src/node/services/heartbeatService.test.ts +++ b/src/node/services/heartbeatService.test.ts @@ -20,6 +20,7 @@ import { advanceAnchoredDeadline, HeartbeatService } from "./heartbeatService"; import type { HistoryService } from "./historyService"; import type { InitStateManager } from "./initStateManager"; import type { TaskService } from "./taskService"; +import { makeAgentTaskIntegrationFake } from "./taskWorkspaceSeam.testUtils"; import { WorkspaceService } from "./workspaceService"; async function waitForCondition( @@ -1251,9 +1252,11 @@ describe("HeartbeatService", () => { getOrCreateSession: mock(() => params.session), sendMessage: sendMessageMock, }); - workspaceService.setTaskService({ - hasActiveDescendantAgentTasksForWorkspace: () => params.hasActiveDescendantTasks ?? false, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + hasActiveDescendantAgentTasksForWorkspace: () => params.hasActiveDescendantTasks ?? false, + }) + ); return { workspaceService, sendMessageMock, diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index a0e7c5392d9..dfbcf45287f 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -82,7 +82,7 @@ import { import type { WorkspaceMetadata } from "@/common/types/workspace"; import type { ProvidersConfigMap, WorkspaceChatMessage } from "@/common/orpc/types"; import type { AIService } from "@/node/services/aiService"; -import type { WorkspaceService } from "@/node/services/workspaceService"; +import type { WorkspaceHost } from "@/node/services/taskWorkspaceSeam"; import type { InitStateManager } from "@/node/services/initStateManager"; import { InitStateManager as RealInitStateManager } from "@/node/services/initStateManager"; import assert from "node:assert"; @@ -559,7 +559,7 @@ function createWorkspaceServiceMocks( countQueuedAgentPeerMessages: ReturnType; }> ): { - workspaceService: WorkspaceService; + workspaceService: WorkspaceHost; sendMessage: ReturnType; resumeStream: ReturnType; clearQueue: ReturnType; @@ -706,14 +706,12 @@ function createWorkspaceServiceMocks( getQueueCutCutter, hasPendingAutoRetry, waitForIdleAndNoQueuedMessages, - waitForIdle, waitForPendingCompactionCompletionDecision, waitForPendingStreamErrorRecoveryDecision, archive, // Same mocks: the lifecycle path holds the (real) task-tree lock and calls the // WhileTaskTreeLocked sinks; assertions target one archive/unarchive surface. archiveWhileTaskTreeLocked: archive, - unarchive, unarchiveWhileTaskTreeLocked: unarchive, preflightArchive, listLiveWorkspaceActivity, @@ -724,19 +722,17 @@ function createWorkspaceServiceMocks( // Task launches register their fire-and-forget background inits for archive gating; // a no-op suffices since these tests archive nothing mid-init. registerExternalBackgroundInit: mock(() => undefined), - deleteWorktree, removeWhileTaskTreeLocked: remove, remove, emit, getInfo, replaceHistory, updateTitle, - updateAgentStatus, isExperimentEnabled, emitChatEvent, isWorkflowInvocationCurrent, countQueuedAgentPeerMessages, - } as unknown as WorkspaceService, + } satisfies WorkspaceHost, create, discardExtensionMetadataEntry, sendMessage, @@ -779,7 +775,7 @@ function createTaskServiceHarness( config: Config, overrides?: { aiService?: AIService; - workspaceService?: WorkspaceService; + workspaceService?: WorkspaceHost; initStateManager?: InitStateManager; sessionUsageService?: SessionUsageService; workspaceGoalService?: WorkspaceGoalService; @@ -789,7 +785,7 @@ function createTaskServiceHarness( partialService: HistoryService; taskService: TaskService; aiService: AIService; - workspaceService: WorkspaceService; + workspaceService: WorkspaceHost; initStateManager: InitStateManager; } { const historyService = new HistoryService(config); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 4edddac2c72..6e1d72bf49a 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -17,9 +17,13 @@ import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; import type { Config, ProjectsConfig, Workspace as WorkspaceConfigEntry } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; -import type { WorkspaceService } from "@/node/services/workspaceService"; import type { QueueCutCutter } from "@/node/services/messageQueue"; -import { areArchiveUntrackedPathListsEqual } from "@/node/services/workspaceService"; +import { + areArchiveUntrackedPathListsEqual, + type AgentTaskIntegration, + type AgentTaskStatus, + type WorkspaceHost, +} from "@/node/services/taskWorkspaceSeam"; import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; import { STRUCTURED_WORKFLOW_REPORT_PLACEHOLDER_MARKDOWN } from "@/common/constants/workflowReports"; @@ -226,7 +230,7 @@ export class AgentReportWaitTimeoutError extends Error { } } -export type AgentTaskStatus = NonNullable; +export type { AgentTaskStatus } from "@/node/services/taskWorkspaceSeam"; /** * Resolved per-agent AI settings (canonical model + optional thinking level). @@ -1601,7 +1605,7 @@ function buildWorkflowTimeoutFinalizationPrompt( return `${base}\n\nAdditional workflow-specific finalization instructions:\n${finalInstructions}`; } -export class TaskService { +export class TaskService implements AgentTaskIntegration { // Serialize stream-end processing per workspace to avoid races when // finalizing reported tasks and cleanup state transitions. private readonly workspaceEventLocks = new MutexMap(); @@ -1706,7 +1710,7 @@ export class TaskService { private readonly familyMessageTargetTotals = new Map(); // Task workspace removals that outlived their termination timeout. Retries must - // await the ORIGINAL removal outcome: WorkspaceService.remove() short-circuits Ok + // await the ORIGINAL removal outcome: the host's remove() short-circuits Ok // for IDs already being removed, so re-calling it would count a still-in-flight // (possibly failing) removal as success and let ancestor deletion orphan the child. private readonly pendingTaskWorkspaceRemovals = new Map>>(); @@ -2239,7 +2243,7 @@ export class TaskService { private readonly config: Config, private readonly historyService: HistoryService, private readonly aiService: AIService, - private readonly workspaceService: WorkspaceService, + private readonly workspaceService: WorkspaceHost, private readonly initStateManager: InitStateManager, private readonly sessionUsageService?: SessionUsageService, private readonly workspaceGoalService?: WorkspaceGoalService @@ -3956,7 +3960,7 @@ export class TaskService { // registered, so creation-time plugin-override sanitization never saw // this checkout — a tracked stale `plugin:` enable would re-activate a // same-name reinstall's default-disabled MCP server on the first send. - // Same contract as WorkspaceService.create/fork: sanitize or fail. + // Same contract as the host's create/fork paths: sanitize or fail. const sanitizeError = await this.workspaceService.sanitizeMaterializedTaskWorkspace( plan.taskId, workspacePath, @@ -3988,7 +3992,7 @@ export class TaskService { const secrets = await secretsToRecord( this.config.getEffectiveSecrets(plan.parentMeta.projectPath) ); - // Registered (not just fired) with WorkspaceService's abort-and-settlement mechanism: + // Registered (not just fired) with the host's abort-and-settlement mechanism: // a model-driven archive of this task workspace must be able to cancel the init and // must wait for the hook process's actual exit before snapshot capture, checkout // deletion, or Coder hooks can proceed (see initSettlementPromises). @@ -4336,7 +4340,7 @@ export class TaskService { } const taskProjectConfig = cfg.projects.get(stripTrailingSlashes(parentMeta.projectPath)); if ((parentMeta.projects?.length ?? 0) > 1) { - // WorkspaceService.create only materializes one project checkout; fail loudly instead of + // The host's create() only materializes one project checkout; fail loudly instead of // silently dropping secondary repos from a multi-project caller's task context. return Err("Task.createWorkspaceTurn: multi-project workspace turns are not supported yet"); } @@ -5550,8 +5554,8 @@ export class TaskService { }); if (!useSharedWorkspace) { - // SECURITY: this checkout materialized outside WorkspaceService.create/ - // fork, so registration-time plugin-override sanitization never saw it — + // SECURITY: this checkout materialized outside the host's create/fork paths, so + // registration-time plugin-override sanitization never saw it — // a tracked stale `plugin:` enable would re-activate a same-name // reinstall's default-disabled MCP server on the send below. Runs // BEFORE emitWorkspaceMetadata (the pre-announcement invariant of @@ -5585,7 +5589,7 @@ export class TaskService { const secrets = await secretsToRecord( this.config.getEffectiveSecrets(parentMeta.projectPath) ); - // Registered (not just fired) with WorkspaceService's abort-and-settlement mechanism: + // Registered (not just fired) with the host's abort-and-settlement mechanism: // a model-driven archive of this task workspace must be able to cancel the init and // must wait for the hook process's actual exit before snapshot capture, checkout // deletion, or Coder hooks can proceed (see initSettlementPromises). @@ -6468,9 +6472,9 @@ export class TaskService { // Admission staleness probe: neither interruptStream nor stopDescendantAgentTask takes // this target's event lock, so a user Stop or task_stop can land during ANY await between - // here and the real admission — including WorkspaceService.sendMessage's own pricing/ - // settings awaits and the session's turn preparation. The probe is synchronous and - // re-evaluated by WorkspaceService at the enqueue block and the session's turn-admission + // here and the real admission — including the host's sendMessage() pricing/settings + // awaits and the session's turn preparation. The probe is synchronous and + // re-evaluated by the host at the enqueue block and the session's turn-admission // gates, so a stop in those windows refuses the send instead of queueing a wake or // resurrecting the stopped task via markInterruptedTaskRunning. let admissionRefusal: SendAgentTreeMessageError | null = null; @@ -6675,7 +6679,7 @@ export class TaskService { }; } - // Optional chaining: test harnesses mock WorkspaceService with a narrow method surface. + // Optional chaining: test harnesses mock the host port with a narrow method surface. const queuedCount = this.workspaceService.countQueuedAgentPeerMessages?.(targetId) ?? 0; if (queuedCount >= MAX_QUEUED_PEER_MESSAGES_PER_TARGET) { return { @@ -7129,7 +7133,7 @@ export class TaskService { } /** - * Archive task workspaces deepest-first (so WorkspaceService.archive preconditions on + * Archive task workspaces deepest-first (so the host's archive preconditions on * descendants hold), logging and continuing on per-task failures — one failed archive * must not abort the sweep; failures self-heal on the next startup sweep. */ diff --git a/src/node/services/taskWorkspaceSeam.testUtils.ts b/src/node/services/taskWorkspaceSeam.testUtils.ts new file mode 100644 index 00000000000..95044cf6076 --- /dev/null +++ b/src/node/services/taskWorkspaceSeam.testUtils.ts @@ -0,0 +1,26 @@ +import type { AgentTaskIntegration } from "@/node/services/taskWorkspaceSeam"; + +interface AgentTaskIntegrationTestOverrides extends Partial { + cleanupReportedDescendantsAfterArchive?: () => Promise; +} + +export function makeAgentTaskIntegrationFake( + overrides: AgentTaskIntegrationTestOverrides = {} +): AgentTaskIntegration { + return { + withTaskTreeLifecycleLock: (_workspaceId: string, operation: () => Promise): Promise => + operation(), + hasDescendantAgentTasks: () => false, + hasActiveDescendantAgentTasksForWorkspace: () => false, + hasActiveTopLevelWorkflowRunsForWorkspace: () => Promise.resolve(false), + getAgentTaskStatus: () => undefined, + resetAutoResumeCount: () => undefined, + backgroundForegroundWaitsForWorkspace: () => 0, + markInterruptedTaskRunning: () => Promise.resolve(false), + restoreInterruptedTaskAfterResumeFailure: () => Promise.resolve(), + markParentWorkspaceInterrupted: () => undefined, + latchHardInterruptCascade: () => undefined, + terminateAllDescendantAgentTasks: () => Promise.resolve([]), + ...overrides, + }; +} diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts new file mode 100644 index 00000000000..c1c92eba7bd --- /dev/null +++ b/src/node/services/taskWorkspaceSeam.ts @@ -0,0 +1,233 @@ +import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; +import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; +import type { ExperimentId } from "@/common/constants/experiments"; +import type { GoalSyntheticMessageKind } from "@/constants/goals"; +import type { ArchivePreflightResult, ArchiveWorkspaceResult } from "@/common/orpc/schemas/api"; +import type { FilePart, SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/types"; +import type { SendMessageError } from "@/common/types/errors"; +import type { + MuxMessage, + MuxMessageMetadata, + WorkspaceTurnTaskCorrelation, +} from "@/common/types/message"; +import type { Result } from "@/common/types/result"; +import type { RuntimeConfig } from "@/common/types/runtime"; +import type { FrontendWorkspaceMetadata, WorkspaceMetadata } from "@/common/types/workspace"; +import assert from "@/common/utils/assert"; +import type { Config, Workspace as WorkspaceConfigEntry } from "@/node/config"; +import type { QueueCutCutter } from "@/node/services/messageQueue"; + +/** + * One-directional service ports keep task and workspace orchestration from depending on each + * other's concrete class. Host call placement intentionally stays at the race-hardened sinks and + * admission gates; this seam is the leverage point for any later control-flow inversion. + */ + +export type AgentTaskStatus = NonNullable; + +type StreamErrorRecoveryOutcome = "retry-started" | "terminal"; + +interface WorkspaceHostArchiveOptions { + forbidWorktreeCheckoutDeletion?: boolean; + refuseLiveUserActivity?: boolean; + worktreeArchiveBehaviorOverride?: WorktreeArchiveBehavior; + forbidCoderWorkspaceDeletion?: boolean; + coderWorkspaceArchiveBehaviorOverride?: CoderWorkspaceArchiveBehavior; +} + +interface WorkspaceHostLiveActivity { + streaming: boolean; + queuedMessages: boolean; + backgroundBashProcesses: boolean; + terminalSessions: boolean; + desktopSession: boolean; +} + +interface WorkspaceHostSendInternalOptions { + allowQueuedAgentTask?: boolean; + skipAutoResumeReset?: boolean; + synthetic?: boolean; + goalContinuation?: boolean; + goalKind?: GoalSyntheticMessageKind; + goalId?: string; + agentInitiated?: boolean; + onAccepted?: () => Promise | void; + onCanceled?: (reason: string) => Promise | void; + onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise | void; + cancelState?: { canceledBeforeAcceptance: boolean }; + cancelSignal?: AbortSignal; + admissionStale?: () => boolean; + preTurnMessages?: MuxMessage[]; + onPreTurnRowsPersisted?: () => void; + startStreamInBackground?: boolean; + requireIdle?: boolean; + workspaceTurnContinuation?: boolean; + queueDedupeKey?: string; + removableQueueDedupeKey?: boolean; + yieldToQueuedMessages?: boolean; +} + +export interface WorkspaceHost { + acquirePreInterruptionArchiveHold( + workspaceId: string, + options: { + queuedDelegatedTurnCount: number; + expectedDelegatedTurnCorrelations: readonly WorkspaceTurnTaskCorrelation[]; + } + ): Result; + archive( + workspaceId: string, + acknowledgedUntrackedPaths?: string[], + options?: WorkspaceHostArchiveOptions + ): Promise>; + archiveWhileTaskTreeLocked( + workspaceId: string, + acknowledgedUntrackedPaths?: string[], + options?: WorkspaceHostArchiveOptions + ): Promise>; + clearQueue(workspaceId: string, options?: { cancelReason?: string }): Result; + countQueuedAgentPeerMessages(workspaceId: string): number; + create( + projectPath: string, + branchName: string | undefined, + trunkBranch: string | undefined, + title?: string, + runtimeConfig?: RuntimeConfig, + subProjectPath?: string, + pendingAutoTitle?: boolean, + tags?: Record + ): Promise>; + discardExtensionMetadataEntry(workspaceId: string): Promise; + emit( + event: "metadata", + payload: { workspaceId: string; metadata: FrontendWorkspaceMetadata | null } + ): boolean; + emit(event: "chat", payload: { workspaceId: string; message: WorkspaceChatMessage }): boolean; + emitChatEvent(workspaceId: string, message: WorkspaceChatMessage): void; + getInfo(workspaceId: string): Promise; + getQueueCutCutter(workspaceId: string): QueueCutCutter | undefined; + hasPendingAutoRetry(workspaceId: string): boolean; + hasPendingBashMonitorWakeContinuation(workspaceId: string): boolean; + hasPendingQueuedOrPreparingTurn(workspaceId: string): boolean; + hasPendingWorkspaceTurnContinuation( + workspaceId: string, + metadata: Extract + ): boolean; + hasQueuedMessages(workspaceId: string, dispatchMode?: "tool-end" | "turn-end"): boolean; + hasQueuedWorkspaceTurn(workspaceId: string, handleId: string): boolean; + hasRunningBackgroundBashProcesses(workspaceId: string): Promise; + hasUntrackableExternalAppOpen(workspaceId: string): Promise; + isBusyForMessage(workspaceId: string): boolean; + isExperimentEnabled(experimentId: ExperimentId): boolean; + isSnapshotArchiveEligibilityMutationSensitive( + workspaceId: string, + worktreeArchiveBehavior?: WorktreeArchiveBehavior, + metadata?: WorkspaceMetadata + ): boolean; + isWorkflowInvocationCurrent(workspaceId: string, runId: string): Promise; + listLiveWorkspaceActivity(workspaceId: string): WorkspaceHostLiveActivity; + preflightArchive( + workspaceId: string, + options?: { worktreeArchiveBehaviorOverride?: WorktreeArchiveBehavior } + ): Promise>; + registerExternalBackgroundInit( + workspaceId: string, + abortController: AbortController, + settled: Promise + ): void; + remove(workspaceId: string, force?: boolean): Promise>; + removeQueuedMessagesByDedupeKeyPrefix( + workspaceId: string, + prefix: string, + options?: { cancelReason?: string } + ): Result; + removeQueuedWorkspaceTurn( + workspaceId: string, + handleId: string, + options: { cancelReason: string } + ): Result; + removeWhileTaskTreeLocked(workspaceId: string, force?: boolean): Promise>; + replaceHistory( + workspaceId: string, + summaryMessage: MuxMessage, + options?: { + mode?: "destructive" | "append-compaction-boundary" | null; + deletePlanFile?: boolean; + } + ): Promise>; + resumeStream( + workspaceId: string, + options: SendMessageOptions, + internal?: { allowQueuedAgentTask?: boolean; agentInitiated?: boolean } + ): Promise>; + sanitizeMaterializedTaskWorkspace( + workspaceId: string, + workspacePath: string, + runtimeConfig: RuntimeConfig | undefined, + persistentSiblingConfig?: Pick + ): Promise; + sendMessage( + workspaceId: string, + message: string, + options: SendMessageOptions & { fileParts?: FilePart[] }, + internal?: WorkspaceHostSendInternalOptions + ): Promise>; + unarchiveWhileTaskTreeLocked(workspaceId: string): Promise>; + updateTitle(workspaceId: string, title: string): Promise>; + waitForIdleAndNoQueuedMessages(workspaceId: string): Promise; + waitForPendingCompactionCompletionDecision( + workspaceId: string, + messageId: string + ): Promise; + waitForPendingStreamErrorRecoveryDecision( + workspaceId: string, + messageId: string + ): Promise; +} + +export interface AgentTaskIntegration { + withTaskTreeLifecycleLock(workspaceId: string, operation: () => Promise): Promise; + hasDescendantAgentTasks(workspaceId: string): boolean; + hasActiveDescendantAgentTasksForWorkspace(workspaceId: string): boolean; + hasActiveTopLevelWorkflowRunsForWorkspace(workspaceId: string): Promise; + getAgentTaskStatus(workspaceId: string): AgentTaskStatus | null | undefined; + resetAutoResumeCount(workspaceId: string): void; + backgroundForegroundWaitsForWorkspace(workspaceId: string): number; + markInterruptedTaskRunning(workspaceId: string): Promise; + restoreInterruptedTaskAfterResumeFailure(workspaceId: string): Promise; + markParentWorkspaceInterrupted(workspaceId: string): void; + latchHardInterruptCascade(workspaceId: string): (() => void) | undefined; + terminateAllDescendantAgentTasks( + workspaceId: string, + options?: { workflowRunId?: string } + ): Promise; +} + +export function normalizeArchiveUntrackedPaths(paths: readonly string[]): string[] { + const normalizedPaths = paths.map((untrackedPath) => { + const trimmedPath = untrackedPath.trim(); + assert( + trimmedPath.length > 0, + "normalizeArchiveUntrackedPaths: untracked paths must be non-empty" + ); + return trimmedPath; + }); + return [...new Set(normalizedPaths)].sort(); +} + +// Shared so the task-side pre-interruption archive preflight applies the exact +// acknowledgement semantics enforced at the archive sink (getArchiveUntrackedFilesConfirmation): +// a drifted acknowledged set (extra OR missing paths) must re-confirm before any +// destructive interruption, not after. +export function areArchiveUntrackedPathListsEqual( + leftPaths: readonly string[], + rightPaths: readonly string[] +): boolean { + const normalizedLeftPaths = normalizeArchiveUntrackedPaths(leftPaths); + const normalizedRightPaths = normalizeArchiveUntrackedPaths(rightPaths); + if (normalizedLeftPaths.length !== normalizedRightPaths.length) { + return false; + } + + return normalizedLeftPaths.every((path, index) => path === normalizedRightPaths[index]); +} diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 73f3efc9b3f..b2d8bc31554 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -42,7 +42,7 @@ import type { WorkspaceActivitySnapshot, WorkspaceMetadata, } from "@/common/types/workspace"; -import type { TaskService } from "./taskService"; +import { makeAgentTaskIntegrationFake } from "./taskWorkspaceSeam.testUtils"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import { BashMonitorRegistryStore } from "./bashMonitorRegistryStore"; import { BashMonitorWakeStore, buildBashMonitorWakeMetadata } from "./bashMonitorWakeStore"; @@ -11932,11 +11932,13 @@ describe("WorkspaceService sendMessage status clearing", () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -11953,11 +11955,13 @@ describe("WorkspaceService sendMessage status clearing", () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const startupFailureHandled = createDeferred(); fakeSession.sendMessage.mockImplementation( @@ -11995,11 +11999,13 @@ describe("WorkspaceService sendMessage status clearing", () => { test("resumeStream restores interrupted task status before successful resume", async () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.resumeStream("test-workspace", { model: "openai:gpt-4o-mini", @@ -12016,11 +12022,13 @@ describe("WorkspaceService sendMessage status clearing", () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.resumeStream("test-workspace", { model: "openai:gpt-4o-mini", @@ -12038,11 +12046,13 @@ describe("WorkspaceService sendMessage status clearing", () => { test("resumeStream does not start interrupted tasks while still busy", async () => { const getAgentTaskStatus = mock(() => "interrupted" as const); const markInterruptedTaskRunning = mock(() => Promise.resolve(false)); - workspaceService.setTaskService({ - getAgentTaskStatus, - markInterruptedTaskRunning, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus, + markInterruptedTaskRunning, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.resumeStream("test-workspace", { model: "openai:gpt-4o-mini", @@ -12061,11 +12071,13 @@ describe("WorkspaceService sendMessage status clearing", () => { test("sendMessage does not queue interrupted tasks while still busy", async () => { const getAgentTaskStatus = mock(() => "interrupted" as const); const markInterruptedTaskRunning = mock(() => Promise.resolve(false)); - workspaceService.setTaskService({ - getAgentTaskStatus, - markInterruptedTaskRunning, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus, + markInterruptedTaskRunning, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12085,10 +12097,12 @@ describe("WorkspaceService sendMessage status clearing", () => { fakeSession.isBusy.mockReturnValue(true); const resetAutoResumeCount = mock(() => undefined); - workspaceService.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - resetAutoResumeCount, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus: mock(() => "running" as const), + resetAutoResumeCount, + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12104,10 +12118,12 @@ describe("WorkspaceService sendMessage status clearing", () => { fakeSession.isBusy.mockReturnValue(true); const resetAutoResumeCount = mock(() => undefined); - workspaceService.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - resetAutoResumeCount, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus: mock(() => "running" as const), + resetAutoResumeCount, + }) + ); const result = await workspaceService.sendMessage( "test-workspace", @@ -12332,10 +12348,12 @@ describe("WorkspaceService sendMessage status clearing", () => { fakeSession.isBusy.mockReturnValue(true); const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus: mock(() => "running" as const), + backgroundForegroundWaitsForWorkspace, + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12352,10 +12370,12 @@ describe("WorkspaceService sendMessage status clearing", () => { fakeSession.queueMessage.mockReturnValue("turn-end"); const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus: mock(() => "running" as const), + backgroundForegroundWaitsForWorkspace, + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12373,10 +12393,12 @@ describe("WorkspaceService sendMessage status clearing", () => { fakeSession.queueMessage.mockReturnValue(null); const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus: mock(() => "running" as const), + backgroundForegroundWaitsForWorkspace, + }) + ); const result = await workspaceService.sendMessage("test-workspace", " ", { model: "openai:gpt-4o-mini", @@ -12393,10 +12415,12 @@ describe("WorkspaceService sendMessage status clearing", () => { fakeSession.queueMessage.mockReturnValue("tool-end"); const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + getAgentTaskStatus: mock(() => "running" as const), + backgroundForegroundWaitsForWorkspace, + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12420,11 +12444,13 @@ describe("WorkspaceService sendMessage status clearing", () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12442,11 +12468,13 @@ describe("WorkspaceService sendMessage status clearing", () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", @@ -12463,11 +12491,13 @@ describe("WorkspaceService sendMessage status clearing", () => { const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + resetAutoResumeCount: mock(() => undefined), + }) + ); const result = await workspaceService.resumeStream("test-workspace", { model: "openai:gpt-4o-mini", @@ -15192,23 +15222,30 @@ describe("WorkspaceService remove lifecycle coordination", () => { }); let insideLifecycleLock = false; const withTaskTreeLifecycleLock = mock( - async (_workspaceId: string, operation: () => Promise): Promise => { - insideLifecycleLock = true; - try { - return await operation(); - } finally { - insideLifecycleLock = false; - } - } + (_workspaceId: string, _operation: () => Promise) => undefined ); + const runWithTaskTreeLifecycleLock = async ( + workspaceId: string, + operation: () => Promise + ): Promise => { + withTaskTreeLifecycleLock(workspaceId, operation); + insideLifecycleLock = true; + try { + return await operation(); + } finally { + insideLifecycleLock = false; + } + }; const hasDescendantAgentTasks = mock(() => { expect(insideLifecycleLock).toBe(true); return true; }); - workspaceService.setTaskService({ - withTaskTreeLifecycleLock, - hasDescendantAgentTasks, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + withTaskTreeLifecycleLock: runWithTaskTreeLifecycleLock, + hasDescendantAgentTasks, + }) + ); expect(await workspaceService.remove(workspaceId, true)).toEqual( Err( @@ -16519,12 +16556,20 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("archive coordinates through the task-tree lifecycle lock", async () => { const withTaskTreeLifecycleLock = mock( - (_: string, operation: () => Promise): Promise => operation() + (_workspaceId: string, _operation: () => Promise) => undefined + ); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + withTaskTreeLifecycleLock: ( + workspaceId: string, + operation: () => Promise + ): Promise => { + withTaskTreeLifecycleLock(workspaceId, operation); + return operation(); + }, + hasActiveDescendantAgentTasksForWorkspace: mock(() => false), + }) ); - workspaceService.setTaskService({ - withTaskTreeLifecycleLock, - hasActiveDescendantAgentTasksForWorkspace: mock(() => false), - } as unknown as TaskService); expect(await workspaceService.archive(workspaceId)).toEqual(Ok({ kind: "archived" })); expect(withTaskTreeLifecycleLock).toHaveBeenCalledWith(workspaceId, expect.any(Function)); @@ -16532,9 +16577,11 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("archive refuses to hide a parent while descendant sub-agents remain active", async () => { const hasActiveDescendantAgentTasksForWorkspace = mock(() => true); - workspaceService.setTaskService({ - hasActiveDescendantAgentTasksForWorkspace, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + hasActiveDescendantAgentTasksForWorkspace, + }) + ); const preflight = await workspaceService.preflightArchive(workspaceId); const archive = await workspaceService.archive(workspaceId); @@ -16730,10 +16777,12 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("archive() does not trigger irreversible descendant cleanup", async () => { const cleanupReportedDescendantsAfterArchive = mock(() => Promise.resolve()); - workspaceService.setTaskService({ - cleanupReportedDescendantsAfterArchive, - hasActiveDescendantAgentTasksForWorkspace: () => false, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + cleanupReportedDescendantsAfterArchive, + hasActiveDescendantAgentTasksForWorkspace: () => false, + }) + ); const result = await workspaceService.archive(workspaceId); @@ -16975,13 +17024,12 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("archive() rechecks durably active workflow runs after arming the admission gate", async () => { - workspaceService.setTaskService({ - hasActiveDescendantAgentTasksForWorkspace: mock(() => false), - hasActiveTopLevelWorkflowRunsForWorkspace: mock(() => Promise.resolve(true)), - withTaskTreeLifecycleLock: mock( - (_: string, operation: () => Promise): Promise => operation() - ), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + hasActiveDescendantAgentTasksForWorkspace: mock(() => false), + hasActiveTopLevelWorkflowRunsForWorkspace: mock(() => Promise.resolve(true)), + }) + ); const result = await workspaceService.archive(workspaceId, undefined, { refuseLiveUserActivity: true, @@ -20649,11 +20697,13 @@ describe("WorkspaceService interruptStream", () => { const resetAutoResumeCount = mock(() => undefined); const markParentWorkspaceInterrupted = mock(() => undefined); const terminateAllDescendantAgentTasks = mock(() => Promise.resolve([] as string[])); - workspaceService.setTaskService({ - resetAutoResumeCount, - markParentWorkspaceInterrupted, - terminateAllDescendantAgentTasks, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + resetAutoResumeCount, + markParentWorkspaceInterrupted, + terminateAllDescendantAgentTasks, + }) + ); const sendNextUserQueuedMessage = mock(() => true); const restoreQueueToInput = mock(() => undefined); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 3c10c28df8f..b90ea196fb7 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -305,7 +305,12 @@ import { type BashMonitorWakeRecord, } from "@/node/services/bashMonitorWakeStore"; import type { WorkspaceLifecycleHooks } from "@/node/services/workspaceLifecycleHooks"; -import type { TaskService } from "@/node/services/taskService"; +import { + areArchiveUntrackedPathListsEqual, + normalizeArchiveUntrackedPaths, + type AgentTaskIntegration, + type WorkspaceHost, +} from "@/node/services/taskWorkspaceSeam"; import { findWorkspaceEntry } from "@/node/services/taskUtils"; import type { WorktreeArchiveSnapshotService } from "@/node/services/worktreeArchiveSnapshotService"; @@ -667,18 +672,6 @@ function normalizeRepoRootProjectPath(projectPath: string | null | undefined): s return stripTrailingSlashes(path.posix.normalize(normalizedPath)); } -function normalizeArchiveUntrackedPaths(paths: readonly string[]): string[] { - const normalizedPaths = paths.map((untrackedPath) => { - const trimmedPath = untrackedPath.trim(); - assert( - trimmedPath.length > 0, - "normalizeArchiveUntrackedPaths: untracked paths must be non-empty" - ); - return trimmedPath; - }); - return [...new Set(normalizedPaths)].sort(); -} - function buildArchiveLossyUntrackedFilesConfirmation( paths: readonly string[] ): ArchiveLossyUntrackedFilesConfirmation { @@ -693,23 +686,6 @@ function buildArchiveLossyUntrackedFilesConfirmation( }; } -// Exported so TaskService's pre-interruption archive preflight applies the exact -// acknowledgement semantics enforced at the archive sink (getArchiveUntrackedFilesConfirmation): -// a drifted acknowledged set — extra OR missing paths — must re-confirm before any -// destructive interruption, not after. -export function areArchiveUntrackedPathListsEqual( - leftPaths: readonly string[], - rightPaths: readonly string[] -): boolean { - const normalizedLeftPaths = normalizeArchiveUntrackedPaths(leftPaths); - const normalizedRightPaths = normalizeArchiveUntrackedPaths(rightPaths); - if (normalizedLeftPaths.length !== normalizedRightPaths.length) { - return false; - } - - return normalizedLeftPaths.every((path, index) => path === normalizedRightPaths[index]); -} - function isArchiveLossyUntrackedFilesConfirmation( value: unknown ): value is ArchiveLossyUntrackedFilesConfirmation { @@ -1886,7 +1862,7 @@ const DELEGATED_TURN_CONTINUATION_OPTIONS_SCHEMA = SendMessageOptionsSchema.pick }); // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging -export class WorkspaceService extends EventEmitter { +export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly sessions = new Map(); private readonly providerConfigChangedListener = (): void => { const liveSessions = new Map([ @@ -2383,7 +2359,7 @@ export class WorkspaceService extends EventEmitter { private readonly initSettlementPromises = new Map>(); /** - * Registers a fire-and-forget background init started outside this service (TaskService + * Registers a fire-and-forget background init started outside this service (task orchestration * starts inits for task workspaces after materializing their checkouts) with the same * abort-and-settlement mechanism archive uses: archiveUnlocked aborts the registered * controller when init state is still running, and always awaits the retained settlement @@ -3410,7 +3386,7 @@ export class WorkspaceService extends EventEmitter { cancelInFlightConsolidation(workspaceId: string): Promise; }; private worktreeArchiveSnapshotService?: WorktreeArchiveSnapshotLifecycleService; - private taskService?: TaskService; + private agentTaskIntegration?: AgentTaskIntegration; private workspaceGoalService?: WorkspaceGoalService; /** Narrow DevTools cleanup surface; wired by coreServices when a DevToolsService exists. */ private devToolsService?: { removeWorkspaceData(workspaceId: string): Promise }; @@ -3474,7 +3450,7 @@ export class WorkspaceService extends EventEmitter { } /** - * TaskService entry point: task worktrees are REGISTERED before their + * Task orchestration entry point: task worktrees are REGISTERED before their * checkout exists (queued/reserved launches persist the entry with a future * path), so creation-time sanitization cannot cover them and an uninstall's * override pruning enumerates a path with nothing to prune — the later @@ -3756,12 +3732,8 @@ export class WorkspaceService extends EventEmitter { this.worktreeArchiveSnapshotService = service; } - /** - * Set the task service for auto-resume counter resets. - * Called after construction due to circular dependency. - */ - setTaskService(taskService: TaskService): void { - this.taskService = taskService; + setAgentTaskIntegration(integration: AgentTaskIntegration): void { + this.agentTaskIntegration = integration; } /** DevTools debug-log cleanup on archive/remove; wired by coreServices. */ @@ -6215,8 +6187,8 @@ export class WorkspaceService extends EventEmitter { workspaceId: string, operation: () => Promise ): Promise { - const taskService = this.taskService; - const withLock = taskService?.withTaskTreeLifecycleLock?.bind(taskService); + const integration = this.agentTaskIntegration; + const withLock = integration?.withTaskTreeLifecycleLock?.bind(integration); return withLock == null ? await operation() : await withLock(workspaceId, operation); } @@ -6227,9 +6199,9 @@ export class WorkspaceService extends EventEmitter { } /** - * Internal entry point for TaskService callers that already hold the task-tree lifecycle lock, + * Internal entry point for task orchestration callers that already hold the task-tree lifecycle lock, * or that must not acquire it for lock-ordering reasons (e.g. createWorkspaceTurn cleanup runs - * under TaskService's creation mutex, which the tree lock is ordered before). + * under the task creation mutex, which the tree lock is ordered before). */ async removeWhileTaskTreeLocked(workspaceId: string, force = false): Promise> { return await this.removeUnlocked(workspaceId, force); @@ -6265,7 +6237,7 @@ export class WorkspaceService extends EventEmitter { // Try to remove from runtime (filesystem) try { - if (this.taskService?.hasDescendantAgentTasks?.(workspaceId) === true) { + if (this.agentTaskIntegration?.hasDescendantAgentTasks?.(workspaceId) === true) { return Err(DESCENDANT_WORKSPACE_REMOVE_ERROR); } @@ -8412,7 +8384,9 @@ export class WorkspaceService extends EventEmitter { options?: { worktreeArchiveBehaviorOverride?: WorktreeArchiveBehavior } ): Promise> { try { - if (this.taskService?.hasActiveDescendantAgentTasksForWorkspace(workspaceId) === true) { + if ( + this.agentTaskIntegration?.hasActiveDescendantAgentTasksForWorkspace(workspaceId) === true + ) { return Err(ACTIVE_DESCENDANT_ARCHIVE_ERROR); } @@ -9061,7 +9035,7 @@ export class WorkspaceService extends EventEmitter { } /** - * Internal entry point for TaskService callers that already hold the task-tree lifecycle + * Internal entry point for task orchestration callers that already hold the task-tree lifecycle * lock. The model-facing workspace lifecycle path pre-acquires that lock before its own * lifecycle locks to preserve the global lock order (task-tree → task-creation mutex → * workspace lifecycle), so the sink must not re-acquire it. @@ -9154,7 +9128,9 @@ export class WorkspaceService extends EventEmitter { // entering later observe the armed guard and refuse. This closes the window between // the caller's earlier active-run snapshot and this sink. if ( - (await this.taskService?.hasActiveTopLevelWorkflowRunsForWorkspace(workspaceId)) === true + (await this.agentTaskIntegration?.hasActiveTopLevelWorkflowRunsForWorkspace( + workspaceId + )) === true ) { return Err( "Workspace has active workflow runs that archiving would orphan. Wait for them to finish or ask the user to archive manually." @@ -9179,7 +9155,9 @@ export class WorkspaceService extends EventEmitter { if (!workspace) { return Err("Workspace not found"); } - if (this.taskService?.hasActiveDescendantAgentTasksForWorkspace(workspaceId) === true) { + if ( + this.agentTaskIntegration?.hasActiveDescendantAgentTasksForWorkspace(workspaceId) === true + ) { return Err(ACTIVE_DESCENDANT_ARCHIVE_ERROR); } const initState = this.initStateManager.getInitState(workspaceId); @@ -9527,7 +9505,7 @@ export class WorkspaceService extends EventEmitter { } /** - * Internal entry point for TaskService callers that already hold the task-tree lifecycle + * Internal entry point for task orchestration callers that already hold the task-tree lifecycle * lock (the model-facing unarchive path pre-acquires it for lock ordering; agent-task * ancestry unarchive runs under the send path's tree lock). */ @@ -11367,7 +11345,7 @@ export class WorkspaceService extends EventEmitter { } // Guard: queued agent tasks must not start streaming via generic sendMessage calls. - // They should only be started by TaskService once a parallel slot is available. + // They should only be started by task orchestration once a parallel slot is available. if (!internal?.allowQueuedAgentTask) { const config = this.config.loadConfigOrDefault(); for (const [_projectPath, project] of config.projects) { @@ -11542,7 +11520,7 @@ export class WorkspaceService extends EventEmitter { if (internal?.admissionStale?.() === true) { return Err({ type: "unknown", raw: SEND_ADMISSION_STALE_MESSAGE }); } - const taskStatus = this.taskService?.getAgentTaskStatus?.(workspaceId); + const taskStatus = this.agentTaskIntegration?.getAgentTaskStatus?.(workspaceId); if (taskStatus === "interrupted") { return Err({ type: "unknown", @@ -11646,18 +11624,18 @@ export class WorkspaceService extends EventEmitter { } if (effectiveQueueDispatchMode != null && !internal?.skipAutoResumeReset) { - this.taskService?.resetAutoResumeCount?.(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount?.(workspaceId); } if (effectiveQueueDispatchMode === "tool-end") { - this.taskService?.backgroundForegroundWaitsForWorkspace?.(workspaceId); + this.agentTaskIntegration?.backgroundForegroundWaitsForWorkspace?.(workspaceId); } return Ok(undefined); } if (!internal?.skipAutoResumeReset) { - this.taskService?.resetAutoResumeCount(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount(workspaceId); } // A stale caller probe must refuse BEFORE the interrupted-task rescue below: a peer send @@ -11668,7 +11646,7 @@ export class WorkspaceService extends EventEmitter { } // Non-destructive interrupt cascades preserve descendant task workspaces with - // taskStatus=interrupted. Transition before starting a new stream so TaskService + // taskStatus=interrupted. Transition before starting a new stream so task orchestration // stream-end handling does not early-return on interrupted status. // // Guarded sends (peer messages) skip this rescue entirely: it exists for user-driven @@ -11679,7 +11657,7 @@ export class WorkspaceService extends EventEmitter { if (internal?.admissionStale == null) { try { resumedInterruptedTask = - (await this.taskService?.markInterruptedTaskRunning?.(workspaceId)) ?? false; + (await this.agentTaskIntegration?.markInterruptedTaskRunning?.(workspaceId)) ?? false; } catch (error: unknown) { log.error("Failed to restore interrupted task status before sendMessage", { workspaceId, @@ -11692,7 +11670,9 @@ export class WorkspaceService extends EventEmitter { const onAcceptedPreStreamFailure = async (error: SendMessageError) => { if (resumedInterruptedTask && normalizedOptions?.editMessageId) { try { - await this.taskService?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( + workspaceId + ); } catch (restoreError: unknown) { log.error( "Failed to restore interrupted task status after accepted edit startup failure", @@ -11761,7 +11741,9 @@ export class WorkspaceService extends EventEmitter { if (resumedInterruptedTask) { try { - await this.taskService?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( + workspaceId + ); } catch (error: unknown) { log.error("Failed to restore interrupted task status after sendMessage failure", { workspaceId, @@ -11796,7 +11778,7 @@ export class WorkspaceService extends EventEmitter { if (resumedInterruptedTask) { try { - await this.taskService?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); } catch (restoreError: unknown) { log.error("Failed to restore interrupted task status after sendMessage throw", { workspaceId, @@ -11906,7 +11888,7 @@ export class WorkspaceService extends EventEmitter { } // Guard: queued agent tasks must not be resumed by generic UI/API calls. - // TaskService is responsible for dequeuing and starting them. + // Task orchestration is responsible for dequeuing and starting them. if (!internal?.allowQueuedAgentTask) { const config = this.config.loadConfigOrDefault(); for (const [_projectPath, project] of config.projects) { @@ -11936,7 +11918,7 @@ export class WorkspaceService extends EventEmitter { const session = this.getOrCreateSession(workspaceId); - const taskStatus = this.taskService?.getAgentTaskStatus?.(workspaceId); + const taskStatus = this.agentTaskIntegration?.getAgentTaskStatus?.(workspaceId); if (taskStatus === "interrupted" && session.isBusy()) { return Err({ type: "unknown", @@ -11960,11 +11942,11 @@ export class WorkspaceService extends EventEmitter { await this.maybePersistAISettingsFromOptions(workspaceId, normalizedOptions, "resume"); // Non-destructive interrupt cascades preserve descendant task workspaces with - // taskStatus=interrupted. Transition before stream start so TaskService stream-end + // taskStatus=interrupted. Transition before stream start so task orchestration stream-end // handling does not early-return on interrupted status. try { resumedInterruptedTask = - (await this.taskService?.markInterruptedTaskRunning?.(workspaceId)) ?? false; + (await this.agentTaskIntegration?.markInterruptedTaskRunning?.(workspaceId)) ?? false; } catch (error: unknown) { log.error("Failed to restore interrupted task status before resumeStream", { workspaceId, @@ -11991,7 +11973,9 @@ export class WorkspaceService extends EventEmitter { }); if (resumedInterruptedTask) { try { - await this.taskService?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( + workspaceId + ); } catch (error: unknown) { log.error("Failed to restore interrupted task status after resumeStream failure", { workspaceId, @@ -12007,7 +11991,9 @@ export class WorkspaceService extends EventEmitter { if (!result.data.started) { if (resumedInterruptedTask) { try { - await this.taskService?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( + workspaceId + ); } catch (error: unknown) { log.error("Failed to restore interrupted task status after no-op resumeStream", { workspaceId, @@ -12022,7 +12008,7 @@ export class WorkspaceService extends EventEmitter { } catch (error) { if (resumedInterruptedTask) { try { - await this.taskService?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); } catch (restoreError: unknown) { log.error("Failed to restore interrupted task status after resumeStream throw", { workspaceId, @@ -12097,11 +12083,11 @@ export class WorkspaceService extends EventEmitter { ): Promise> { let releaseHardStopLatch: (() => void) | undefined; try { - this.taskService?.resetAutoResumeCount(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount(workspaceId); if (!options?.soft) { // Mark before attempting the session interrupt to close races where a child // could report between stop initiation and descendant cascade termination. - this.taskService?.markParentWorkspaceInterrupted(workspaceId); + this.agentTaskIntegration?.markParentWorkspaceInterrupted(workspaceId); // Latch synchronously at the request boundary, BEFORE the session-interrupt await // below: the suppression mark above is level-triggered (a user resume clears it), so // a peer send from a still-running descendant entering during that await — or during @@ -12109,8 +12095,8 @@ export class WorkspaceService extends EventEmitter { // ancestor epoch as its clean baseline and wake workspaces outside the stopped // subtree. Released in the finally, after the descendant cascade persisted terminal // statuses. - // Optional call: test harnesses mock TaskService with a narrow method surface. - releaseHardStopLatch = this.taskService?.latchHardInterruptCascade?.(workspaceId); + // Optional call: test harnesses mock the task integration port with a narrow method surface. + releaseHardStopLatch = this.agentTaskIntegration?.latchHardInterruptCascade?.(workspaceId); } const session = this.getOrCreateSession(workspaceId); @@ -12118,7 +12104,7 @@ export class WorkspaceService extends EventEmitter { if (!stopResult.success) { // Interrupt failed, so clear hard-interrupt suppression we set above. if (!options?.soft) { - this.taskService?.resetAutoResumeCount(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount(workspaceId); } log.error("Failed to stop stream:", stopResult.error); return Err(stopResult.error); @@ -12136,7 +12122,7 @@ export class WorkspaceService extends EventEmitter { if (!options?.soft) { try { const interruptedTaskIds = - await this.taskService?.terminateAllDescendantAgentTasks?.(workspaceId); + await this.agentTaskIntegration?.terminateAllDescendantAgentTasks?.(workspaceId); if (interruptedTaskIds && interruptedTaskIds.length > 0) { log.debug("Cascade-interrupted descendant tasks on interrupt", { workspaceId, @@ -12155,7 +12141,7 @@ export class WorkspaceService extends EventEmitter { if (options?.sendQueuedImmediately) { // `sendQueuedMessages()` routes through AgentSession directly, so explicitly // clear hard-interrupt suppression first (it won't flow through sendMessage()). - this.taskService?.resetAutoResumeCount(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount(workspaceId); // The card represents only user-authored queue content. Prioritize that // entry over hidden synthetic/background work before dispatching. session.sendNextUserQueuedMessage(); @@ -12168,7 +12154,7 @@ export class WorkspaceService extends EventEmitter { } catch (error) { if (!options?.soft) { // Keep suppression state consistent if interrupt setup/stop throws. - this.taskService?.resetAutoResumeCount(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount(workspaceId); } const errorMessage = getErrorMessage(error); log.error("Unexpected error in interruptStream handler:", error); From 5c9ba0e31f2df384056564c44a19d08d11a2fbc8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:48:15 +0000 Subject: [PATCH 17/20] refactor(services): dedupe seam types and drop port-optional chaining The seam file is now the single documented home for ArchiveWorkspaceOptions, SendMessageInternalOptions, and WorkspaceLiveActivity instead of duplicating workspaceService declarations. StreamErrorRecoveryOutcome comes from its canonical agentSession export. The AgentTaskStatus re-export shim is gone; importers use the seam. Method-level optional chaining on both typed ports is removed: the interfaces guarantee the methods, so the narrow-mock hedges and their comments no longer apply. --- src/node/services/taskService.ts | 5 +- src/node/services/taskWorkspaceSeam.ts | 93 +++++++++++-- src/node/services/tools/task_await.ts | 2 +- src/node/services/tools/task_list.test.ts | 3 +- src/node/services/tools/task_list.ts | 3 +- src/node/services/workspaceService.ts | 161 +++------------------- 6 files changed, 111 insertions(+), 156 deletions(-) diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 6e1d72bf49a..0da071a7188 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -230,8 +230,6 @@ export class AgentReportWaitTimeoutError extends Error { } } -export type { AgentTaskStatus } from "@/node/services/taskWorkspaceSeam"; - /** * Resolved per-agent AI settings (canonical model + optional thinking level). * @@ -6679,8 +6677,7 @@ export class TaskService implements AgentTaskIntegration { }; } - // Optional chaining: test harnesses mock the host port with a narrow method surface. - const queuedCount = this.workspaceService.countQueuedAgentPeerMessages?.(targetId) ?? 0; + const queuedCount = this.workspaceService.countQueuedAgentPeerMessages(targetId); if (queuedCount >= MAX_QUEUED_PEER_MESSAGES_PER_TARGET) { return { code: "refused", diff --git a/src/node/services/taskWorkspaceSeam.ts b/src/node/services/taskWorkspaceSeam.ts index c1c92eba7bd..f3cb1e84f7b 100644 --- a/src/node/services/taskWorkspaceSeam.ts +++ b/src/node/services/taskWorkspaceSeam.ts @@ -11,6 +11,7 @@ import type { WorkspaceTurnTaskCorrelation, } from "@/common/types/message"; import type { Result } from "@/common/types/result"; +import type { StreamErrorRecoveryOutcome } from "@/node/services/agentSession"; import type { RuntimeConfig } from "@/common/types/runtime"; import type { FrontendWorkspaceMetadata, WorkspaceMetadata } from "@/common/types/workspace"; import assert from "@/common/utils/assert"; @@ -25,45 +26,119 @@ import type { QueueCutCutter } from "@/node/services/messageQueue"; export type AgentTaskStatus = NonNullable; -type StreamErrorRecoveryOutcome = "retry-started" | "terminal"; - -interface WorkspaceHostArchiveOptions { +export interface ArchiveWorkspaceOptions { + /** + * Refuse to archive when the effective worktree archive behavior would delete the checkout + * ("delete"). Model-facing callers set this so a concurrent settings flip cannot turn an + * agent-driven archive into an unconfirmed checkout deletion; enforced against the same + * behavior read that drives the snapshot/deletion decisions. + */ forbidWorktreeCheckoutDeletion?: boolean; + /** + * Refuse to archive when live user activity exists at the sink (a stream, a send still in + * its pre-admission window, queued/preparing turns, terminal sessions, or a desktop + * session). Model-facing callers set this so an agent-driven archive fails closed instead + * of silently terminating user work that started after the caller's earlier activity check. + * Checked synchronously in the same block that marks the workspace as archiving, pairing + * with sendMessage's synchronous entry guards: whichever side runs first is observed by the + * other. Also holds the session's turn admission for the rest of the archive so a queued + * entry cannot dispatch through AgentSession's internal send path (which bypasses + * WorkspaceService.sendMessage) into the workspace mid-archive. The user-driven archive + * path intentionally omits this and keeps its stop-activity semantics. + */ refuseLiveUserActivity?: boolean; + /** + * Behavior snapshot read by the caller before it committed to the archive (e.g. before + * interrupting active turns). The sink uses it for every snapshot/deletion decision instead + * of re-reading config, so a concurrent settings flip cannot change archive eligibility + * between the caller's checks and the sink — e.g. flipping keep → snapshot after turns were + * interrupted would otherwise bounce with requires_confirmation, stranding destroyed work. + */ worktreeArchiveBehaviorOverride?: WorktreeArchiveBehavior; + /** + * Refuse to archive when the Coder workspace-on-archive policy would permanently delete a + * dedicated (mux-created) remote Coder workspace via the before-archive hook. Unarchive + * does not recreate deleted Coder workspaces, so a model-facing "reversible" archive must + * fail closed instead; route that policy through user-mediated archive. + */ forbidCoderWorkspaceDeletion?: boolean; + /** + * Coder archive-policy snapshot read by the caller before it committed to the archive (e.g. + * before deciding interrupt_active eligibility and interrupting turns). Mirrors + * worktreeArchiveBehaviorOverride: the sink's deletion guard and the before-archive hook honor + * this same read, so a keep → stop/delete settings flip after the caller's checks cannot make + * the sink run (or refuse on) a remote stop/deletion the caller never admitted — which would + * otherwise strand already-interrupted turns behind a failed archive. + */ coderWorkspaceArchiveBehaviorOverride?: CoderWorkspaceArchiveBehavior; } -interface WorkspaceHostLiveActivity { +export interface WorkspaceLiveActivity { streaming: boolean; + /** Queued or dispatching (PREPARING) messages that would start a stream after archive. */ queuedMessages: boolean; + /** + * Detached background bash processes still running (sync snapshot; may briefly read + * stale-running until the next lazy refresh — callers wanting freshness should await + * hasRunningBackgroundBashProcesses first). + */ backgroundBashProcesses: boolean; terminalSessions: boolean; desktopSession: boolean; } -interface WorkspaceHostSendInternalOptions { +export interface SendMessageInternalOptions { allowQueuedAgentTask?: boolean; skipAutoResumeReset?: boolean; synthetic?: boolean; + /** Marks a synthetic send as an active-goal continuation turn. */ goalContinuation?: boolean; + /** Specific active-goal synthetic turn kind to persist on the user message. */ goalKind?: GoalSyntheticMessageKind; + /** Goal identity persisted alongside goalKind so reconciliation can scope the row. */ goalId?: string; + /** Force Copilot billing classification to "agent" for internal sends. */ agentInitiated?: boolean; onAccepted?: () => Promise | void; onCanceled?: (reason: string) => Promise | void; onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise | void; cancelState?: { canceledBeforeAcceptance: boolean }; + /** Cancels a synthetic send even after it has left MessageQueue for PREPARING. */ cancelSignal?: AbortSignal; + /** + * Synchronous staleness probe from the caller, re-evaluated at the real admission points + * (the enqueue block and the session's turn-admission gates) in addition to the + * context-mutation epoch. Peer agent sends use it so a user Stop or task_stop landing + * during this method's awaits refuses the send instead of queueing a wake or resurrecting + * the stopped task via markInterruptedTaskRunning. + */ admissionStale?: () => boolean; + /** + * Synthetic assistant rows persisted just before the turn's user row + * (family-message payloads). Delivered atomically with the message — + * queued alongside it when the workspace is busy — so they never land + * inside another turn's PREPARING window (see AgentSession.sendMessage). + */ preTurnMessages?: MuxMessage[]; + /** r54: fired once pre-turn rows cross the rollback horizon (see AgentSession). */ onPreTurnRowsPersisted?: () => void; + /** Return once the user message is accepted; stream startup continues asynchronously. */ startStreamInBackground?: boolean; + /** When true, reject instead of queueing if the workspace is busy. */ requireIdle?: boolean; + /** Preserve workspace-turn correlation only when this send is the next continuation. */ workspaceTurnContinuation?: boolean; + /** Coalescing for queued sends: drop the message when the same key is already queued. */ queueDedupeKey?: string; + /** Keep this dedupe-keyed queue entry isolated so it can be selectively superseded. */ removableQueueDedupeKey?: boolean; + /** + * For queued sends: quietly drop the message (success) when other messages are already + * queued at enqueue time. Scheduled heartbeats use this so a user send racing the awaits + * in this method keeps queue ownership — MessageQueue dispatches with the latest queued + * options, so merging a heartbeat in would run the user's queued turn with the + * heartbeat's model/agent. + */ yieldToQueuedMessages?: boolean; } @@ -78,12 +153,12 @@ export interface WorkspaceHost { archive( workspaceId: string, acknowledgedUntrackedPaths?: string[], - options?: WorkspaceHostArchiveOptions + options?: ArchiveWorkspaceOptions ): Promise>; archiveWhileTaskTreeLocked( workspaceId: string, acknowledgedUntrackedPaths?: string[], - options?: WorkspaceHostArchiveOptions + options?: ArchiveWorkspaceOptions ): Promise>; clearQueue(workspaceId: string, options?: { cancelReason?: string }): Result; countQueuedAgentPeerMessages(workspaceId: string): number; @@ -125,7 +200,7 @@ export interface WorkspaceHost { metadata?: WorkspaceMetadata ): boolean; isWorkflowInvocationCurrent(workspaceId: string, runId: string): Promise; - listLiveWorkspaceActivity(workspaceId: string): WorkspaceHostLiveActivity; + listLiveWorkspaceActivity(workspaceId: string): WorkspaceLiveActivity; preflightArchive( workspaceId: string, options?: { worktreeArchiveBehaviorOverride?: WorktreeArchiveBehavior } @@ -170,7 +245,7 @@ export interface WorkspaceHost { workspaceId: string, message: string, options: SendMessageOptions & { fileParts?: FilePart[] }, - internal?: WorkspaceHostSendInternalOptions + internal?: SendMessageInternalOptions ): Promise>; unarchiveWhileTaskTreeLocked(workspaceId: string): Promise>; updateTitle(workspaceId: string, title: string): Promise>; diff --git a/src/node/services/tools/task_await.ts b/src/node/services/tools/task_await.ts index 352a224bf6c..0c8c8b13e03 100644 --- a/src/node/services/tools/task_await.ts +++ b/src/node/services/tools/task_await.ts @@ -32,9 +32,9 @@ import { type WorkspaceTurnTaskStatus, } from "@/node/services/taskHandleStore"; import { buildWorkflowProgressSummary, formatWorkflowProgressNote } from "./workflowProgress"; +import type { AgentTaskStatus } from "@/node/services/taskWorkspaceSeam"; import { ForegroundWaitBackgroundedError, - type AgentTaskStatus, type AgentTaskStatusLookup, type AgentTaskTimestamps, } from "@/node/services/taskService"; diff --git a/src/node/services/tools/task_list.test.ts b/src/node/services/tools/task_list.test.ts index cc54567afbc..a7c0325e094 100644 --- a/src/node/services/tools/task_list.test.ts +++ b/src/node/services/tools/task_list.test.ts @@ -6,7 +6,8 @@ import type { ToolExecutionOptions } from "ai"; import { createTaskListTool } from "./task_list"; import { TestTempDir, createTestToolConfig } from "./testHelpers"; import { Config, type Workspace } from "@/node/config"; -import type { AgentTaskStatus, TaskService } from "@/node/services/taskService"; +import type { TaskService } from "@/node/services/taskService"; +import type { AgentTaskStatus } from "@/node/services/taskWorkspaceSeam"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { WorkspaceTurnTaskHandleRecord, diff --git a/src/node/services/tools/task_list.ts b/src/node/services/tools/task_list.ts index ea6cfde2e0f..df472ba2559 100644 --- a/src/node/services/tools/task_list.ts +++ b/src/node/services/tools/task_list.ts @@ -14,7 +14,8 @@ import { TaskListToolResultSchema, TOOL_DEFINITIONS } from "@/common/utils/tools import { isWorkspaceArchived } from "@/common/utils/archive"; import { isNestedWorkflowRun } from "@/common/types/workflow"; -import type { AgentTaskStatus, TaskService } from "@/node/services/taskService"; +import type { TaskService } from "@/node/services/taskService"; +import type { AgentTaskStatus } from "@/node/services/taskWorkspaceSeam"; import type { Workspace as WorkspaceConfigEntry } from "@/node/config"; import { Config } from "@/node/config"; import { log } from "@/node/services/log"; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index b90ea196fb7..6c17290fe46 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -8,7 +8,6 @@ import assert from "@/common/utils/assert"; import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; import type { WorktreeArchiveBehavior } from "@/common/config/worktreeArchiveBehavior"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; -import type { CoderWorkspaceArchiveBehavior } from "@/common/config/coderArchiveBehavior"; import type { WorktreeArchiveSnapshot } from "@/common/schemas/project"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { @@ -309,7 +308,10 @@ import { areArchiveUntrackedPathListsEqual, normalizeArchiveUntrackedPaths, type AgentTaskIntegration, + type ArchiveWorkspaceOptions, + type SendMessageInternalOptions, type WorkspaceHost, + type WorkspaceLiveActivity, } from "@/node/services/taskWorkspaceSeam"; import { findWorkspaceEntry } from "@/node/services/taskUtils"; import type { WorktreeArchiveSnapshotService } from "@/node/services/worktreeArchiveSnapshotService"; @@ -612,53 +614,6 @@ const POST_COMPACTION_METADATA_REFRESH_DEBOUNCE_MS = 100; const DESCENDANT_WORKSPACE_REMOVE_ERROR = "This workspace has descendant sub-agent workspaces. Remove those descendants deepest-first before removing their parent."; -export interface ArchiveWorkspaceOptions { - /** - * Refuse to archive when the effective worktree archive behavior would delete the checkout - * ("delete"). Model-facing callers set this so a concurrent settings flip cannot turn an - * agent-driven archive into an unconfirmed checkout deletion; enforced against the same - * behavior read that drives the snapshot/deletion decisions. - */ - forbidWorktreeCheckoutDeletion?: boolean; - /** - * Refuse to archive when live user activity exists at the sink (a stream, a send still in - * its pre-admission window, queued/preparing turns, terminal sessions, or a desktop - * session). Model-facing callers set this so an agent-driven archive fails closed instead - * of silently terminating user work that started after the caller's earlier activity check. - * Checked synchronously in the same block that marks the workspace as archiving, pairing - * with sendMessage's synchronous entry guards: whichever side runs first is observed by the - * other. Also holds the session's turn admission for the rest of the archive so a queued - * entry cannot dispatch through AgentSession's internal send path (which bypasses - * WorkspaceService.sendMessage) into the workspace mid-archive. The user-driven archive - * path intentionally omits this and keeps its stop-activity semantics. - */ - refuseLiveUserActivity?: boolean; - /** - * Behavior snapshot read by the caller before it committed to the archive (e.g. before - * interrupting active turns). The sink uses it for every snapshot/deletion decision instead - * of re-reading config, so a concurrent settings flip cannot change archive eligibility - * between the caller's checks and the sink — e.g. flipping keep → snapshot after turns were - * interrupted would otherwise bounce with requires_confirmation, stranding destroyed work. - */ - worktreeArchiveBehaviorOverride?: WorktreeArchiveBehavior; - /** - * Refuse to archive when the Coder workspace-on-archive policy would permanently delete a - * dedicated (mux-created) remote Coder workspace via the before-archive hook. Unarchive - * does not recreate deleted Coder workspaces, so a model-facing "reversible" archive must - * fail closed instead; route that policy through user-mediated archive. - */ - forbidCoderWorkspaceDeletion?: boolean; - /** - * Coder archive-policy snapshot read by the caller before it committed to the archive (e.g. - * before deciding interrupt_active eligibility and interrupting turns). Mirrors - * worktreeArchiveBehaviorOverride: the sink's deletion guard and the before-archive hook honor - * this same read, so a keep → stop/delete settings flip after the caller's checks cannot make - * the sink run (or refuse on) a remote stop/deletion the caller never admitted — which would - * otherwise strand already-interrupted turns behind a failed archive. - */ - coderWorkspaceArchiveBehaviorOverride?: CoderWorkspaceArchiveBehavior; -} - const ACTIVE_DESCENDANT_ARCHIVE_ERROR = "This workspace has active descendant sub-agents. Stop them before archiving their parent."; const MULTI_PROJECT_WORKSPACES_DISABLED_ERROR = "Multi-project workspaces experiment is disabled"; @@ -6188,7 +6143,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { operation: () => Promise ): Promise { const integration = this.agentTaskIntegration; - const withLock = integration?.withTaskTreeLifecycleLock?.bind(integration); + const withLock = integration?.withTaskTreeLifecycleLock.bind(integration); return withLock == null ? await operation() : await withLock(workspaceId, operation); } @@ -6237,7 +6192,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // Try to remove from runtime (filesystem) try { - if (this.agentTaskIntegration?.hasDescendantAgentTasks?.(workspaceId) === true) { + if (this.agentTaskIntegration?.hasDescendantAgentTasks(workspaceId) === true) { return Err(DESCENDANT_WORKSPACE_REMOVE_ERROR); } @@ -8862,19 +8817,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * stopLiveWorkspaceActivityForArchive. Model-facing lifecycle paths consult this to refuse * archiving instead of killing activity that has no delegated workspace-turn handle. */ - listLiveWorkspaceActivity(workspaceId: string): { - streaming: boolean; - /** Queued or dispatching (PREPARING) messages that would start a stream after archive. */ - queuedMessages: boolean; - /** - * Detached background bash processes still running (sync snapshot; may briefly read - * stale-running until the next lazy refresh — callers wanting freshness should await - * hasRunningBackgroundBashProcesses first). - */ - backgroundBashProcesses: boolean; - terminalSessions: boolean; - desktopSession: boolean; - } { + listLiveWorkspaceActivity(workspaceId: string): WorkspaceLiveActivity { return { streaming: this.aiService.isStreaming(workspaceId), queuedMessages: @@ -11178,60 +11121,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { options: SendMessageOptions & { fileParts?: FilePart[]; }, - internal?: { - allowQueuedAgentTask?: boolean; - skipAutoResumeReset?: boolean; - synthetic?: boolean; - /** Marks a synthetic send as an active-goal continuation turn. */ - goalContinuation?: boolean; - /** Specific active-goal synthetic turn kind to persist on the user message. */ - goalKind?: GoalSyntheticMessageKind; - /** Goal identity persisted alongside goalKind so reconciliation can scope the row. */ - goalId?: string; - /** Force Copilot billing classification to "agent" for internal sends. */ - agentInitiated?: boolean; - onAccepted?: () => Promise | void; - onCanceled?: (reason: string) => Promise | void; - onAcceptedPreStreamFailure?: (error: SendMessageError) => Promise | void; - cancelState?: { canceledBeforeAcceptance: boolean }; - /** Cancels a synthetic send even after it has left MessageQueue for PREPARING. */ - cancelSignal?: AbortSignal; - /** - * Synchronous staleness probe from the caller, re-evaluated at the real admission points - * (the enqueue block and the session's turn-admission gates) in addition to the - * context-mutation epoch. Peer agent sends use it so a user Stop or task_stop landing - * during this method's awaits refuses the send instead of queueing a wake or resurrecting - * the stopped task via markInterruptedTaskRunning. - */ - admissionStale?: () => boolean; - /** - * Synthetic assistant rows persisted just before the turn's user row - * (family-message payloads). Delivered atomically with the message — - * queued alongside it when the workspace is busy — so they never land - * inside another turn's PREPARING window (see AgentSession.sendMessage). - */ - preTurnMessages?: MuxMessage[]; - /** r54: fired once pre-turn rows cross the rollback horizon (see AgentSession). */ - onPreTurnRowsPersisted?: () => void; - /** Return once the user message is accepted; stream startup continues asynchronously. */ - startStreamInBackground?: boolean; - /** When true, reject instead of queueing if the workspace is busy. */ - requireIdle?: boolean; - /** Preserve workspace-turn correlation only when this send is the next continuation. */ - workspaceTurnContinuation?: boolean; - /** Coalescing for queued sends: drop the message when the same key is already queued. */ - queueDedupeKey?: string; - /** Keep this dedupe-keyed queue entry isolated so it can be selectively superseded. */ - removableQueueDedupeKey?: boolean; - /** - * For queued sends: quietly drop the message (success) when other messages are already - * queued at enqueue time. Scheduled heartbeats use this so a user send racing the awaits - * in this method keeps queue ownership — MessageQueue dispatches with the latest queued - * options, so merging a heartbeat in would run the user's queued turn with the - * heartbeat's model/agent. - */ - yieldToQueuedMessages?: boolean; - } + internal?: SendMessageInternalOptions ): Promise> { log.debug("sendMessage handler: Received", { workspaceId, @@ -11520,7 +11410,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (internal?.admissionStale?.() === true) { return Err({ type: "unknown", raw: SEND_ADMISSION_STALE_MESSAGE }); } - const taskStatus = this.agentTaskIntegration?.getAgentTaskStatus?.(workspaceId); + const taskStatus = this.agentTaskIntegration?.getAgentTaskStatus(workspaceId); if (taskStatus === "interrupted") { return Err({ type: "unknown", @@ -11624,11 +11514,11 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } if (effectiveQueueDispatchMode != null && !internal?.skipAutoResumeReset) { - this.agentTaskIntegration?.resetAutoResumeCount?.(workspaceId); + this.agentTaskIntegration?.resetAutoResumeCount(workspaceId); } if (effectiveQueueDispatchMode === "tool-end") { - this.agentTaskIntegration?.backgroundForegroundWaitsForWorkspace?.(workspaceId); + this.agentTaskIntegration?.backgroundForegroundWaitsForWorkspace(workspaceId); } return Ok(undefined); @@ -11657,7 +11547,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (internal?.admissionStale == null) { try { resumedInterruptedTask = - (await this.agentTaskIntegration?.markInterruptedTaskRunning?.(workspaceId)) ?? false; + (await this.agentTaskIntegration?.markInterruptedTaskRunning(workspaceId)) ?? false; } catch (error: unknown) { log.error("Failed to restore interrupted task status before sendMessage", { workspaceId, @@ -11670,9 +11560,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const onAcceptedPreStreamFailure = async (error: SendMessageError) => { if (resumedInterruptedTask && normalizedOptions?.editMessageId) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( - workspaceId - ); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); } catch (restoreError: unknown) { log.error( "Failed to restore interrupted task status after accepted edit startup failure", @@ -11741,9 +11629,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( - workspaceId - ); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); } catch (error: unknown) { log.error("Failed to restore interrupted task status after sendMessage failure", { workspaceId, @@ -11778,7 +11664,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); } catch (restoreError: unknown) { log.error("Failed to restore interrupted task status after sendMessage throw", { workspaceId, @@ -11918,7 +11804,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const session = this.getOrCreateSession(workspaceId); - const taskStatus = this.agentTaskIntegration?.getAgentTaskStatus?.(workspaceId); + const taskStatus = this.agentTaskIntegration?.getAgentTaskStatus(workspaceId); if (taskStatus === "interrupted" && session.isBusy()) { return Err({ type: "unknown", @@ -11946,7 +11832,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // handling does not early-return on interrupted status. try { resumedInterruptedTask = - (await this.agentTaskIntegration?.markInterruptedTaskRunning?.(workspaceId)) ?? false; + (await this.agentTaskIntegration?.markInterruptedTaskRunning(workspaceId)) ?? false; } catch (error: unknown) { log.error("Failed to restore interrupted task status before resumeStream", { workspaceId, @@ -11973,9 +11859,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { }); if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( - workspaceId - ); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); } catch (error: unknown) { log.error("Failed to restore interrupted task status after resumeStream failure", { workspaceId, @@ -11991,9 +11875,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (!result.data.started) { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.( - workspaceId - ); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); } catch (error: unknown) { log.error("Failed to restore interrupted task status after no-op resumeStream", { workspaceId, @@ -12008,7 +11890,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } catch (error) { if (resumedInterruptedTask) { try { - await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure?.(workspaceId); + await this.agentTaskIntegration?.restoreInterruptedTaskAfterResumeFailure(workspaceId); } catch (restoreError: unknown) { log.error("Failed to restore interrupted task status after resumeStream throw", { workspaceId, @@ -12095,8 +11977,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // ancestor epoch as its clean baseline and wake workspaces outside the stopped // subtree. Released in the finally, after the descendant cascade persisted terminal // statuses. - // Optional call: test harnesses mock the task integration port with a narrow method surface. - releaseHardStopLatch = this.agentTaskIntegration?.latchHardInterruptCascade?.(workspaceId); + releaseHardStopLatch = this.agentTaskIntegration?.latchHardInterruptCascade(workspaceId); } const session = this.getOrCreateSession(workspaceId); @@ -12122,7 +12003,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (!options?.soft) { try { const interruptedTaskIds = - await this.agentTaskIntegration?.terminateAllDescendantAgentTasks?.(workspaceId); + await this.agentTaskIntegration?.terminateAllDescendantAgentTasks(workspaceId); if (interruptedTaskIds && interruptedTaskIds.length > 0) { log.debug("Cascade-interrupted descendant tasks on interrupt", { workspaceId, From 74c091ea7328580e6635cdb1d8e6c66e8ff72ee7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:56:47 +0000 Subject: [PATCH 18/20] test(services): delete seam-obsoleted scaffolding in workspaceService suite The typed AgentTaskIntegration port makes several tests unwritable or redundant: the phantom cleanupReportedDescendantsAfterArchive guard (method never existed in production), the archive lock pass-through wiring assertion, and four private updateAgentStatus non-invocation spies whose positive registerSession behavior tests remain. Near-identical send/resume lifecycle, winding-down, auto-resume, and foreground-wait-backgrounding siblings collapse into table-driven tests preserving every case, and dead fake stubs the code under test never reads are dropped. --- .../services/taskWorkspaceSeam.testUtils.ts | 6 +- src/node/services/workspaceService.test.ts | 511 +++++------------- 2 files changed, 124 insertions(+), 393 deletions(-) diff --git a/src/node/services/taskWorkspaceSeam.testUtils.ts b/src/node/services/taskWorkspaceSeam.testUtils.ts index 95044cf6076..b986d923f40 100644 --- a/src/node/services/taskWorkspaceSeam.testUtils.ts +++ b/src/node/services/taskWorkspaceSeam.testUtils.ts @@ -1,11 +1,7 @@ import type { AgentTaskIntegration } from "@/node/services/taskWorkspaceSeam"; -interface AgentTaskIntegrationTestOverrides extends Partial { - cleanupReportedDescendantsAfterArchive?: () => Promise; -} - export function makeAgentTaskIntegrationFake( - overrides: AgentTaskIntegrationTestOverrides = {} + overrides: Partial = {} ): AgentTaskIntegration { return { withTaskTreeLifecycleLock: (_workspaceId: string, operation: () => Promise): Promise => diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index b2d8bc31554..aae7c6a0f5f 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -11886,49 +11886,21 @@ describe("WorkspaceService sendMessage status clearing", () => { } }); - test("does not clear persisted agent status directly for non-synthetic sends", async () => { - const updateAgentStatus = spyOn( - workspaceService as unknown as { - updateAgentStatus: (workspaceId: string, status: null) => Promise; - }, - "updateAgentStatus" - ).mockResolvedValue(undefined); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(true); - expect(updateAgentStatus).not.toHaveBeenCalled(); - }); - - test("does not clear persisted agent status directly for synthetic sends", async () => { - const updateAgentStatus = spyOn( - workspaceService as unknown as { - updateAgentStatus: (workspaceId: string, status: null) => Promise; - }, - "updateAgentStatus" - ).mockResolvedValue(undefined); - - const result = await workspaceService.sendMessage( - "test-workspace", - "hello", - { - model: "openai:gpt-4o-mini", - agentId: "exec", - }, - { - synthetic: true, - } - ); - - expect(result.success).toBe(true); - expect(updateAgentStatus).not.toHaveBeenCalled(); - }); - - test("sendMessage restores interrupted task status before successful send", async () => { + // Send outcome drives interrupted-task rollback: a successful send keeps the + // restored running status; a failed or thrown send rolls it back. + test.each([ + ["sendMessage restores interrupted task status before successful send", "ok", true], + ["sendMessage restores interrupted status when resumed send fails", "err", false], + ["sendMessage restores interrupted status when resumed send throws", "throw", false], + ] as const)("%s", async (_name, sendOutcome, expectSuccess) => { fakeSession.isBusy.mockReturnValue(false); + if (sendOutcome === "err") { + fakeSession.sendMessage.mockResolvedValue( + Err({ type: "unknown" as const, raw: "runtime startup failed after user turn persisted" }) + ); + } else if (sendOutcome === "throw") { + fakeSession.sendMessage.mockRejectedValue(new Error("send explode")); + } const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); @@ -11936,7 +11908,6 @@ describe("WorkspaceService sendMessage status clearing", () => { makeAgentTaskIntegrationFake({ markInterruptedTaskRunning, restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), }) ); @@ -11945,9 +11916,13 @@ describe("WorkspaceService sendMessage status clearing", () => { agentId: "exec", }); - expect(result.success).toBe(true); + expect(result.success).toBe(expectSuccess); expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); - expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); + if (expectSuccess) { + expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); + } else { + expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); + } }); test("sendMessage restores interrupted status when accepted edit startup fails later", async () => { @@ -11996,29 +11971,18 @@ describe("WorkspaceService sendMessage status clearing", () => { expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); }); - test("resumeStream restores interrupted task status before successful resume", async () => { - const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); - const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - }) - ); - - const result = await workspaceService.resumeStream("test-workspace", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(true); - expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); - expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); - }); - - test("resumeStream keeps interrupted task status when no stream starts", async () => { - fakeSession.resumeStream.mockResolvedValue(Ok({ started: false })); + // Resume outcome drives interrupted-task rollback: only a resume that actually + // starts a stream keeps the restored running status. + test.each([ + ["resumeStream restores interrupted task status before successful resume", "started", true], + ["resumeStream keeps interrupted task status when no stream starts", "not-started", true], + ["resumeStream restores interrupted status when resumed stream throws", "throw", false], + ] as const)("%s", async (_name, resumeOutcome, expectSuccess) => { + if (resumeOutcome === "not-started") { + fakeSession.resumeStream.mockResolvedValue(Ok({ started: false })); + } else if (resumeOutcome === "throw") { + fakeSession.resumeStream.mockRejectedValue(new Error("resume explode")); + } const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); @@ -12026,7 +11990,6 @@ describe("WorkspaceService sendMessage status clearing", () => { makeAgentTaskIntegrationFake({ markInterruptedTaskRunning, restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), }) ); @@ -12035,54 +11998,35 @@ describe("WorkspaceService sendMessage status clearing", () => { agentId: "exec", }); - expect(result.success).toBe(true); - if (result.success) { + expect(result.success).toBe(expectSuccess); + if (resumeOutcome === "not-started" && result.success) { expect(result.data.started).toBe(false); } expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); - }); - - test("resumeStream does not start interrupted tasks while still busy", async () => { - const getAgentTaskStatus = mock(() => "interrupted" as const); - const markInterruptedTaskRunning = mock(() => Promise.resolve(false)); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus, - markInterruptedTaskRunning, - resetAutoResumeCount: mock(() => undefined), - }) - ); - - const result = await workspaceService.resumeStream("test-workspace", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(false); - if (!result.success && result.error.type === "unknown") { - expect(result.error.raw).toContain("Interrupted task is still winding down"); + if (resumeOutcome === "started") { + expect(restoreInterruptedTaskAfterResumeFailure).not.toHaveBeenCalled(); + } else { + expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); } - expect(getAgentTaskStatus).toHaveBeenCalledWith("test-workspace"); - expect(markInterruptedTaskRunning).not.toHaveBeenCalled(); - expect(fakeSession.resumeStream).not.toHaveBeenCalled(); }); - test("sendMessage does not queue interrupted tasks while still busy", async () => { + // Winding-down gate: an interrupted task that has not finished stopping + // refuses new work on both entry points without touching the session. + test.each([ + ["resumeStream does not start interrupted tasks while still busy", "resumeStream"], + ["sendMessage does not queue interrupted tasks while still busy", "sendMessage"], + ] as const)("%s", async (_name, entryPoint) => { const getAgentTaskStatus = mock(() => "interrupted" as const); const markInterruptedTaskRunning = mock(() => Promise.resolve(false)); workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus, - markInterruptedTaskRunning, - resetAutoResumeCount: mock(() => undefined), - }) + makeAgentTaskIntegrationFake({ getAgentTaskStatus, markInterruptedTaskRunning }) ); - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); + const options = { model: "openai:gpt-4o-mini", agentId: "exec" }; + const result = + entryPoint === "resumeStream" + ? await workspaceService.resumeStream("test-workspace", options) + : await workspaceService.sendMessage("test-workspace", "hello", options); expect(result.success).toBe(false); if (!result.success && result.error.type === "unknown") { @@ -12090,54 +12034,41 @@ describe("WorkspaceService sendMessage status clearing", () => { } expect(getAgentTaskStatus).toHaveBeenCalledWith("test-workspace"); expect(markInterruptedTaskRunning).not.toHaveBeenCalled(); + expect(fakeSession.resumeStream).not.toHaveBeenCalled(); expect(fakeSession.queueMessage).not.toHaveBeenCalled(); }); - test("queued user messages reset auto-resume state", async () => { - fakeSession.isBusy.mockReturnValue(true); - - const resetAutoResumeCount = mock(() => undefined); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus: mock(() => "running" as const), - resetAutoResumeCount, - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(true); - expect(fakeSession.queueMessage).toHaveBeenCalled(); - expect(resetAutoResumeCount).toHaveBeenCalledWith("test-workspace"); - }); - - test("synthetic queued auto-resume messages preserve auto-resume state", async () => { + // Queued sends reset the auto-resume counter unless the send is a synthetic + // auto-resume continuation that opted out. + test.each([ + ["queued user messages reset auto-resume state", undefined, true], + [ + "synthetic queued auto-resume messages preserve auto-resume state", + { skipAutoResumeReset: true, synthetic: true, agentInitiated: true }, + false, + ], + ] as const)("%s", async (_name, internal, expectReset) => { fakeSession.isBusy.mockReturnValue(true); const resetAutoResumeCount = mock(() => undefined); workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus: mock(() => "running" as const), - resetAutoResumeCount, - }) + makeAgentTaskIntegrationFake({ resetAutoResumeCount }) ); const result = await workspaceService.sendMessage( "test-workspace", - "await background work", - { - model: "openai:gpt-4o-mini", - agentId: "exec", - }, - { skipAutoResumeReset: true, synthetic: true, agentInitiated: true } + "hello", + { model: "openai:gpt-4o-mini", agentId: "exec" }, + internal ); expect(result.success).toBe(true); expect(fakeSession.queueMessage).toHaveBeenCalled(); - expect(resetAutoResumeCount).not.toHaveBeenCalled(); + if (expectReset) { + expect(resetAutoResumeCount).toHaveBeenCalledWith("test-workspace"); + } else { + expect(resetAutoResumeCount).not.toHaveBeenCalled(); + } }); test("strips stale workspace-turn correlation behind an earlier queued entry", async () => { @@ -12344,220 +12275,62 @@ describe("WorkspaceService sendMessage status clearing", () => { expect(await settled).toBeInstanceOf(Error); }); - test("backgrounds foreground task waits when queuing a tool-end message", async () => { - fakeSession.isBusy.mockReturnValue(true); - - const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(true); - expect(backgroundForegroundWaitsForWorkspace).toHaveBeenCalledWith("test-workspace"); - expect(fakeSession.queueMessage).toHaveBeenCalled(); - }); - - test("does not background foreground task waits when queuing a turn-end message", async () => { - fakeSession.isBusy.mockReturnValue(true); - fakeSession.queueMessage.mockReturnValue("turn-end"); - - const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - queueDispatchMode: "turn-end", - }); - - expect(result.success).toBe(true); - expect(backgroundForegroundWaitsForWorkspace).not.toHaveBeenCalled(); - expect(fakeSession.queueMessage).toHaveBeenCalled(); - }); - - test("does not background foreground task waits when queueMessage enqueues nothing", async () => { - fakeSession.isBusy.mockReturnValue(true); - fakeSession.queueMessage.mockReturnValue(null); - - const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", " ", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(true); - expect(backgroundForegroundWaitsForWorkspace).not.toHaveBeenCalled(); - }); - - test("backgrounds foreground task waits when effective queue mode is tool-end despite incoming turn-end", async () => { - fakeSession.isBusy.mockReturnValue(true); - // Incoming mode is turn-end but queue's effective mode is tool-end (sticky from prior enqueue) - fakeSession.queueMessage.mockReturnValue("tool-end"); - - const backgroundForegroundWaitsForWorkspace = mock(() => 0); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - queueDispatchMode: "turn-end", - }); - - expect(result.success).toBe(true); - expect(backgroundForegroundWaitsForWorkspace).toHaveBeenCalledWith("test-workspace"); - expect(fakeSession.queueMessage).toHaveBeenCalled(); - }); - - test("sendMessage restores interrupted status when resumed send fails", async () => { - fakeSession.isBusy.mockReturnValue(false); - fakeSession.sendMessage.mockResolvedValue( - Err({ - type: "unknown" as const, - raw: "runtime startup failed after user turn persisted", - }) - ); - - const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); - const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(false); - expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); - }); - - test("sendMessage restores interrupted status when resumed send throws", async () => { - fakeSession.isBusy.mockReturnValue(false); - fakeSession.sendMessage.mockRejectedValue(new Error("send explode")); - - const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); - const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - }) - ); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(false); - expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); - }); - - test("resumeStream restores interrupted status when resumed stream throws", async () => { - fakeSession.resumeStream.mockRejectedValue(new Error("resume explode")); - - const markInterruptedTaskRunning = mock(() => Promise.resolve(true)); - const restoreInterruptedTaskAfterResumeFailure = mock(() => Promise.resolve()); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - }) - ); - - const result = await workspaceService.resumeStream("test-workspace", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(false); - expect(markInterruptedTaskRunning).toHaveBeenCalledWith("test-workspace"); - expect(restoreInterruptedTaskAfterResumeFailure).toHaveBeenCalledWith("test-workspace"); - }); - - test("does not clear persisted agent status directly when direct send fails after turn acceptance", async () => { - fakeSession.isBusy.mockReturnValue(false); - fakeSession.sendMessage.mockResolvedValue( - Err({ - type: "unknown" as const, - raw: "runtime startup failed after user turn persisted", - }) - ); - - const updateAgentStatus = spyOn( - workspaceService as unknown as { - updateAgentStatus: (workspaceId: string, status: null) => Promise; - }, - "updateAgentStatus" - ).mockResolvedValue(undefined); - - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); - - expect(result.success).toBe(false); - expect(updateAgentStatus).not.toHaveBeenCalled(); - }); - - test("does not clear persisted agent status directly when direct send is rejected pre-acceptance", async () => { - fakeSession.isBusy.mockReturnValue(false); - fakeSession.sendMessage.mockResolvedValue( - Err({ - type: "invalid_model_string" as const, - message: "invalid model", - }) - ); + // The sticky case: incoming mode is turn-end but the queue's effective mode is + // tool-end from a prior enqueue, so the wait still backgrounds. + test.each([ + [ + "backgrounds foreground task waits when queuing a tool-end message", + "tool-end", + "hello", + undefined, + true, + ], + [ + "does not background foreground task waits when queuing a turn-end message", + "turn-end", + "hello", + "turn-end", + false, + ], + [ + "does not background foreground task waits when queueMessage enqueues nothing", + null, + " ", + undefined, + false, + ], + [ + "backgrounds foreground task waits when effective queue mode is tool-end despite incoming turn-end", + "tool-end", + "hello", + "turn-end", + true, + ], + ] as const)( + "%s", + async (_name, effectiveQueueMode, message, queueDispatchMode, expectBackgrounded) => { + fakeSession.isBusy.mockReturnValue(true); + fakeSession.queueMessage.mockReturnValue(effectiveQueueMode); - const updateAgentStatus = spyOn( - workspaceService as unknown as { - updateAgentStatus: (workspaceId: string, status: null) => Promise; - }, - "updateAgentStatus" - ).mockResolvedValue(undefined); + const backgroundForegroundWaitsForWorkspace = mock(() => 0); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ backgroundForegroundWaitsForWorkspace }) + ); - const result = await workspaceService.sendMessage("test-workspace", "hello", { - model: "openai:gpt-4o-mini", - agentId: "exec", - }); + const result = await workspaceService.sendMessage("test-workspace", message, { + model: "openai:gpt-4o-mini", + agentId: "exec", + queueDispatchMode, + }); - expect(result.success).toBe(false); - expect(updateAgentStatus).not.toHaveBeenCalled(); - }); + expect(result.success).toBe(true); + if (expectBackgrounded) { + expect(backgroundForegroundWaitsForWorkspace).toHaveBeenCalledWith("test-workspace"); + } else { + expect(backgroundForegroundWaitsForWorkspace).not.toHaveBeenCalled(); + } + } + ); test("registerSession clears persisted agent status for accepted user chat events", () => { const updateAgentStatus = spyOn( @@ -16554,27 +16327,6 @@ describe("WorkspaceService archive lifecycle hooks", () => { await cleanupHistory(); }); - test("archive coordinates through the task-tree lifecycle lock", async () => { - const withTaskTreeLifecycleLock = mock( - (_workspaceId: string, _operation: () => Promise) => undefined - ); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - withTaskTreeLifecycleLock: ( - workspaceId: string, - operation: () => Promise - ): Promise => { - withTaskTreeLifecycleLock(workspaceId, operation); - return operation(); - }, - hasActiveDescendantAgentTasksForWorkspace: mock(() => false), - }) - ); - - expect(await workspaceService.archive(workspaceId)).toEqual(Ok({ kind: "archived" })); - expect(withTaskTreeLifecycleLock).toHaveBeenCalledWith(workspaceId, expect.any(Function)); - }); - test("archive refuses to hide a parent while descendant sub-agents remain active", async () => { const hasActiveDescendantAgentTasksForWorkspace = mock(() => true); workspaceService.setAgentTaskIntegration( @@ -16775,23 +16527,6 @@ describe("WorkspaceService archive lifecycle hooks", () => { expect(entry?.archivedAt).toBeTruthy(); }); - test("archive() does not trigger irreversible descendant cleanup", async () => { - const cleanupReportedDescendantsAfterArchive = mock(() => Promise.resolve()); - workspaceService.setAgentTaskIntegration( - makeAgentTaskIntegrationFake({ - cleanupReportedDescendantsAfterArchive, - hasActiveDescendantAgentTasksForWorkspace: () => false, - }) - ); - - const result = await workspaceService.archive(workspaceId); - - expect(result).toEqual(Ok({ kind: "archived" })); - expect(cleanupReportedDescendantsAfterArchive).not.toHaveBeenCalled(); - const entry = configState.projects.get(projectPath)?.workspaces[0]; - expect(entry?.archivedAt).toBeTruthy(); - }); - test("archive() honors the caller's pinned Coder policy over a flipped config read", async () => { // Dedicated (mux-created) Coder workspace: the remote-deletion guard only applies to these. (mockAIService.getWorkspaceMetadata as ReturnType).mockReturnValue( From c3b03030ed67ffea7e59d6b1c6c38ee39d373809 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:09:18 +0000 Subject: [PATCH 19/20] test(services): dedupe taskService suite scaffolding against the typed host port createWorkspaceServiceMocks loses its dead stubs (waitForIdle, deleteWorktree, updateAgentStatus never existed on WorkspaceHost), its hand-written 37-line return annotation, and return entries nothing consumes; the removeQueuedMessagesByDedupeKeyPrefix override is wired instead of the one call site mutating the built host. 23 copy-pasted createWorkspace mock blocks collapse into two shared helpers, and the redundant conditional-spread forwarding in both harnesses becomes direct pass-through since the factory already defaults absent overrides. --- src/node/services/taskService.test.ts | 456 ++++---------------------- 1 file changed, 67 insertions(+), 389 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index dfbcf45287f..cd38c31a395 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -534,7 +534,6 @@ function createWorkspaceServiceMocks( getQueueCutCutter: ReturnType; hasPendingAutoRetry: ReturnType; waitForIdleAndNoQueuedMessages: ReturnType; - waitForIdle: ReturnType; waitForPendingCompactionCompletionDecision: ReturnType; waitForPendingStreamErrorRecoveryDecision: ReturnType; archive: ReturnType; @@ -545,57 +544,18 @@ function createWorkspaceServiceMocks( isSnapshotArchiveEligibilityMutationSensitive: ReturnType; hasUntrackableExternalAppOpen: ReturnType; acquirePreInterruptionArchiveHold: ReturnType; - deleteWorktree: ReturnType; remove: ReturnType; emit: ReturnType; getInfo: ReturnType; replaceHistory: ReturnType; updateTitle: ReturnType; - updateAgentStatus: ReturnType; isExperimentEnabled: ReturnType; emitChatEvent: ReturnType; isWorkflowInvocationCurrent: ReturnType; create: ReturnType; countQueuedAgentPeerMessages: ReturnType; }> -): { - workspaceService: WorkspaceHost; - sendMessage: ReturnType; - resumeStream: ReturnType; - clearQueue: ReturnType; - removeQueuedWorkspaceTurn: ReturnType; - removeQueuedMessagesByDedupeKeyPrefix: ReturnType; - hasQueuedWorkspaceTurn: ReturnType; - hasQueuedMessages: ReturnType; - isBusyForMessage: ReturnType; - waitForIdleAndNoQueuedMessages: ReturnType; - waitForIdle: ReturnType; - hasPendingQueuedOrPreparingTurn: ReturnType; - hasPendingWorkspaceTurnContinuation: ReturnType; - getQueueCutCutter: ReturnType; - hasPendingAutoRetry: ReturnType; - waitForPendingCompactionCompletionDecision: ReturnType; - waitForPendingStreamErrorRecoveryDecision: ReturnType; - archive: ReturnType; - unarchive: ReturnType; - preflightArchive: ReturnType; - listLiveWorkspaceActivity: ReturnType; - hasRunningBackgroundBashProcesses: ReturnType; - isSnapshotArchiveEligibilityMutationSensitive: ReturnType; - hasUntrackableExternalAppOpen: ReturnType; - deleteWorktree: ReturnType; - remove: ReturnType; - emit: ReturnType; - getInfo: ReturnType; - replaceHistory: ReturnType; - updateTitle: ReturnType; - updateAgentStatus: ReturnType; - isExperimentEnabled: ReturnType; - emitChatEvent: ReturnType; - isWorkflowInvocationCurrent: ReturnType; - create: ReturnType; - discardExtensionMetadataEntry: ReturnType; -} { +) { const sendMessage = overrides?.sendMessage ?? mock((): Promise> => Promise.resolve(Ok(undefined))); const resumeStream = @@ -604,7 +564,8 @@ function createWorkspaceServiceMocks( const clearQueue = overrides?.clearQueue ?? mock((): Result => Ok(undefined)); const removeQueuedWorkspaceTurn = overrides?.removeQueuedWorkspaceTurn ?? mock((): Result => Ok(true)); - const removeQueuedMessagesByDedupeKeyPrefix = mock((): Result => Ok(0)); + const removeQueuedMessagesByDedupeKeyPrefix = + overrides?.removeQueuedMessagesByDedupeKeyPrefix ?? mock((): Result => Ok(0)); const hasQueuedWorkspaceTurn = overrides?.hasQueuedWorkspaceTurn ?? mock(() => false); const hasQueuedMessages = overrides?.hasQueuedMessages ?? mock(() => false); const isBusyForMessage = overrides?.isBusyForMessage ?? mock(() => false); @@ -618,7 +579,6 @@ function createWorkspaceServiceMocks( const hasPendingAutoRetry = overrides?.hasPendingAutoRetry ?? mock(() => false); const waitForIdleAndNoQueuedMessages = overrides?.waitForIdleAndNoQueuedMessages ?? mock((): Promise => Promise.resolve()); - const waitForIdle = overrides?.waitForIdle ?? mock((): Promise => Promise.resolve()); const waitForPendingCompactionCompletionDecision = overrides?.waitForPendingCompactionCompletionDecision ?? mock((): Promise => Promise.resolve(true)); @@ -651,8 +611,6 @@ function createWorkspaceServiceMocks( overrides?.isSnapshotArchiveEligibilityMutationSensitive ?? mock(() => false); const hasUntrackableExternalAppOpen = overrides?.hasUntrackableExternalAppOpen ?? mock(() => false); - const deleteWorktree = - overrides?.deleteWorktree ?? mock((): Promise> => Promise.resolve(Ok(undefined))); const remove = overrides?.remove ?? mock((): Promise> => Promise.resolve(Ok(undefined))); const emit = overrides?.emit ?? mock(() => true); @@ -661,8 +619,6 @@ function createWorkspaceServiceMocks( overrides?.replaceHistory ?? mock((): Promise> => Promise.resolve(Ok(undefined))); const updateTitle = overrides?.updateTitle ?? mock((): Promise> => Promise.resolve(Ok(undefined))); - const updateAgentStatus = - overrides?.updateAgentStatus ?? mock((): Promise => Promise.resolve()); const isExperimentEnabled = overrides?.isExperimentEnabled ?? mock(() => false); const emitChatEvent = overrides?.emitChatEvent ?? @@ -739,38 +695,45 @@ function createWorkspaceServiceMocks( resumeStream, clearQueue, removeQueuedWorkspaceTurn, - removeQueuedMessagesByDedupeKeyPrefix, - hasQueuedWorkspaceTurn, - hasQueuedMessages, isBusyForMessage, - hasPendingQueuedOrPreparingTurn, - hasPendingWorkspaceTurnContinuation, getQueueCutCutter, - hasPendingAutoRetry, - waitForIdleAndNoQueuedMessages, - waitForIdle, - waitForPendingCompactionCompletionDecision, - waitForPendingStreamErrorRecoveryDecision, - archive, - unarchive, - preflightArchive, - listLiveWorkspaceActivity, - hasRunningBackgroundBashProcesses, - isSnapshotArchiveEligibilityMutationSensitive, - hasUntrackableExternalAppOpen, - deleteWorktree, remove, - emit, - getInfo, - replaceHistory, updateTitle, - updateAgentStatus, - isExperimentEnabled, emitChatEvent, + emit, + archive, + unarchive, isWorkflowInvocationCurrent, }; } +// Registers the created workspace-turn checkout in config the way the real create() +// would, so handle persistence and cleanup paths see a config entry. +function makeWorkspaceTurnCreateMock(config: Config, projectPath: string) { + return mock(async (...args: unknown[]): Promise> => { + const tags = args[7] as Record | undefined; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; + }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + }); +} + +function makeCreateMockReturning(result: Result<{ metadata: WorkspaceMetadata }>) { + return mock((): Promise> => Promise.resolve(result)); +} + function createTaskServiceHarness( config: Config, overrides?: { @@ -950,57 +913,8 @@ describe("TaskService", () => { stubStableIds(config, options.stableIds ?? ["handle", "turn"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); - const workspaceMocks = createWorkspaceServiceMocks({ - create: createWorkspace, - ...(options.sendMessage != null ? { sendMessage: options.sendMessage } : {}), - ...(options.remove != null ? { remove: options.remove } : {}), - ...(options.hasQueuedMessages != null - ? { hasQueuedMessages: options.hasQueuedMessages } - : {}), - ...(options.hasPendingQueuedOrPreparingTurn != null - ? { hasPendingQueuedOrPreparingTurn: options.hasPendingQueuedOrPreparingTurn } - : {}), - ...(options.hasPendingBashMonitorWakeContinuation != null - ? { - hasPendingBashMonitorWakeContinuation: options.hasPendingBashMonitorWakeContinuation, - } - : {}), - ...(options.hasPendingWorkspaceTurnContinuation != null - ? { hasPendingWorkspaceTurnContinuation: options.hasPendingWorkspaceTurnContinuation } - : {}), - ...(options.getQueueCutCutter != null - ? { getQueueCutCutter: options.getQueueCutCutter } - : {}), - ...(options.hasPendingAutoRetry != null - ? { hasPendingAutoRetry: options.hasPendingAutoRetry } - : {}), - ...(options.waitForPendingStreamErrorRecoveryDecision != null - ? { - waitForPendingStreamErrorRecoveryDecision: - options.waitForPendingStreamErrorRecoveryDecision, - } - : {}), - }); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); + const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, ...options }); const aiMocks = createAIServiceMocks(config, { ...(options.isStreaming != null ? { isStreaming: options.isStreaming } : {}), }); @@ -1069,27 +983,7 @@ describe("TaskService", () => { return cfg; }); - const workspaceMocks = createWorkspaceServiceMocks({ - ...(options.archive != null ? { archive: options.archive } : {}), - ...(options.unarchive != null ? { unarchive: options.unarchive } : {}), - ...(options.preflightArchive != null ? { preflightArchive: options.preflightArchive } : {}), - ...(options.listLiveWorkspaceActivity != null - ? { listLiveWorkspaceActivity: options.listLiveWorkspaceActivity } - : {}), - ...(options.hasRunningBackgroundBashProcesses != null - ? { hasRunningBackgroundBashProcesses: options.hasRunningBackgroundBashProcesses } - : {}), - ...(options.isSnapshotArchiveEligibilityMutationSensitive != null - ? { - isSnapshotArchiveEligibilityMutationSensitive: - options.isSnapshotArchiveEligibilityMutationSensitive, - } - : {}), - ...(options.hasUntrackableExternalAppOpen != null - ? { hasUntrackableExternalAppOpen: options.hasUntrackableExternalAppOpen } - : {}), - ...(options.create != null ? { create: options.create } : {}), - }); + const workspaceMocks = createWorkspaceServiceMocks(options); const { taskService } = createTaskServiceHarness(config, { workspaceService: workspaceMocks.workspaceService, }); @@ -2598,26 +2492,7 @@ describe("TaskService", () => { stubStableIds(config, ["childworkspace", "turnhandle"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -2663,9 +2538,8 @@ describe("TaskService", () => { stubStableIds(config, ["childworkspace", "turnhandle"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: createWorkspaceTurnMetadata(projectPath) })) + const createWorkspace = makeCreateMockReturning( + Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }) ); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); @@ -2726,10 +2600,7 @@ describe("TaskService", () => { runtimeConfig: { type: "worktree", srcBaseDir: path.join(rootDir, "wt") }, namedWorkspacePath: cleanCheckout, }; - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: targetMetadata })) - ); + const createWorkspace = makeCreateMockReturning(Ok({ metadata: targetMetadata })); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -2756,9 +2627,8 @@ describe("TaskService", () => { const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); checkoutOwnerBranch(projectPath, "parent"); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: createWorkspaceTurnMetadata(projectPath) })) + const createWorkspace = makeCreateMockReturning( + Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }) ); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); @@ -2791,9 +2661,8 @@ describe("TaskService", () => { const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); checkoutOwnerBranch(projectPath, "parent"); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: createWorkspaceTurnMetadata(projectPath) })) + const createWorkspace = makeCreateMockReturning( + Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }) ); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); @@ -2846,9 +2715,8 @@ describe("TaskService", () => { await writeCustomAgentDefinition(projectPath); commitOwnerAgentFiles(projectPath); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: createWorkspaceTurnMetadata(projectPath) })) + const createWorkspace = makeCreateMockReturning( + Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }) ); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); @@ -2970,9 +2838,8 @@ describe("TaskService", () => { ); commitOwnerAgentFiles(projectPath); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: createWorkspaceTurnMetadata(projectPath) })) + const createWorkspace = makeCreateMockReturning( + Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }) ); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); @@ -3025,10 +2892,7 @@ describe("TaskService", () => { runtimeConfig: { type: "worktree", srcBaseDir: path.join(rootDir, "wt") }, namedWorkspacePath: targetBranchCheckout, }; - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: targetMetadata })) - ); + const createWorkspace = makeCreateMockReturning(Ok({ metadata: targetMetadata })); const sendMessage = mock( (..._args: unknown[]): Promise> => Promise.resolve(Ok(undefined)) ); @@ -3062,10 +2926,7 @@ describe("TaskService", () => { runtimeConfig: { type: "worktree", srcBaseDir: path.join(rootDir, "wt") }, namedWorkspacePath: path.join(rootDir, "not-provisioned-branch"), }; - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: unreachableMetadata })) - ); + const createWorkspace = makeCreateMockReturning(Ok({ metadata: unreachableMetadata })); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -3162,10 +3023,7 @@ describe("TaskService", () => { runtimeConfig: { type: "worktree", srcBaseDir: path.join(rootDir, "wt") }, namedWorkspacePath: path.join(rootDir, "not-provisioned-collision"), }; - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: unreachableMetadata })) - ); + const createWorkspace = makeCreateMockReturning(Ok({ metadata: unreachableMetadata })); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -3224,10 +3082,7 @@ describe("TaskService", () => { runtimeConfig: { type: "worktree", srcBaseDir: path.join(rootDir, "wt") }, namedWorkspacePath: targetOnlyCheckout, }; - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Ok({ metadata: targetMetadata })) - ); + const createWorkspace = makeCreateMockReturning(Ok({ metadata: targetMetadata })); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -3566,26 +3421,7 @@ describe("TaskService", () => { testTaskSettings() ); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -3622,26 +3458,7 @@ describe("TaskService", () => { agentAiDefaults: { exec: { modelString: "openai:gpt-5.2", thinkingLevel: "xhigh" } }, }); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock(async (...args: unknown[]): Promise> => { const internal = args[3] as { onAccepted?: () => Promise | void } | undefined; await internal?.onAccepted?.(); @@ -3743,26 +3560,7 @@ describe("TaskService", () => { testTaskSettings() ); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock(async (...args: unknown[]): Promise> => { const internal = args[3] as { onAccepted?: () => Promise | void } | undefined; await internal?.onAccepted?.(); @@ -3838,10 +3636,7 @@ describe("TaskService", () => { extraProjects: [[secondaryProjectPath, { trusted: true, workspaces: [] }]], } ); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Err("should not create workspace")) - ); + const createWorkspace = makeCreateMockReturning(Err("should not create workspace")); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace }); const { taskService } = createTaskServiceHarness(config, { workspaceService: workspaceMocks.workspaceService, @@ -3864,10 +3659,7 @@ describe("TaskService", () => { const config = await createTestConfig(rootDir); stubStableIds(config, ["handle", "turn"]); const { parentId } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - (): Promise> => - Promise.resolve(Err("should not create workspace")) - ); + const createWorkspace = makeCreateMockReturning(Err("should not create workspace")); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace }); const { taskService } = createTaskServiceHarness(config, { workspaceService: workspaceMocks.workspaceService, @@ -3914,26 +3706,7 @@ describe("TaskService", () => { stubStableIds(config, ["firsthandle", "firstturn", "secondhandle", "secondturn"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock(async (...args: unknown[]): Promise> => { const internal = args[3] as { onAccepted?: () => Promise | void } | undefined; await internal?.onAccepted?.(); @@ -4877,26 +4650,7 @@ describe("TaskService", () => { return cfg; }); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock( (..._args: unknown[]): Promise> => Promise.resolve(Ok(undefined)) @@ -5100,26 +4854,7 @@ describe("TaskService", () => { return cfg; }); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -5410,26 +5145,7 @@ describe("TaskService", () => { stubStableIds(config, ["handle", "turn"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -10875,26 +10591,7 @@ describe("TaskService", () => { stubStableIds(config, ["handle", "turn"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -10939,26 +10636,7 @@ describe("TaskService", () => { stubStableIds(config, ["handle", "turn"]); const { parentId, projectPath } = await saveLocalParentWorkspace(config, rootDir); - const createWorkspace = mock( - async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, - }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - } - ); + const createWorkspace = makeWorkspaceTurnCreateMock(config, projectPath); const sendMessage = mock((): Promise> => Promise.resolve(Ok(undefined))); const workspaceMocks = createWorkspaceServiceMocks({ create: createWorkspace, sendMessage }); const { taskService } = createTaskServiceHarness(config, { @@ -27664,8 +27342,9 @@ describe("TaskService", () => { ); const removeQueuedMessagesByDedupeKeyPrefix = mock((): Result => Ok(1)); - const { workspaceService } = createWorkspaceServiceMocks(); - workspaceService.removeQueuedMessagesByDedupeKeyPrefix = removeQueuedMessagesByDedupeKeyPrefix; + const { workspaceService } = createWorkspaceServiceMocks({ + removeQueuedMessagesByDedupeKeyPrefix, + }); const { taskService } = createTaskServiceHarness(config, { workspaceService }); await handleTaskServiceStreamEndForTest(taskService, { @@ -30264,7 +29943,7 @@ describe("TaskService", () => { namedWorkspacePath: childWorkspacePath, })); const replaceHistory = mock((): Promise> => Promise.resolve(Ok(undefined))); - const { workspaceService, sendMessage, updateAgentStatus } = createWorkspaceServiceMocks({ + const { workspaceService, sendMessage } = createWorkspaceServiceMocks({ getInfo, replaceHistory, sendMessage: options?.sendMessageOverride, @@ -30284,7 +29963,6 @@ describe("TaskService", () => { sendMessage, replaceHistory, createModel, - updateAgentStatus, taskService, internal, }; From e05dc05acb9150ccd4f3518be9533b25af19df73 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:45:08 +0000 Subject: [PATCH 20/20] test(services): apply deslop and simplify audit findings Drops fake overrides that restate makeAgentTaskIntegrationFake defaults, types makeWorkspaceTurnCreateMock's rest args via Parameters instead of an unknown[] cast, and calls withTaskTreeLifecycleLock directly rather than through a bound closure. --- src/node/services/taskService.test.ts | 38 ++++++++++++---------- src/node/services/workspaceService.test.ts | 2 -- src/node/services/workspaceService.ts | 5 +-- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index cd38c31a395..68e80c83d84 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -710,24 +710,28 @@ function createWorkspaceServiceMocks( // Registers the created workspace-turn checkout in config the way the real create() // would, so handle persistence and cleanup paths see a config entry. function makeWorkspaceTurnCreateMock(config: Config, projectPath: string) { - return mock(async (...args: unknown[]): Promise> => { - const tags = args[7] as Record | undefined; - await config.editConfig((cfg) => { - const project = cfg.projects.get(projectPath); - assert(project, "test project must exist"); - project.workspaces.push({ - path: path.join(projectPath, "workspace-turn"), - id: "childworkspace", - name: "workspace-turn", - title: "Workspace turn", - createdAt: "2026-06-19T00:00:00.000Z", - runtimeConfig: { type: "local" }, - tags, + return mock( + async ( + ...args: Parameters + ): Promise> => { + const tags = args[7]; + await config.editConfig((cfg) => { + const project = cfg.projects.get(projectPath); + assert(project, "test project must exist"); + project.workspaces.push({ + path: path.join(projectPath, "workspace-turn"), + id: "childworkspace", + name: "workspace-turn", + title: "Workspace turn", + createdAt: "2026-06-19T00:00:00.000Z", + runtimeConfig: { type: "local" }, + tags, + }); + return cfg; }); - return cfg; - }); - return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); - }); + return Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); } function makeCreateMockReturning(result: Result<{ metadata: WorkspaceMetadata }>) { diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index aae7c6a0f5f..fb98eb0e635 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -11934,7 +11934,6 @@ describe("WorkspaceService sendMessage status clearing", () => { makeAgentTaskIntegrationFake({ markInterruptedTaskRunning, restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), }) ); @@ -16761,7 +16760,6 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("archive() rechecks durably active workflow runs after arming the admission gate", async () => { workspaceService.setAgentTaskIntegration( makeAgentTaskIntegrationFake({ - hasActiveDescendantAgentTasksForWorkspace: mock(() => false), hasActiveTopLevelWorkflowRunsForWorkspace: mock(() => Promise.resolve(true)), }) ); diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 6c17290fe46..492412ac31b 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -6143,8 +6143,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { operation: () => Promise ): Promise { const integration = this.agentTaskIntegration; - const withLock = integration?.withTaskTreeLifecycleLock.bind(integration); - return withLock == null ? await operation() : await withLock(workspaceId, operation); + return integration == null + ? await operation() + : await integration.withTaskTreeLifecycleLock(workspaceId, operation); } async remove(workspaceId: string, force = false): Promise> {