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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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/42] 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> { From 3071b1aabbc5cdb7159b22e0909e9faef43c0b98 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:10:20 +0000 Subject: [PATCH 21/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20col?= =?UTF-8?q?lapse=20turn=20relay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merge the AIService and StreamManager lifecycle seam behind a typed event sink, options object, and exactly-once turn completion handle. AgentSession now handles post-start failures only from completion and no longer keeps stream failure dedupe latches. --- .../agentSession.autoCompaction.test.ts | 63 +- .../agentSession.editMessageId.test.ts | 17 +- .../agentSession.goalAutoPause.test.ts | 36 +- .../agentSession.postCompactionRetry.test.ts | 57 +- .../agentSession.preStreamError.test.ts | 30 +- .../agentSession.startupAutoRetry.test.ts | 48 +- src/node/services/agentSession.testHarness.ts | 29 +- src/node/services/agentSession.ts | 138 +++-- src/node/services/aiService.test.ts | 128 ++--- src/node/services/aiService.ts | 311 ++++++---- ...reamManager.modelOnlyNotifications.test.ts | 36 +- src/node/services/streamManager.test.ts | 542 ++++++++++++------ src/node/services/streamManager.ts | 314 +++++++--- 13 files changed, 1177 insertions(+), 572 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 3ef773f3396..fd546f0ad63 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -15,6 +15,7 @@ import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; import { Ok, Err } from "@/common/types/result"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; +import type { TurnStreamHandle } from "./streamManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { InitStateManager } from "@/node/services/initStateManager"; import { AgentSession } from "./agentSession"; @@ -22,6 +23,14 @@ import type { CompactionMonitor } from "./compactionMonitor"; import { createAgentSessionHarness } from "./agentSession.testHarness"; import { createTestHistoryService } from "./testHistoryService"; +function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: new Promise(() => undefined), + }; +} + describe("AgentSession on-send auto-compaction snapshot deferral", () => { let historyCleanup: (() => Promise) | undefined; @@ -48,7 +57,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("does not persist or emit snapshots before forced on-send compaction", async () => { const workspaceId = "ws-auto-compaction-snapshot-deferral"; - const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_history: MuxMessage[]) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session, historyService, events, backgroundProcessManager } = await createSessionHarness({ workspaceId, @@ -182,7 +193,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("does not materialize skill snapshots (or run their directives) on deferred on-send compaction turns", async () => { const workspaceId = "ws-auto-compaction-skill-snapshot-deferral"; - const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_history: MuxMessage[]) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session } = await createSessionHarness({ workspaceId, streamMessage: streamMessage as unknown as AIService["streamMessage"], @@ -234,7 +247,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { workspaceId: string; experiments?: SendMessageOptions["experiments"]; }) => { - const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_history: MuxMessage[]) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session, historyService } = await createSessionHarness({ workspaceId: args.workspaceId, streamMessage: streamMessage as unknown as AIService["streamMessage"], @@ -342,7 +357,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { const streamRequests: unknown[] = []; const streamMessage = mock((request: unknown) => { streamRequests.push(request); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session } = await createSessionHarness({ workspaceId, @@ -386,7 +401,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { const streamRequests: unknown[] = []; const streamMessage = mock((request: unknown) => { streamRequests.push(request); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const compactionModel = "openai:gpt-4o-mini"; const config = { @@ -440,7 +455,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { const streamRequests: unknown[] = []; const streamMessage = mock((request: unknown) => { streamRequests.push(request); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session, historyService } = await createSessionHarness({ workspaceId, @@ -503,7 +518,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { const streamRequests: unknown[] = []; const streamMessage = mock((request: unknown) => { streamRequests.push(request); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session, historyService } = await createSessionHarness({ workspaceId, @@ -562,7 +577,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction model inherit uses caller-provided baseOptions.model when no preferred model configured", async () => { const workspaceId = "ws-auto-compaction-inherit-base-options-model"; - const streamMessage = mock((_request: unknown) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_request: unknown) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session } = await createSessionHarness({ workspaceId, streamMessage: streamMessage as unknown as AIService["streamMessage"], @@ -611,7 +628,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("clears strictAgentResolution on the internal compact request", async () => { const workspaceId = "ws-auto-compaction-clears-strict"; - const streamMessage = mock((_request: unknown) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_request: unknown) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session } = await createSessionHarness({ workspaceId, streamMessage: streamMessage as unknown as AIService["streamMessage"], @@ -654,7 +673,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction model explicit override takes priority over baseOptions.model", async () => { const workspaceId = "ws-auto-compaction-explicit-model-overrides-base-model"; - const streamMessage = mock((_request: unknown) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_request: unknown) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const compactionModel = "openai:gpt-5.5"; const config = { srcDir: "/tmp", @@ -711,7 +732,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction thinking level prefers compact agent default over baseOptions", async () => { const workspaceId = "ws-auto-compaction-compact-thinking-default"; - const streamMessage = mock((_request: unknown) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_request: unknown) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const config = { srcDir: "/tmp", getSessionDir: (_workspaceId: string) => "/tmp", @@ -764,7 +787,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction thinking level falls back to baseOptions when compact default is unset", async () => { const workspaceId = "ws-auto-compaction-base-thinking-fallback"; - const streamMessage = mock((_request: unknown) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_request: unknown) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session } = await createSessionHarness({ workspaceId, streamMessage: streamMessage as unknown as AIService["streamMessage"], @@ -846,7 +871,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }, }); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const aiService = Object.assign(aiEmitter, { @@ -966,10 +991,12 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { expect(appendCurrentEpochUser.success).toBe(true); const aiEmitter = new EventEmitter(); - const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_history: MuxMessage[]) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const aiService = Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), - stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(createStartedTurnHandle()))), streamMessage: streamMessage as unknown as ( ...args: Parameters ) => Promise, @@ -1062,7 +1089,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }); } - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const stopStream = mock((_workspaceId: string) => { @@ -1210,7 +1237,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }); } - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const stopStream = mock((_workspaceId: string) => { @@ -1386,7 +1413,7 @@ describe("AgentSession on-send auto-compaction for synthetic guidance sends", () }, }); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const harness = await createAgentSessionHarness({ diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index 146f494a541..3f7796c4bc4 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -5,15 +5,22 @@ import type { InitStateManager } from "@/node/services/initStateManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { Config } from "@/node/config"; import { createMuxMessage } from "@/common/types/message"; -import type { SendMessageError } from "@/common/types/errors"; -import type { Result } from "@/common/types/result"; import { Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; +import type { TurnStreamHandle } from "./streamManager"; import { createTestHistoryService } from "./testHistoryService"; type StreamMessageHandler = AIService["streamMessage"]; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; + +function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: new Promise(() => undefined), + }; +} const config = { srcDir: "/tmp", getSessionDir: (_workspaceId: string) => "/tmp", @@ -35,7 +42,7 @@ describe("AgentSession.sendMessage (editMessageId)", () => { async function createSessionHarness( workspaceId: string, - streamHandler: StreamMessageHandler = () => Promise.resolve(Ok(undefined)) + streamHandler: StreamMessageHandler = () => Promise.resolve(Ok(createStartedTurnHandle())) ) { const { historyService, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; @@ -282,8 +289,8 @@ describe("AgentSession.sendMessage (editMessageId)", () => { const workspaceId = "ws-edit-preparing"; const streamResolves: Array<() => void> = []; const streamHandler: StreamMessageHandler = (opts) => { - return new Promise>((resolve) => { - const resolveOk = () => resolve(Ok(undefined)); + return new Promise>>((resolve) => { + const resolveOk = () => resolve(Ok(createStartedTurnHandle())); if (opts.abortSignal?.aborted === true) { resolveOk(); return; diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 7ba6a05ecfa..b3cafd51d9f 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import type { AIService } from "./aiService"; +import type { TurnStreamHandle } from "./streamManager"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import { ExtensionMetadataService } from "./ExtensionMetadataService"; import type { HistoryService } from "./historyService"; @@ -20,6 +21,27 @@ import { import { waitForCondition } from "./testDispatchHelpers"; import { IdleDispatcher } from "./idleDispatcher"; +function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: new Promise(() => undefined), + }; +} + +function createFailedTurnHandle(messageId: string, error: string): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: Promise.resolve({ + status: "failed", + messageId, + error: { type: "unknown", raw: error }, + streamError: { messageId, error, errorType: "unknown" }, + }), + }; +} + const PROJECT_PATH = "/tmp/mux-agent-session-goal-test-project"; const SEND_OPTIONS: SendMessageOptions = { model: "openai:gpt-4o", agentId: "exec" }; @@ -50,7 +72,7 @@ function createAiService(workspaceId: string): AIService & EventEmitter { return Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), - streamMessage: mock((_request: unknown) => Promise.resolve(Ok(undefined))), + streamMessage: mock((_request: unknown) => Promise.resolve(Ok(createStartedTurnHandle()))), getStreamInfo: mock((_workspaceId: string) => null), getProvidersConfig: mock(() => null), getWorkspaceMetadata: mock((_workspaceId: string) => @@ -1001,7 +1023,7 @@ describe("AgentSession goal safety hooks", () => { error: "boom", errorType: "unknown", }); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createFailedTurnHandle("assistant-stream-error", "boom"))); }) as unknown as AIService["streamMessage"]; const eventTypes: string[] = []; session.onChatEvent((event) => { @@ -1181,7 +1203,7 @@ describe("AgentSession goal safety hooks", () => { emitStreamEnd(aiService, workspaceId, "assistant-silent", [ { type: "text", text: "I believe everything is done already." }, ]); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }) as unknown as AIService["streamMessage"]; const result = await session.sendMessage("Synthetic continuation", SEND_OPTIONS, { @@ -1228,7 +1250,7 @@ describe("AgentSession goal safety hooks", () => { output: { ok: true }, }, ]); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }) as unknown as AIService["streamMessage"]; const result = await session.sendMessage("Synthetic continuation", SEND_OPTIONS, { @@ -1253,7 +1275,7 @@ describe("AgentSession goal safety hooks", () => { emitStreamEnd(aiService, workspaceId, "assistant-text-only", [ { type: "text", text: "Just thinking out loud." }, ]); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }) as unknown as AIService["streamMessage"]; // Manual user messages now pause active goals because the goal mode is @@ -1291,7 +1313,7 @@ describe("AgentSession goal safety hooks", () => { [{ type: "text", text: "Mid-sentence, then cut off by the token limit" }], { finishReason: "length" } ); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }) as unknown as AIService["streamMessage"]; const result = await session.sendMessage("Synthetic continuation", SEND_OPTIONS, { @@ -1323,7 +1345,7 @@ describe("AgentSession goal safety hooks", () => { emitStreamEnd(aiService, workspaceId, "assistant-paused-silent", [ { type: "text", text: "All wrapped up." }, ]); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }) as unknown as AIService["streamMessage"]; const result = await session.sendMessage("Synthetic continuation", SEND_OPTIONS, { diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index d4c39fc3b94..e255eb14bac 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -7,6 +7,7 @@ import * as path from "path"; import { AgentSession } from "./agentSession"; import type { Config } from "@/node/config"; import type { AIService } from "./aiService"; +import type { TurnStreamHandle } from "./streamManager"; import type { InitStateManager } from "./initStateManager"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; @@ -14,6 +15,25 @@ import type { MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; import { createTestHistoryService } from "./testHistoryService"; +function createTurnHandle( + messageId: string, + failure?: { error: string; errorType: "context_exceeded" | "model_refusal" } +): TurnStreamHandle { + return { + streamToken: "token-" + messageId, + messageId, + completion: + failure == null + ? new Promise(() => undefined) + : Promise.resolve({ + status: "failed" as const, + messageId, + error: { type: "unknown" as const, raw: failure.error }, + streamError: { messageId, ...failure }, + }), + }; +} + function createPersistedPostCompactionState(options: { filePath: string; diffs: Array<{ path: string; diff: string; truncated: boolean }>; @@ -91,11 +111,20 @@ describe("AgentSession post-compaction context retry", () => { errorType: "context_exceeded", }); - return Promise.resolve({ success: true as const, data: undefined }); + return Promise.resolve({ + success: true as const, + data: createTurnHandle("assistant-ctx-exceeded", { + error: "Context length exceeded", + errorType: "context_exceeded", + }), + }); } resolveSecondCall?.(); - return Promise.resolve({ success: true as const, data: undefined }); + return Promise.resolve({ + success: true as const, + data: createTurnHandle("assistant-retry"), + }); }); const aiService: AIService = { @@ -237,7 +266,13 @@ describe("AgentSession post-compaction context retry", () => { error: "Context length exceeded", errorType: "context_exceeded", }); - return { success: true as const, data: undefined }; + return { + success: true as const, + data: createTurnHandle("assistant-ctx-exceeded", { + error: "Context length exceeded", + errorType: "context_exceeded", + }), + }; } // Retry startup in flight: hold it until the test releases, then fail // pre-stream (e.g. commitPartial / history read failure). @@ -372,7 +407,13 @@ describe("AgentSession post-compaction context retry", () => { error: "Context length exceeded", errorType: "context_exceeded", }); - return Promise.resolve({ success: true as const, data: undefined }); + return Promise.resolve({ + success: true as const, + data: createTurnHandle("assistant-attempt-1", { + error: "Context length exceeded", + errorType: "context_exceeded", + }), + }); } // The retry's startup succeeds, but the stream dies immediately with a // terminal error — emitted before the original retry path resumes. @@ -382,7 +423,13 @@ describe("AgentSession post-compaction context retry", () => { error: "The model refused to continue", errorType: "model_refusal", }); - return Promise.resolve({ success: true as const, data: undefined }); + return Promise.resolve({ + success: true as const, + data: createTurnHandle("assistant-attempt-2", { + error: "The model refused to continue", + errorType: "model_refusal", + }), + }); }); const aiService: AIService = { diff --git a/src/node/services/agentSession.preStreamError.test.ts b/src/node/services/agentSession.preStreamError.test.ts index 4a2c8a00b5c..a2619c076c6 100644 --- a/src/node/services/agentSession.preStreamError.test.ts +++ b/src/node/services/agentSession.preStreamError.test.ts @@ -443,25 +443,39 @@ describe("AgentSession pre-stream errors", () => { session.dispose(); }); - it("does not double-schedule auto-retry when runtime startup failure already emitted", async () => { + it("does not double-schedule auto-retry when a failed completion follows its error event", async () => { const workspaceId = "ws-runtime-start-failed-pre-emitted-error"; const { historyService, config, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const aiEmitter = new EventEmitter(); + const messageId = "assistant-stream-startup-failed"; const streamMessage = mock((_history: MuxMessage[]) => { aiEmitter.emit("error", { workspaceId, - messageId: "assistant-stream-startup-failed", + messageId, error: "Runtime is still starting", errorType: "runtime_start_failed", }); return Promise.resolve( - Err({ - type: "runtime_start_failed", - message: "Runtime is still starting", + Ok({ + streamToken: "stream-token", + messageId, + completion: Promise.resolve({ + status: "failed" as const, + messageId, + error: { + type: "runtime_start_failed" as const, + message: "Runtime is still starting", + }, + streamError: { + messageId, + error: "Runtime is still starting", + errorType: "runtime_start_failed" as const, + }, + }), }) ); }); @@ -469,9 +483,7 @@ describe("AgentSession pre-stream errors", () => { const aiService = Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), - streamMessage: streamMessage as unknown as ( - ...args: Parameters - ) => Promise>, + streamMessage: streamMessage as unknown as AIService["streamMessage"], }) as unknown as AIService; const initStateManager = new EventEmitter() as unknown as InitStateManager; @@ -502,7 +514,7 @@ describe("AgentSession pre-stream errors", () => { agentId: "exec", }); - expect(result.success).toBe(false); + expect(result.success).toBe(true); await session.waitForIdle(); diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index 248933ab3d2..af3f61113d6 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -6,6 +6,7 @@ import { createTestHistoryService } from "./testHistoryService"; import type { AIService } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { HistoryService } from "./historyService"; +import type { TurnStreamHandle } from "./streamManager"; import type { Config } from "@/node/config"; import type { InitStateManager } from "./initStateManager"; import type { WorkspaceChatMessage, SendMessageOptions } from "@/common/orpc/types"; @@ -17,6 +18,14 @@ import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; import { formatSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; +function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: new Promise(() => undefined), + }; +} + interface AutoRetryResumeRequest { options: SendMessageOptions; agentInitiated?: boolean; @@ -258,7 +267,7 @@ describe("AgentSession startup auto-retry recovery", () => { ); expect(appendResult.success).toBe(true); const streamMessageMock = mock((_payload: Parameters[0]) => - Promise.resolve(Ok(undefined)) + Promise.resolve(Ok(createStartedTurnHandle())) ); aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; const privateSession = session as unknown as { @@ -302,7 +311,7 @@ describe("AgentSession startup auto-retry recovery", () => { ); expect(appendResult.success).toBe(true); const streamMessageMock = mock((_payload: Parameters[0]) => - Promise.resolve(Ok(undefined)) + Promise.resolve(Ok(createStartedTurnHandle())) ); aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; const privateSession = session as unknown as { @@ -1180,12 +1189,13 @@ describe("AgentSession startup auto-retry recovery", () => { const privateSession = session as unknown as { retryActiveStream: () => Promise; lastAutoRetryResumeRequest?: AutoRetryResumeRequest; - activeStreamFailureHandled: boolean; - resumeStream: ( - options: SendMessageOptions - ) => Promise< + resumeStream: (options: SendMessageOptions) => Promise< | { success: true; data: { started: boolean } } - | { success: false; error: { type: "runtime_start_failed"; message: string } } + | { + success: false; + error: { type: "runtime_start_failed"; message: string }; + failureHandled?: true; + } >; }; @@ -1196,7 +1206,6 @@ describe("AgentSession startup auto-retry recovery", () => { }, }; - privateSession.activeStreamFailureHandled = true; const resumeStreamMock = mock((_options: SendMessageOptions) => Promise.resolve({ success: false as const, @@ -1204,6 +1213,7 @@ describe("AgentSession startup auto-retry recovery", () => { type: "runtime_start_failed" as const, message: "runtime is still starting", }, + failureHandled: true as const, }) ); privateSession.resumeStream = resumeStreamMock; @@ -1227,12 +1237,13 @@ describe("AgentSession startup auto-retry recovery", () => { const privateSession = session as unknown as { retryActiveStream: () => Promise; lastAutoRetryResumeRequest?: AutoRetryResumeRequest; - activeStreamFailureHandled: boolean; - resumeStream: ( - options: SendMessageOptions - ) => Promise< + resumeStream: (options: SendMessageOptions) => Promise< | { success: true; data: { started: boolean } } - | { success: false; error: { type: "runtime_start_failed"; message: string } } + | { + success: false; + error: { type: "runtime_start_failed"; message: string }; + failureHandled?: true; + } >; }; @@ -1243,7 +1254,6 @@ describe("AgentSession startup auto-retry recovery", () => { }, }; - privateSession.activeStreamFailureHandled = false; const resumeStreamMock = mock((_options: SendMessageOptions) => Promise.resolve({ success: false as const, @@ -1382,7 +1392,7 @@ describe("AgentSession startup auto-retry recovery", () => { }); } - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); aiService.streamMessage = streamMessageMock as unknown as AIService["streamMessage"]; @@ -1476,7 +1486,11 @@ describe("AgentSession startup auto-retry recovery", () => { agentInitiated?: boolean ) => Promise< | { success: true; data: undefined } - | { success: false; error: { type: "runtime_start_failed"; message: string } } + | { + success: false; + error: { type: "runtime_start_failed"; message: string }; + failureHandled?: true; + } >; }; @@ -1555,7 +1569,7 @@ describe("AgentSession startup auto-retry recovery", () => { const aiService = Object.assign(aiEmitter, { stopStream: mock(() => Promise.resolve(Ok(undefined))), isStreaming: mock(() => false), - streamMessage: mock(() => Promise.resolve(Ok(undefined))), + streamMessage: mock(() => Promise.resolve(Ok(createStartedTurnHandle()))), getWorkspaceMetadata: mock(() => Promise.resolve(Ok(workspaceMetadata))), }) as unknown as AIService; diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index e4fce6b3fc8..1bd2275b6b8 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -6,6 +6,7 @@ import type { MuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; +import type { TurnStreamHandle } from "@/node/services/streamManager"; import { AgentSession } from "@/node/services/agentSession"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; @@ -42,16 +43,36 @@ function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partia aiService: AIService; } { const aiEmitter = args?.emitter ?? new EventEmitter(); + const { streamMessage: streamMessageOverride, ...overrides } = args?.overrides ?? {}; + const streamMessage = + streamMessageOverride ?? + (mock((_history: MuxMessage[]) => + Promise.resolve(Ok(undefined)) + ) as unknown as AIService["streamMessage"]); + const normalizedStreamMessage = mock( + async (...streamArgs: Parameters) => { + const result = await streamMessage(...streamArgs); + if (!result.success || result.data != null) { + return result; + } + + const handle: TurnStreamHandle = { + streamToken: "test-stream-token", + messageId: "test-assistant-message", + completion: new Promise(() => undefined), + }; + return Ok(handle); + } + ); + return { aiEmitter, aiService: Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), getStreamInfo: mock((_workspaceId: string) => null), - streamMessage: mock((_history: MuxMessage[]) => - Promise.resolve(Ok(undefined)) - ) as unknown as AIService["streamMessage"], - ...args?.overrides, + streamMessage: normalizedStreamMessage as AIService["streamMessage"], + ...overrides, }) as unknown as AIService, }; } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index efcf5d7f199..a0c6a40f565 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9,6 +9,7 @@ import { log } from "@/node/services/log"; import { eventSpine } from "@/node/services/events/eventSpine"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; +import type { TurnCompletion } from "@/node/services/streamManager"; import type { HistoryService } from "@/node/services/historyService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -65,6 +66,10 @@ import { } from "@/node/services/utils/fileChangeTracker"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; + +type AgentSessionResult = + | { success: true; data: T } + | { success: false; error: SendMessageError; failureHandled?: true }; import { coerceOpenAIReasoningMode, coerceThinkingLevel, @@ -747,18 +752,6 @@ export class AgentSession { /** Last lifecycle snapshot emitted to live subscribers (used for change detection only). */ private lastEmittedStreamLifecycle: StreamLifecycleSnapshot | null = null; - /** - * True when AIService has already emitted an `error` event for the current stream attempt. - * Used to avoid duplicate retry scheduling when streamMessage later returns the same failure. - */ - private activeStreamErrorEventReceived = false; - - /** - * True when the latest streamWithHistory() failure path already updated retry/abandon state. - * retryActiveStream() uses this to avoid double-processing handled failures. - */ - private activeStreamFailureHandled = false; - /** * Stream-error recovery decisions keyed by the failed assistant messageId. * Per-attempt tracking (not a single shared decision) because recovery @@ -1270,10 +1263,7 @@ export class AgentSession { return; } - if (this.activeStreamFailureHandled) { - // resumeStream() failure paths already flowed through streamWithHistory() / - // handleStreamError(), which scheduled retry and persisted abandon state. - // Re-processing here would double-increment backoff attempts. + if (result.failureHandled === true) { return; } @@ -3019,7 +3009,7 @@ export class AgentSession { */ admissionStale?: () => boolean; } - ): Promise> { + ): Promise> { this.assertNotDisposed("sendMessage"); assert(typeof message === "string", "sendMessage requires a string message"); @@ -4026,7 +4016,7 @@ export class AgentSession { // service-side preflight reservation (see onTurnAdmissionCommitted doc). internal?.onTurnAdmissionCommitted?.(); - const startPreparedStream = async (): Promise> => { + const startPreparedStream = async (): Promise> => { try { if (preparedTurnAbortController.signal.aborted) { await notifyAcceptedPreStreamFailure( @@ -4127,7 +4117,7 @@ export class AgentSession { async resumeStream( options: SendMessageOptions, internal?: { agentInitiated?: boolean; goalKind?: GoalSyntheticMessageKind; goalId?: string } - ): Promise> { + ): Promise> { this.assertNotDisposed("resumeStream"); assert(options, "resumeStream requires options"); @@ -4757,7 +4747,7 @@ export class AgentSession { }); const failureType = sendResult.error.type; - const handledByNestedSend = this.activeStreamFailureHandled; + const handledByNestedSend = sendResult.failureHandled === true; if (!handledByNestedSend) { await this.handleStreamFailureForAutoRetry({ @@ -4836,6 +4826,47 @@ export class AgentSession { return Ok(undefined); } + private async handleStreamWithHistoryFailure( + error: SendMessageError, + acpPromptId?: string + ): Promise> { + const failureType = error.type; + + if (failureType === "runtime_not_ready" || failureType === "runtime_start_failed") { + const failedUserMessageId = this.activeStreamUserMessageId; + this.activeCompactionRequest = undefined; + this.resetActiveStreamState(); + await this.handleStreamFailureForAutoRetry({ + type: failureType, + message: this.extractRetryFailureMessage(error), + }); + await this.updateStartupAutoRetryAbandonFromFailure(failureType, failedUserMessageId); + } else { + await this.handleStreamError(buildStreamErrorEventData(error, { acpPromptId })); + } + + return { success: false, error, failureHandled: true }; + } + + private consumeTurnCompletion(completion: Promise): void { + void completion + .then(async (outcome) => { + if (outcome.status !== "failed") return; + + try { + await this.handleStreamError(outcome.streamError); + } finally { + this.resolveStreamErrorRecoveryDecision(outcome.messageId, "terminal"); + } + }) + .catch((error: unknown) => { + log.error("Failed to consume turn completion", { + workspaceId: this.workspaceId, + error: getErrorMessage(error), + }); + }); + } + private async streamWithHistory( modelString: string, options?: SendMessageOptions, @@ -4849,7 +4880,7 @@ export class AgentSession { // explicitly (not read from the field) so a preempted turn can never pick // up its replacement's holder. Absent for internal retry paths. activeTurnThinkingOverride?: ActiveTurnThinkingOverride - ): Promise> { + ): Promise> { const isStartupAbortRequested = (): boolean => abortSignal?.aborted === true; if (this.disposed || isStartupAbortRequested()) { @@ -4861,8 +4892,6 @@ export class AgentSession { this.clearLiveUsageState(); this.ackPendingPostCompactionStateOnStreamEnd = false; this.activeStreamHadAnyDelta = false; - this.activeStreamErrorEventReceived = false; - this.activeStreamFailureHandled = false; this.activeStreamHadPostCompactionInjection = false; const providersConfig = this.getProvidersConfigSafe(); this.activeStreamContext = { @@ -4878,7 +4907,9 @@ export class AgentSession { const commitResult = await this.historyService.commitPartial(this.workspaceId); if (!commitResult.success) { - return Err(createUnknownSendMessageError(commitResult.error)); + return await this.handleStreamWithHistoryFailure( + createUnknownSendMessageError(commitResult.error) + ); } if (isStartupAbortRequested()) { @@ -4903,7 +4934,9 @@ export class AgentSession { createFileChangeNotificationMessage(fileChangeDetection.attachments) ); if (!notificationAppendResult.success) { - return Err(createUnknownSendMessageError(notificationAppendResult.error)); + return await this.handleStreamWithHistoryFailure( + createUnknownSendMessageError(notificationAppendResult.error) + ); } fileChangeDetection.commit(); } @@ -4914,7 +4947,9 @@ export class AgentSession { } if (!historyResult.success) { - return Err(createUnknownSendMessageError(historyResult.error)); + return await this.handleStreamWithHistoryFailure( + createUnknownSendMessageError(historyResult.error) + ); } // A crash between snapshot and user-row appends can leave orphaned prompt @@ -4922,7 +4957,7 @@ export class AgentSession { let requestMessages = filterOrphanedMcpPromptSnapshots(historyResult.data); if (requestMessages.length === 0) { - return Err( + return await this.handleStreamWithHistoryFailure( createUnknownSendMessageError( "Cannot resume stream: workspace history is empty. Send a new message instead." ) @@ -5082,38 +5117,11 @@ export class AgentSession { }); if (!streamResult.success) { - // Deduplicate failures when AIService already emitted an `error` event for - // this stream attempt. attachAiListeners schedules retry via handleStreamError - // on that channel; re-handling here would bump attempt/backoff twice. - if (this.activeStreamErrorEventReceived) { - this.activeStreamFailureHandled = true; - return streamResult; - } - - const failureType = streamResult.error.type; - - // Runtime startup failures can happen before any stream events are emitted. - // Handle them directly when the `error` channel did not fire. - if (failureType === "runtime_not_ready" || failureType === "runtime_start_failed") { - this.activeStreamFailureHandled = true; - const failedUserMessageId = this.activeStreamUserMessageId; - this.activeCompactionRequest = undefined; - this.resetActiveStreamState(); - await this.handleStreamFailureForAutoRetry({ - type: failureType, - message: this.extractRetryFailureMessage(streamResult.error), - }); - await this.updateStartupAutoRetryAbandonFromFailure(failureType, failedUserMessageId); - } else { - this.activeStreamFailureHandled = true; - const streamError = buildStreamErrorEventData(streamResult.error, { - acpPromptId, - }); - await this.handleStreamError(streamError); - } + return await this.handleStreamWithHistoryFailure(streamResult.error, acpPromptId); } - return streamResult; + this.consumeTurnCompletion(streamResult.data.completion); + return Ok(undefined); } private resolveCompactionRequest( @@ -6108,21 +6116,9 @@ export class AgentSession { return; } const data = raw as StreamErrorPayload & { workspaceId: string }; - this.activeStreamErrorEventReceived = true; - // Begin synchronously at event emission so settlement waiters (which - // observe the same AIService error event) always find this attempt's - // decision when they run. + // Begin synchronously at event emission so completion waiters always find + // this attempt's decision before they run. this.beginStreamErrorRecoveryDecision(data.messageId); - void this.handleStreamError({ - messageId: data.messageId, - error: data.error, - errorType: data.errorType, - }) - // Safety net for exceptions inside handleStreamError: a successful - // retry already resolved "retry-started" for this attempt (no-op - // here); an unwound handler means no retry survived, so record - // terminal. - .finally(() => this.resolveStreamErrorRecoveryDecision(data.messageId, "terminal")); }; this.aiListeners.push({ event: "error", handler: errorHandler }); diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 2991b7820a3..a8c5486210e 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -56,7 +56,13 @@ import type { } from "@/common/types/stream"; import { log } from "./log"; import type { SessionUsageService } from "./sessionUsageService"; -import type { ModelFallbackOptions, StreamManager } from "./streamManager"; +import type { + StreamManager, + TurnCompletion, + TurnEngineEvent, + TurnExecutionOptions, + TurnStreamHandle, +} from "./streamManager"; import type { ActiveTurnThinkingOverride, RebuildProviderOptionsForThinkingLevel, @@ -293,7 +299,7 @@ function stubCommonStreamMessageDependencies(args: { historyService: HistoryService; initStateManager: InitStateManager; metadata: WorkspaceMetadata; - startStreamCalls?: unknown[][]; + startStreamCalls?: TurnExecutionOptions[]; routeProvider?: ProviderName; allTools?: Record; workspacePathOverride?: string; @@ -399,13 +405,18 @@ function stubCommonStreamMessageDependencies(args: { // the stream is registered (durable turn-envelope emission hangs on it), so // a success stub must call it too or envelope tests assert an empty journal. const stubStartStream = async ( - ...startArgs: Parameters - ): Promise<{ success: true; data: typeof streamToken }> => { - args.startStreamCalls?.push(startArgs); - // Positional parameter 31 of startStream (typed via Parameters above). - const onStreamConstructed = startArgs[30]; - await onStreamConstructed?.(); - return { success: true, data: streamToken }; + options: TurnExecutionOptions + ): Promise<{ success: true; data: TurnStreamHandle }> => { + args.startStreamCalls?.push(options); + await options.onStreamConstructed?.(); + return { + success: true, + data: { + streamToken, + messageId: options.messageId, + completion: new Promise(() => undefined), + }, + }; }; spyOn(streamManager, "startStream").mockImplementation(stubStartStream); @@ -618,9 +629,9 @@ describe("resolveMuxProjectRootForHostFs", () => { }); }); -describe("AIService.setupStreamEventForwarding", () => { +describe("AIService turn engine events", () => { interface ForwardingInternals { - streamManager: StreamManager; + emitEngineEvent: (event: TurnEngineEvent) => void | Promise; pendingDevToolsRunMetadataByMessageId: Map; } @@ -674,7 +685,7 @@ describe("AIService.setupStreamEventForwarding", () => { const forwardedAbortPromise = new Promise((resolve) => { service.once("stream-abort", (event) => resolve(event as StreamAbortEvent)); }); - internals.streamManager.emit("stream-abort", abortEvent); + await internals.emitEngineEvent(abortEvent); expect(await forwardedAbortPromise).toEqual(abortEvent); expect(deletePartialSpy).toHaveBeenCalledWith(abortEvent.workspaceId); @@ -699,7 +710,7 @@ describe("AIService.setupStreamEventForwarding", () => { const forwardedAbortPromise = new Promise((resolve) => { service.once("stream-abort", (event) => resolve(event as StreamAbortEvent)); }); - internals.streamManager.emit("stream-abort", abortEvent); + await internals.emitEngineEvent(abortEvent); expect(await forwardedAbortPromise).toEqual(abortEvent); expect(clearPendingRunMetadataSpy).not.toHaveBeenCalled(); @@ -723,7 +734,7 @@ describe("AIService.setupStreamEventForwarding", () => { resolve(forwarded as WorkflowRunAttachedEvent) ); }); - internals.streamManager.emit("workflow-run-attached", event); + await internals.emitEngineEvent(event); expect(await forwardedPromise).toEqual(event); }); @@ -762,7 +773,7 @@ describe("AIService.setupStreamEventForwarding", () => { const forwardedPromise = new Promise((resolve) => { service.once(eventName, (forwarded) => resolve(forwarded as typeof event)); }); - internals.streamManager.emit(eventName, event); + await internals.emitEngineEvent(event); expect(await forwardedPromise).toEqual(event); expect(clearPendingRunMetadataSpy).toHaveBeenCalledWith(event.workspaceId, "metadata-1"); @@ -1205,7 +1216,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { streamSystemContextAdvisorFlags: Array; streamSystemContextMemoryToolFlags: Array; streamSystemContextHotMemoriesBlocks: Array; - startStreamCalls: unknown[][]; + startStreamCalls: TurnExecutionOptions[]; getToolsForModelSpy: ReturnType>; } @@ -1228,10 +1239,12 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }); } - function openAIOptionsFromStartStreamCall(startStreamArgs: unknown[]): Record { - const providerOptions = startStreamArgs[11]; + function openAIOptionsFromStartStreamCall( + startStreamOptions: TurnExecutionOptions + ): Record { + const providerOptions = startStreamOptions.providerOptions; if (!providerOptions || typeof providerOptions !== "object") { - throw new Error("Expected provider options object at startStream arg index 11"); + throw new Error("Expected provider options object in startStream options"); } const openai = (providerOptions as { openai?: unknown }).openai; @@ -1242,10 +1255,12 @@ describe("AIService.streamMessage compaction boundary slicing", () => { return openai as Record; } - function initialMetadataFromStartStreamCall(startStreamArgs: unknown[]): Record { - const initialMetadata = startStreamArgs[10]; + function initialMetadataFromStartStreamCall( + startStreamOptions: TurnExecutionOptions + ): Record { + const initialMetadata = startStreamOptions.initialMetadata; if (!initialMetadata || typeof initialMetadata !== "object" || Array.isArray(initialMetadata)) { - throw new Error("Expected initial metadata object at startStream arg index 10"); + throw new Error("Expected initial metadata object in startStream options"); } return initialMetadata as Record; @@ -1280,7 +1295,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { const streamSystemContextAdvisorFlags: Array = []; const streamSystemContextMemoryToolFlags: Array = []; const streamSystemContextHotMemoriesBlocks: Array = []; - const startStreamCalls: unknown[][] = []; + const startStreamCalls: TurnExecutionOptions[] = []; const getToolsForModelSpy = stubCommonStreamMessageDependencies({ service, @@ -1333,12 +1348,6 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }; } - const START_STREAM_ON_CHUNK_INDEX = 21; - const START_STREAM_ON_STEP_MESSAGES_INDEX = 22; - const START_STREAM_RUNTIME_TEMP_DIR_INDEX = 23; - - const START_STREAM_MODEL_FALLBACK_INDEX = 24; - interface AdvisorRuntimeForTests { createModel: (modelString: string) => Promise; takeToolCallSnapshot: (toolCallId: string) => @@ -1424,8 +1433,8 @@ describe("AIService.streamMessage compaction boundary slicing", () => { throw new Error("Expected streamManager.startStream call arguments"); } - const onChunk = startStreamCall[START_STREAM_ON_CHUNK_INDEX]; - const onStepMessages = startStreamCall[START_STREAM_ON_STEP_MESSAGES_INDEX]; + const onChunk = startStreamCall.onChunk; + const onStepMessages = startStreamCall.onStepMessages; expect(typeof onChunk).toBe("function"); expect(typeof onStepMessages).toBe("function"); if (typeof onChunk !== "function" || typeof onStepMessages !== "function") { @@ -1553,9 +1562,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { expect(result.success).toBe(true); expect(harness.startStreamCalls).toHaveLength(1); - const modelFallback = harness.startStreamCalls[0]?.[START_STREAM_MODEL_FALLBACK_INDEX] as - | ModelFallbackOptions - | undefined; + const modelFallback = harness.startStreamCalls[0]?.modelFallback; expect(modelFallback).toBeDefined(); if (!modelFallback) { throw new Error("Expected modelFallback options on startStream"); @@ -1652,9 +1659,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }); expect(result.success).toBe(true); - const modelFallback = harness.startStreamCalls[0]?.[START_STREAM_MODEL_FALLBACK_INDEX] as - | ModelFallbackOptions - | undefined; + const modelFallback = harness.startStreamCalls[0]?.modelFallback; expect(modelFallback).toBeDefined(); if (!modelFallback) { throw new Error("Expected modelFallback options on startStream"); @@ -1746,9 +1751,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { expect(harness.startStreamCalls).toHaveLength(1); const startStreamArgs = harness.startStreamCalls[0]; - const modelFallback = startStreamArgs[START_STREAM_MODEL_FALLBACK_INDEX] as - | ModelFallbackOptions - | undefined; + const modelFallback = startStreamArgs.modelFallback; if (!modelFallback) { throw new Error("Expected modelFallback options on startStream"); } @@ -1903,9 +1906,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }); expect(result.success).toBe(true); - const modelFallback = harness.startStreamCalls[0]?.[START_STREAM_MODEL_FALLBACK_INDEX] as - | ModelFallbackOptions - | undefined; + const modelFallback = harness.startStreamCalls[0]?.modelFallback; if (!modelFallback) { throw new Error("Expected modelFallback options on startStream"); } @@ -2417,7 +2418,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { expect(result.success).toBe(true); expect(harness.streamSystemContextAdvisorFlags).toEqual([false]); - expect(harness.startStreamCalls[0]?.[START_STREAM_RUNTIME_TEMP_DIR_INDEX]).toBe( + expect(harness.startStreamCalls[0]?.providedRuntimeTempDir).toBe( path.join(metadata.projectPath, ".tmp-stream") ); }); @@ -2798,7 +2799,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { throw new Error("Expected streamManager.startStream call arguments"); } - const startStreamMessageIds = messageIdsFromUnknownArray(startStreamCall[1]); + const startStreamMessageIds = messageIdsFromUnknownArray(startStreamCall.messages); expect(startStreamMessageIds).toEqual(["boundary-2", "latest-user"]); expect(initialMetadataFromStartStreamCall(startStreamCall).requestHistorySequence).toBe(42); @@ -2991,7 +2992,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { throw new Error("Expected streamManager.startStream call arguments"); } - const startStreamMessageIds = messageIdsFromUnknownArray(startStreamCall[1]); + const startStreamMessageIds = messageIdsFromUnknownArray(startStreamCall.messages); expect(startStreamMessageIds).toEqual([ "assistant-before-malformed", "malformed-boundary", @@ -3475,9 +3476,6 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }); describe("mid-turn thinking override rebuild closure", () => { - const START_STREAM_THINKING_OVERRIDE_STATE_INDEX = 26; - const START_STREAM_THINKING_REBUILD_INDEX = 27; - function getThinkingOverrideStartStreamArgs(harness: StreamMessageHarness): { holder: unknown; rebuild: RebuildProviderOptionsForThinkingLevel; @@ -3487,10 +3485,10 @@ describe("AIService.streamMessage compaction boundary slicing", () => { if (!call) { throw new Error("Expected streamManager.startStream call arguments"); } - const holder = call[START_STREAM_THINKING_OVERRIDE_STATE_INDEX]; - const rebuild = call[START_STREAM_THINKING_REBUILD_INDEX]; + const holder = call.thinkingOverrideState; + const rebuild = call.rebuildProviderOptionsForThinkingLevel; expect(typeof rebuild).toBe("function"); - return { holder, rebuild: rebuild as RebuildProviderOptionsForThinkingLevel }; + return { holder, rebuild: rebuild! }; } it("threads the session holder by reference and rebuilds options through the same pipeline", async () => { @@ -3815,22 +3813,24 @@ describe("AIService.streamMessage model parameter overrides", () => { interface ModelParameterOverridesHarness { service: AIService; config: Config; - startStreamCalls: unknown[][]; + startStreamCalls: TurnExecutionOptions[]; } - function providerOptionsFromStartStreamCall(startStreamArgs: unknown[]): Record { - const providerOptions = startStreamArgs[11]; + function providerOptionsFromStartStreamCall( + startStreamArgs: TurnExecutionOptions + ): Record { + const providerOptions = startStreamArgs.providerOptions; if (!providerOptions || typeof providerOptions !== "object" || Array.isArray(providerOptions)) { throw new Error("Expected provider options object at startStream arg index 11"); } - return providerOptions as Record; + return providerOptions; } function callSettingsOverridesFromStartStreamCall( - startStreamArgs: unknown[] + startStreamArgs: TurnExecutionOptions ): Record { - const callSettingsOverrides = startStreamArgs[20]; + const callSettingsOverrides = startStreamArgs.callSettingsOverrides; if ( !callSettingsOverrides || typeof callSettingsOverrides !== "object" || @@ -3848,7 +3848,7 @@ describe("AIService.streamMessage model parameter overrides", () => { options?: { routeProvider?: ProviderName } ): ModelParameterOverridesHarness { const { config, historyService, initStateManager, service } = createBasicAIService(xumHomePath); - const startStreamCalls: unknown[][] = []; + const startStreamCalls: TurnExecutionOptions[] = []; stubCommonStreamMessageDependencies({ service, config, @@ -3869,7 +3869,7 @@ describe("AIService.streamMessage model parameter overrides", () => { harness: ModelParameterOverridesHarness, workspaceId: string, modelString = ANTHROPIC_MODEL - ): Promise { + ): Promise { const result = await harness.service.streamMessage({ messages: [createMuxMessage("user-message", "user", "hello")], workspaceId, @@ -4101,7 +4101,7 @@ describe("AIService.streamMessage model parameter overrides", () => { spyOn(harness.config, "loadProvidersConfig").mockReturnValue({}); const startStreamArgs = await streamAndGetStartStreamArgs(harness, workspaceId); - expect(startStreamArgs[20]).toEqual({}); + expect(startStreamArgs.callSettingsOverrides).toEqual({}); }); it("preserves Xum-built provider options when provider extras conflict", async () => { @@ -4281,7 +4281,7 @@ describe("AIService.streamMessage model parameter overrides", () => { const { config, historyService, initStateManager, service } = createBasicAIService( xumHome.path ); - const startStreamCalls: unknown[][] = []; + const startStreamCalls: TurnExecutionOptions[] = []; stubCommonStreamMessageDependencies({ service, config, @@ -4347,7 +4347,7 @@ describe("AIService.streamMessage turn envelope", () => { interface TurnEnvelopeHarness { service: AIService; config: Config; - startStreamCalls: unknown[][]; + startStreamCalls: TurnExecutionOptions[]; } function createHarness( @@ -4356,7 +4356,7 @@ describe("AIService.streamMessage turn envelope", () => { options?: { allTools?: Record } ): TurnEnvelopeHarness { const { config, historyService, initStateManager, service } = createBasicAIService(xumHomePath); - const startStreamCalls: unknown[][] = []; + const startStreamCalls: TurnExecutionOptions[] = []; stubCommonStreamMessageDependencies({ service, config, diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index d0d192fd51f..e7c7562780e 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -24,7 +24,15 @@ import type { GoalRecordV1 } from "@/common/types/goal"; import type { ModelMessage, MuxMessage, MuxMessageMetadata } from "@/common/types/message"; import { createMuxMessage } from "@/common/types/message"; import type { Config } from "@/node/config"; -import { StreamManager, type ModelFallbackOptions, type StreamTextOnChunk } from "./streamManager"; +import { + StreamManager, + type ModelFallbackOptions, + type StreamTextOnChunk, + type TurnCompletion, + type TurnEngineEvent, + type TurnExecutionOptions, + type TurnStreamHandle, +} from "./streamManager"; import { emitTurnEnvelope } from "./turnEnvelope"; import { sharedDurableEventJournal, @@ -620,8 +628,11 @@ export class AIService extends EventEmitter { this.experimentsService = experimentsService; this.providerService = providerService; this.providerService.onConfigChanged(() => this.emit("providers-config-changed")); - this.streamManager = new StreamManager(historyService, sessionUsageService, () => - this.providerService.getConfig() + this.streamManager = new StreamManager( + historyService, + sessionUsageService, + () => this.providerService.getConfig(), + (event) => this.emitEngineEvent(event) ); this.devToolsService = devToolsService; this.providerModelFactory = new ProviderModelFactory( @@ -632,7 +643,6 @@ export class AIService extends EventEmitter { devToolsService ); void this.ensureSessionsDir(); - this.setupStreamEventForwarding(); this.mockModeEnabled = false; if (resolveXumEnvironmentValue("MOCK_AI", process.env) === "1") { @@ -780,57 +790,31 @@ export class AIService extends EventEmitter { this.extraTools = tools; } - /** - * Forward all stream events from StreamManager to AIService consumers - */ - private setupStreamEventForwarding(): void { - // Simple one-to-one event forwarding from StreamManager → AIService consumers - for (const event of [ - "stream-start", - "stream-delta", - "tool-call-start", - "tool-call-execution-start", - "tool-call-delta", - "tool-call-end", - "reasoning-delta", - "reasoning-end", - "workflow-run-attached", - "usage-delta", - ] as const) { - this.streamManager.on(event, (data) => this.emit(event, data)); + private emitEngineEvent(event: TurnEngineEvent): void | Promise { + if (event.type === "error") { + this.clearTrackedPendingDevToolsRunMetadata(event.messageId); + this.emit("error", event); + return; } - // Stream errors can bypass stream-end/stream-abort. Clear any queued metadata - // so failed requests don't leak pending-run tracking entries. - this.streamManager.on("error", (data: ErrorEvent) => { - this.clearTrackedPendingDevToolsRunMetadata(data.messageId); - this.emit("error", data); - }); - - // stream-end needs extra logic: capture provider response for debug modal - this.streamManager.on("stream-end", (data: StreamEndEvent) => { - // Streams can end before DevTools middleware creates a run (for example when - // interrupted early). Clear any still-queued run metadata for this message. - this.clearTrackedPendingDevToolsRunMetadata(data.messageId); + if (event.type === "stream-end") { + this.clearTrackedPendingDevToolsRunMetadata(event.messageId); - // Best-effort capture of the provider response for the "Last LLM request" debug modal. - // Must never break live streaming. try { - const snapshot = this.lastLlmRequestByWorkspace.get(data.workspaceId); + const snapshot = this.lastLlmRequestByWorkspace.get(event.workspaceId); if (snapshot) { - // If messageId is missing (legacy fixtures), attach anyway. - const shouldAttach = snapshot.messageId === data.messageId || snapshot.messageId == null; + const shouldAttach = snapshot.messageId === event.messageId || snapshot.messageId == null; if (shouldAttach) { const updated: DebugLlmRequestSnapshot = { ...snapshot, response: { capturedAt: Date.now(), - metadata: data.metadata, - parts: data.parts, + metadata: event.metadata, + parts: event.parts, }, }; - this.lastLlmRequestByWorkspace.set(data.workspaceId, structuredClone(updated)); + this.lastLlmRequestByWorkspace.set(event.workspaceId, structuredClone(updated)); } } } catch (error) { @@ -838,41 +822,117 @@ export class AIService extends EventEmitter { log.warn("Failed to capture debug LLM response snapshot", { error: errMsg }); } - this.emit("stream-end", data); - }); - - // Handle stream-abort: dispose of partial based on abandonPartial flag - this.streamManager.on("stream-abort", (data: StreamAbortEvent) => { - // Aborts can happen before the first provider call reaches DevTools middleware. - // Clear any queued run metadata for this message to avoid memory growth. - this.clearTrackedPendingDevToolsRunMetadata(data.messageId); + this.emit("stream-end", event); + return; + } - void (async () => { + if (event.type === "stream-abort") { + this.clearTrackedPendingDevToolsRunMetadata(event.messageId); + return (async () => { try { - if (data.abandonPartial) { - // Caller requested discarding partial - delete without committing - await this.historyService.deletePartial(data.workspaceId); + if (event.abandonPartial) { + await this.historyService.deletePartial(event.workspaceId); } else { - // Commit interrupted message to history with partial:true metadata - // This ensures /clear can clean up interrupted messages - const partial = await this.historyService.readPartial(data.workspaceId); + const partial = await this.historyService.readPartial(event.workspaceId); if (partial) { - await this.historyService.commitPartial(data.workspaceId); - await this.historyService.deletePartial(data.workspaceId); + await this.historyService.commitPartial(event.workspaceId); + await this.historyService.deletePartial(event.workspaceId); } } } catch (error) { log.error("Failed partial cleanup during stream-abort", { - workspaceId: data.workspaceId, + workspaceId: event.workspaceId, error: getErrorMessage(error), }); } finally { - // Always forward abort event to consumers (workspaceService, agentSession) - // even if partial cleanup failed — stream lifecycle consistency is higher priority. - this.emit("stream-abort", data); + this.emit("stream-abort", event); } })(); + } + + this.emit(event.type, event); + } + + private createSettledTurnHandle(messageId: string, completion: TurnCompletion): TurnStreamHandle { + return { + streamToken: this.streamManager.generateStreamToken(), + messageId, + completion: Promise.resolve(completion), + }; + } + + private observeFacadeTurnCompletion(input: { + workspaceId: string; + messageId: string; + adoptStreamStartMessageId?: boolean; + }): { handle: TurnStreamHandle; cancel: () => void } { + let messageId = input.messageId; + let settled = false; + let resolveCompletion!: (completion: TurnCompletion) => void; + const completion = new Promise((resolve) => { + resolveCompletion = resolve; }); + + const cleanup = (): void => { + this.off("stream-start", onStreamStart as never); + this.off("stream-end", onStreamEnd as never); + this.off("stream-abort", onStreamAbort as never); + this.off("error", onError as never); + }; + const settle = (outcome: TurnCompletion): void => { + if (settled) return; + settled = true; + cleanup(); + resolveCompletion(outcome); + }; + const matches = (event: { workspaceId: string; messageId: string }): boolean => + event.workspaceId === input.workspaceId && event.messageId === messageId; + const onStreamStart = (event: TurnEngineEvent): void => { + if ( + input.adoptStreamStartMessageId === true && + event.type === "stream-start" && + event.workspaceId === input.workspaceId + ) { + messageId = event.messageId; + } + }; + const onStreamEnd = (event: StreamEndEvent): void => { + if (matches(event)) settle({ status: "completed", messageId }); + }; + const onStreamAbort = (event: StreamAbortEvent): void => { + if (matches(event)) { + settle({ status: "aborted", messageId, abortReason: event.abortReason ?? "system" }); + } + }; + const onError = (event: ErrorEvent): void => { + if (matches(event)) { + settle({ + status: "failed", + messageId, + error: { type: "unknown", raw: event.error }, + streamError: { + messageId, + error: event.error, + errorType: event.errorType ?? "unknown", + acpPromptId: event.acpPromptId, + }, + }); + } + }; + + this.on("stream-start", onStreamStart as never); + this.on("stream-end", onStreamEnd as never); + this.on("stream-abort", onStreamAbort as never); + this.on("error", onError as never); + + const handle: TurnStreamHandle = { + streamToken: this.streamManager.generateStreamToken(), + get messageId() { + return messageId; + }, + completion, + }; + return { handle, cancel: cleanup }; } private trackPendingDevToolsRunMetadata( @@ -1317,7 +1377,9 @@ export class AIService extends EventEmitter { } /** Stream a message conversation to the AI model. */ - async streamMessage(opts: StreamMessageOptions): Promise> { + async streamMessage( + opts: StreamMessageOptions + ): Promise> { const { messages, workspaceId, @@ -1388,15 +1450,31 @@ export class AIService extends EventEmitter { if (this.mockModeEnabled && this.mockAiStreamPlayer) { await this.initStateManager.waitForInit(workspaceId, combinedAbortSignal); if (combinedAbortSignal.aborted) { - return Ok(undefined); + return Ok( + this.createSettledTurnHandle(syntheticMessageId, { + status: "aborted", + messageId: syntheticMessageId, + abortReason: "startup", + }) + ); } - return await this.mockAiStreamPlayer.play(messages, workspaceId, { + const observed = this.observeFacadeTurnCompletion({ + workspaceId, + messageId: syntheticMessageId, + adoptStreamStartMessageId: true, + }); + const result = await this.mockAiStreamPlayer.play(messages, workspaceId, { model: modelString, agentId, thinkingLevel, muxMetadata, abortSignal: combinedAbortSignal, }); + if (!result.success) { + observed.cancel(); + return result; + } + return Ok(observed.handle); } // DEBUG: Log streamMessage call @@ -1759,7 +1837,13 @@ export class AIService extends EventEmitter { await this.initStateManager.waitForInit(workspaceId, combinedAbortSignal); recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); if (combinedAbortSignal.aborted) { - return Ok(undefined); + return Ok( + this.createSettledTurnHandle(syntheticMessageId, { + status: "aborted", + messageId: syntheticMessageId, + abortReason: "startup", + }) + ); } // Verify runtime is actually reachable after init completes. @@ -3006,7 +3090,13 @@ export class AIService extends EventEmitter { }); if (combinedAbortSignal.aborted) { - return Ok(undefined); + return Ok( + this.createSettledTurnHandle(assistantMessageId, { + status: "aborted", + messageId: assistantMessageId, + abortReason: "startup", + }) + ); } const requestHistorySequence = providerRequestMessages.reduce( @@ -3041,6 +3131,10 @@ export class AIService extends EventEmitter { effectiveMuxProviderOptions.openai?.simulateToolPolicyNoop === true; if (forceContextLimitError || simulateToolPolicyNoopFlag) { + const observed = this.observeFacadeTurnCompletion({ + workspaceId, + messageId: assistantMessageId, + }); const simulationCtx: SimulationContext = { workspaceId, assistantMessageId, @@ -3056,12 +3150,17 @@ export class AIService extends EventEmitter { emit: (event, data) => this.emit(event, data), }; - if (forceContextLimitError) { - await simulateContextLimitError(simulationCtx, this.historyService); - } else { - await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); + try { + if (forceContextLimitError) { + await simulateContextLimitError(simulationCtx, this.historyService); + } else { + await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); + } + return Ok(observed.handle); + } catch (error) { + observed.cancel(); + throw error; } - return Ok(undefined); } // Build provider options based on thinking level and request-sliced message history. @@ -3336,7 +3435,13 @@ export class AIService extends EventEmitter { if (combinedAbortSignal.aborted) { await deleteAbortedPlaceholder(assistantMessageId); - return Ok(undefined); + return Ok( + this.createSettledTurnHandle(assistantMessageId, { + status: "aborted", + messageId: assistantMessageId, + abortReason: "startup", + }) + ); } // Capture request payload for the debug modal, then delegate to StreamManager. @@ -4097,43 +4202,41 @@ export class AIService extends EventEmitter { emitStartupBreadcrumb("starting_stream"); const startStreamStartedAt = Date.now(); - const streamResult = await this.streamManager.startStream( + const turnExecutionOptions: TurnExecutionOptions = { workspaceId, - streamFinalMessages, - modelResult.data.model, + messages: streamFinalMessages, + model: modelResult.data.model, modelString, historySequence, - systemMessage, + system: systemMessage, runtime, - assistantMessageId, // Shared messageId ensures nested tool events match stream events - combinedAbortSignal, - toolsForStream, - { + messageId: assistantMessageId, + abortSignal: combinedAbortSignal, + tools: toolsForStream, + initialMetadata: { ...(requestHistorySequence >= 0 ? { requestHistorySequence } : {}), systemMessageTokens, timestamp: Date.now(), agentId: effectiveAgentId, ...(legacyModeForMetadata != null ? { mode: legacyModeForMetadata } : {}), routedThroughGateway, - // Preserve the resolved route source so stream events and persisted messages - // keep non-gateway attribution even when the model ID itself is gateway-agnostic. ...(routeProvider != null ? { routeProvider } : {}), ...(muxMetadata !== undefined ? { muxMetadata } : {}), ...(acpPromptId != null ? { acpPromptId } : {}), ...(modelCostsIncluded(modelResult.data.model) ? { costsIncluded: true } : {}), }, - streamProviderOptions, + providerOptions: streamProviderOptions, maxOutputTokens, - effectiveToolPolicy, - streamToken, // Pass the pre-generated stream token + toolPolicy: effectiveToolPolicy, + providedStreamToken: streamToken, hasQueuedMessages, - metadata.name, - streamThinkingLevel, - requestHeaders, - effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, - resolvedOverrides.standard, - advisorToolEligible ? onAdvisorChunk : undefined, - advisorToolEligible + workspaceName: metadata.name, + thinkingLevel: streamThinkingLevel, + headers: requestHeaders, + anthropicCacheTtlOverride: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, + callSettingsOverrides: resolvedOverrides.standard, + onChunk: advisorToolEligible ? onAdvisorChunk : undefined, + onStepMessages: advisorToolEligible ? (stepMessages) => { advisorTranscriptRef.messages = stepMessages; advisorStepCaptureRef.currentStepText = ""; @@ -4141,16 +4244,17 @@ export class AIService extends EventEmitter { advisorStepCaptureRef.frozenSnapshotsByToolCallId.clear(); } : undefined, - runtimeTempDir, + providedRuntimeTempDir: runtimeTempDir, modelFallback, - toolSearchRuntime?.state, - activeTurnThinkingOverride, + toolSearchState: toolSearchRuntime?.state, + thinkingOverrideState: activeTurnThinkingOverride, rebuildProviderOptionsForThinkingLevel, forcedFirstStepToolNames, - requestProvidersConfig, - emitPrimaryEnvelope, - rebuildFirstStepForThinkingLevel - ); + providersConfigSnapshot: requestProvidersConfig, + onStreamConstructed: emitPrimaryEnvelope, + rebuildFirstStepForThinkingLevel, + }; + const streamResult = await this.streamManager.startStream(turnExecutionOptions); recordStartupPhaseTiming("startStreamMs", startStreamStartedAt); if (!streamResult.success) { @@ -4204,9 +4308,8 @@ export class AIService extends EventEmitter { finalMessageCount: finalMessages.length, }); - // StreamManager now handles history updates directly on stream-end - // No need for event listener here - return Ok(undefined); + // StreamManager now handles history updates directly on stream-end. + return Ok(streamResult.data); } catch (error) { if (pendingRunMetadataId != null) { this.clearTrackedPendingDevToolsRunMetadataById(workspaceId, pendingRunMetadataId); diff --git a/src/node/services/streamManager.modelOnlyNotifications.test.ts b/src/node/services/streamManager.modelOnlyNotifications.test.ts index 6269c177df3..91e876d93cc 100644 --- a/src/node/services/streamManager.modelOnlyNotifications.test.ts +++ b/src/node/services/streamManager.modelOnlyNotifications.test.ts @@ -1,10 +1,31 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { StreamManager } from "./streamManager"; +import { StreamManager, type TurnEngineEvent, type TurnEngineEventSink } from "./streamManager"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; +type TurnEngineEventOfType = Extract< + TurnEngineEvent, + { type: T } +>; + +function onTurnEngineEvent( + streamManager: StreamManager, + type: T, + listener: (event: TurnEngineEventOfType) => void +): void { + const internals = streamManager as unknown as { eventSink: TurnEngineEventSink }; + const previous = internals.eventSink; + internals.eventSink = (event) => { + const result = previous(event); + if (event.type === type) { + listener(event as TurnEngineEventOfType); + } + return result; + }; +} + describe("StreamManager - model-only tool notifications", () => { let historyService: HistoryService; let historyCleanup: () => Promise; @@ -29,9 +50,13 @@ describe("StreamManager - model-only tool notifications", () => { }; const events: Array<{ toolName?: string; result?: unknown }> = []; - streamManager.on("tool-call-end", (data: { toolName: string; result: unknown }) => { - events.push({ toolName: data.toolName, result: data.result }); - }); + onTurnEngineEvent( + streamManager, + "tool-call-end", + (data: { toolName: string; result: unknown }) => { + events.push({ toolName: data.toolName, result: data.result }); + } + ); const mockStreamResult = { // eslint-disable-next-line @typescript-eslint/require-await @@ -115,7 +140,8 @@ describe("StreamManager - model-only tool notifications", () => { result?: unknown; providerExecuted?: boolean; }> = []; - streamManager.on( + onTurnEngineEvent( + streamManager, "tool-call-end", (data: { toolName: string; result: unknown; providerExecuted?: boolean }) => { events.push({ diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 52c7f4abc2e..ef711e1b19b 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -15,7 +15,12 @@ import type { MuxMessage } from "@/common/types/message"; import { Ok, Err } from "@/common/types/result"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ToolSearchStreamState } from "@/common/utils/tools/toolCatalog"; -import { StreamManager, type ModelFallbackPrepareOptions } from "./streamManager"; +import { + StreamManager, + type ModelFallbackPrepareOptions, + type TurnEngineEvent, + type TurnEngineEventSink, +} from "./streamManager"; import type { ActiveTurnThinkingOverride, RebuildFirstStepForThinkingLevel, @@ -46,6 +51,27 @@ import { createRuntime } from "@/node/runtime/runtimeFactory"; import { attachLanguageModelCleanup } from "./languageModelCleanup"; import { shellQuote } from "@/common/utils/shell"; +type TurnEngineEventOfType = Extract< + TurnEngineEvent, + { type: T } +>; + +function onTurnEngineEvent( + streamManager: StreamManager, + type: T, + listener: (event: TurnEngineEventOfType) => void +): void { + const internals = streamManager as unknown as { eventSink: TurnEngineEventSink }; + const previous = internals.eventSink; + internals.eventSink = (event) => { + const result = previous(event); + if (event.type === type) { + listener(event as TurnEngineEventOfType); + } + return result; + }; +} + function createTestLanguageModel(modelId = "cleanup-model"): LanguageModel { return { specificationVersion: "v3", @@ -317,7 +343,7 @@ describe("StreamManager - workflow run attachments", () => { >(streamManager, "appendPartAndEmit"); const replayedAttachments: WorkflowRunAttachedEvent[] = []; - streamManager.on("workflow-run-attached", (event: WorkflowRunAttachedEvent) => { + onTurnEngineEvent(streamManager, "workflow-run-attached", (event: WorkflowRunAttachedEvent) => { replayedAttachments.push(event); }); @@ -379,7 +405,7 @@ describe("StreamManager - nested tool call normalization", () => { getWorkspaceStreamsForTests(streamManager).set(workspaceId, streamInfo); const events: unknown[] = []; - streamManager.on("tool-call-start", (event: unknown) => events.push(event)); + onTurnEngineEvent(streamManager, "tool-call-start", (event: unknown) => events.push(event)); streamManager.emitNestedToolEvent(workspaceId, messageId, { type: "tool-call-start", @@ -429,9 +455,13 @@ describe("StreamManager - tool execution start timing", () => { getWorkspaceStreamsForTests(streamManager).set(workspaceId, streamInfo); const events: ToolCallExecutionStartEvent[] = []; - streamManager.on("tool-call-execution-start", (event: ToolCallExecutionStartEvent) => { - events.push(event); - }); + onTurnEngineEvent( + streamManager, + "tool-call-execution-start", + (event: ToolCallExecutionStartEvent) => { + events.push(event); + } + ); const handleToolExecutionStart = getPrivateMethodForTests< (workspaceId: string, messageId: string, toolCallId: string) => void @@ -461,9 +491,13 @@ describe("StreamManager - tool execution start timing", () => { getWorkspaceStreamsForTests(streamManager).set(workspaceId, streamInfo); const events: ToolCallExecutionStartEvent[] = []; - streamManager.on("tool-call-execution-start", (event: ToolCallExecutionStartEvent) => { - events.push(event); - }); + onTurnEngineEvent( + streamManager, + "tool-call-execution-start", + (event: ToolCallExecutionStartEvent) => { + events.push(event); + } + ); // execute() wins the race: no part yet, so the start is parked as pending. const handleToolExecutionStart = getPrivateMethodForTests< @@ -1210,7 +1244,7 @@ describe("StreamManager - OpenAI GPT-5.6 cached system instructions", () => { undefined, () => eligibleProvidersConfig ); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), countTokens: () => Promise.resolve(0), @@ -1726,7 +1760,7 @@ describe("StreamManager - language model cleanup", () => { streamInfoOverrides?: Record; }): Promise { const streamManager = new StreamManager(historyService); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); const historySequence = 1; await appendPartialAssistantForTests(params.workspaceId, params.messageId, historySequence); @@ -1862,17 +1896,17 @@ describe("StreamManager - language model cleanup", () => { const abortController = new AbortController(); abortController.abort(new Error("pre-abort")); - const result = await streamManager.startStream( - "cleanup-preabort-workspace", - [{ role: "user", content: "hello" }], + const result = await streamManager.startStream({ + workspaceId: "cleanup-preabort-workspace", + messages: [{ role: "user", content: "hello" }], model, - "openai:gpt-4.1-mini", - 1, - "system", + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", runtime, - "cleanup-preabort-message", - abortController.signal - ); + messageId: "cleanup-preabort-message", + abortSignal: abortController.signal, + }); expect(result.success).toBe(true); expect(getCleanupCalls()).toBe(1); @@ -1880,10 +1914,10 @@ describe("StreamManager - language model cleanup", () => { test("interrupt during onStreamConstructed skips processing and preserves a replacement registration", async () => { const streamManager = new StreamManager(historyService); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); const { model, getCleanupCalls } = createCleanupModel("constructed-abort-model"); const startEvents: unknown[] = []; - streamManager.on("stream-start", (event) => startEvents.push(event)); + onTurnEngineEvent(streamManager, "stream-start", (event) => startEvents.push(event)); const workspaceId = "constructed-abort-workspace"; const replacementSentinel = { replacement: true }; @@ -1897,39 +1931,17 @@ describe("StreamManager - language model cleanup", () => { streams.set(workspaceId, replacementSentinel); }; - const result = await streamManager.startStream( + const result = await streamManager.startStream({ workspaceId, - [{ role: "user", content: "hello" }], + messages: [{ role: "user", content: "hello" }], model, - "openai:gpt-4.1-mini", - 1, - "system", + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", runtime, - "constructed-abort-message", - undefined, // abortSignal - undefined, // tools - undefined, // initialMetadata - undefined, // providerOptions - undefined, // maxOutputTokens - undefined, // toolPolicy - undefined, // providedStreamToken - undefined, // hasQueuedMessages - undefined, // workspaceName - undefined, // thinkingLevel - undefined, // headers - undefined, // anthropicCacheTtlOverride - undefined, // callSettingsOverrides - undefined, // onChunk - undefined, // onStepMessages - undefined, // providedRuntimeTempDir - undefined, // modelFallback - undefined, // toolSearchState - undefined, // thinkingOverrideState - undefined, // rebuildProviderOptionsForThinkingLevel - undefined, // forcedFirstStepToolNames - undefined, // providersConfigSnapshot - onStreamConstructed - ); + messageId: "constructed-abort-message", + onStreamConstructed, + }); expect(result.success).toBe(true); // The canceled stream must never start processing: stream-start after the @@ -1951,21 +1963,205 @@ describe("StreamManager - language model cleanup", () => { }); expect(replaceCreateStreamResult).toBe(true); - const result = await streamManager.startStream( - "cleanup-create-throw-workspace", - [{ role: "user", content: "hello" }], + const result = await streamManager.startStream({ + workspaceId: "cleanup-create-throw-workspace", + messages: [{ role: "user", content: "hello" }], model, - "openai:gpt-4.1-mini", - 1, - "system", + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", runtime, - "cleanup-create-throw-message" - ); + messageId: "cleanup-create-throw-message", + }); expect(result.success).toBe(false); expect(getCleanupCalls()).toBe(1); }); }); + +describe("StreamManager - turn completion", () => { + const runtime = LOCAL_TEST_RUNTIME; + + function stubTokenTracker(streamManager: StreamManager): void { + Reflect.set(streamManager, "tokenTracker", { + setModel: () => Promise.resolve(), + countTokens: () => Promise.resolve(0), + }); + } + + async function startWithStreamResult(input: { + workspaceId: string; + messageId: string; + fullStream: AsyncGenerator; + events?: TurnEngineEvent[]; + }) { + const streamManager = new StreamManager(historyService, undefined, undefined, (event) => { + input.events?.push(event); + }); + stubTokenTracker(streamManager); + Reflect.set(streamManager, "createStreamResult", () => + createStreamResultForTests(input.fullStream) + ); + await appendPartialAssistantForTests(input.workspaceId, input.messageId, 1); + + const result = await streamManager.startStream({ + workspaceId: input.workspaceId, + messages: [{ role: "user", content: "hello" }], + model: createTestLanguageModel(), + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", + runtime, + messageId: input.messageId, + providedRuntimeTempDir: "", + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected stream to start"); + return { streamManager, handle: result.data }; + } + + test("pre-start failures return Err while successful startup owns an aborted completion", async () => { + const streamManager = new StreamManager(historyService); + const model = createTestLanguageModel(); + const failed = await streamManager.startStream({ + workspaceId: "completion-prestart-failure", + messages: [], + model, + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", + runtime, + messageId: "prestart-failure-message", + }); + expect(failed.success).toBe(false); + + const abortController = new AbortController(); + abortController.abort(); + const aborted = await streamManager.startStream({ + workspaceId: "completion-prestart-abort", + messages: [{ role: "user", content: "hello" }], + model, + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", + runtime, + messageId: "prestart-abort-message", + abortSignal: abortController.signal, + }); + expect(aborted.success).toBe(true); + if (!aborted.success) throw new Error("Expected aborted startup handle"); + expect(await aborted.data.completion).toEqual({ + status: "aborted", + messageId: "prestart-abort-message", + abortReason: "startup", + }); + }); + + test("completed and failed turns settle once after their terminal event", async () => { + const completedEvents: TurnEngineEvent[] = []; + const completed = await startWithStreamResult({ + workspaceId: "completion-success-workspace", + messageId: "completion-success-message", + events: completedEvents, + fullStream: (async function* () { + await Promise.resolve(); + yield { type: "text-delta", text: "done" }; + yield { type: "finish", finishReason: "stop" }; + })(), + }); + let completedSettlements = 0; + void completed.handle.completion.then(() => { + completedSettlements += 1; + }); + expect(await completed.handle.completion).toEqual({ + status: "completed", + messageId: "completion-success-message", + }); + await Promise.resolve(); + expect(completedEvents.at(-1)?.type).toBe("stream-end"); + expect(completedSettlements).toBe(1); + + const failedEvents: TurnEngineEvent[] = []; + const failed = await startWithStreamResult({ + workspaceId: "completion-failure-workspace", + messageId: "completion-failure-message", + events: failedEvents, + fullStream: (async function* () { + await Promise.resolve(); + throw new Error("provider failed"); + yield* []; + })(), + }); + let failedSettlements = 0; + void failed.handle.completion.then(() => { + failedSettlements += 1; + }); + const failedCompletion = await failed.handle.completion; + expect(failedCompletion.status).toBe("failed"); + expect(failedEvents.at(-1)?.type).toBe("error"); + await failed.streamManager.stopStream("completion-failure-workspace"); + await Promise.resolve(); + expect(failedSettlements).toBe(1); + }); + + test("aborted completion waits for asynchronous abort delivery", async () => { + let releaseAbortDelivery!: () => void; + const abortDelivery = new Promise((resolve) => { + releaseAbortDelivery = resolve; + }); + const streamManager = new StreamManager(historyService, undefined, undefined, (event) => + event.type === "stream-abort" ? abortDelivery : undefined + ); + stubTokenTracker(streamManager); + Reflect.set( + streamManager, + "createStreamResult", + (_request: unknown, abortController: AbortController) => + createStreamResultForTests( + (async function* () { + await new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + yield* []; + })() + ) + ); + await appendPartialAssistantForTests( + "completion-abort-workspace", + "completion-abort-message", + 1 + ); + const result = await streamManager.startStream({ + workspaceId: "completion-abort-workspace", + messages: [{ role: "user", content: "hello" }], + model: createTestLanguageModel(), + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", + runtime, + messageId: "completion-abort-message", + providedRuntimeTempDir: "", + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected stream to start"); + + let settled = false; + void result.data.completion.then(() => { + settled = true; + }); + await streamManager.stopStream("completion-abort-workspace", { abortReason: "user" }); + await Promise.resolve(); + expect(settled).toBe(false); + + releaseAbortDelivery(); + expect(await result.data.completion).toEqual({ + status: "aborted", + messageId: "completion-abort-message", + abortReason: "user", + }); + }); +}); + describe("StreamManager - stripEncryptedContent", () => { test("strips encryptedContent from array output shape", () => { const output = [ @@ -2039,7 +2235,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => { beforeEach(() => { streamManager = new StreamManager(historyService); // Suppress error events from bubbling up as uncaught exceptions during tests - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); }); // Integration test - requires API key and TEST_INTEGRATION=1 @@ -2053,38 +2249,41 @@ describe("StreamManager - Concurrent Stream Prevention", () => { const streamStates: Record = {}; let firstMessageId: string | undefined; - streamManager.on("stream-start", (data: { messageId: string; historySequence: number }) => { - streamStates[data.messageId] = { started: true, finished: false }; - if (data.historySequence === 1) { - firstMessageId = data.messageId; + onTurnEngineEvent( + streamManager, + "stream-start", + (data: { messageId: string; historySequence: number }) => { + streamStates[data.messageId] = { started: true, finished: false }; + if (data.historySequence === 1) { + firstMessageId = data.messageId; + } } - }); + ); - streamManager.on("stream-end", (data: { messageId: string }) => { + onTurnEngineEvent(streamManager, "stream-end", (data: { messageId: string }) => { if (streamStates[data.messageId]) { streamStates[data.messageId].finished = true; } }); - streamManager.on("stream-abort", (data: { messageId: string }) => { + onTurnEngineEvent(streamManager, "stream-abort", (data: { messageId: string }) => { if (streamStates[data.messageId]) { streamStates[data.messageId].finished = true; } }); // Start first stream - const result1 = await streamManager.startStream( + const result1 = await streamManager.startStream({ workspaceId, - [{ role: "user", content: "Say hello and nothing else" }], + messages: [{ role: "user", content: "Say hello and nothing else" }], model, - KNOWN_MODELS.SONNET.id, - 1, - "You are a helpful assistant", + modelString: KNOWN_MODELS.SONNET.id, + historySequence: 1, + system: "You are a helpful assistant", runtime, - "test-msg-1", - undefined, - {} - ); + messageId: "test-msg-1", + tools: {}, + }); expect(result1.success).toBe(true); @@ -2092,18 +2291,17 @@ describe("StreamManager - Concurrent Stream Prevention", () => { await new Promise((resolve) => setTimeout(resolve, 200)); // Start second stream - should cancel first - const result2 = await streamManager.startStream( + const result2 = await streamManager.startStream({ workspaceId, - [{ role: "user", content: "Say goodbye and nothing else" }], + messages: [{ role: "user", content: "Say goodbye and nothing else" }], model, - KNOWN_MODELS.SONNET.id, - 2, - "You are a helpful assistant", + modelString: KNOWN_MODELS.SONNET.id, + historySequence: 2, + system: "You are a helpful assistant", runtime, - "test-msg-2", - undefined, - {} - ); + messageId: "test-msg-2", + tools: {}, + }); expect(result2.success).toBe(true); @@ -2265,18 +2463,17 @@ describe("StreamManager - Concurrent Stream Prevention", () => { // Without mutex, these would interleave (ensure-start, ensure-start, ensure-start, ensure-end, ensure-end, ensure-end) // With mutex, they should be serialized (ensure-start, ensure-end, ensure-start, ensure-end, ensure-start, ensure-end) const promises = [1, 2, 3].map((sequence) => - streamManager.startStream( + streamManager.startStream({ workspaceId, - [{ role: "user", content: `test ${sequence}` }], + messages: [{ role: "user", content: `test ${sequence}` }], model, - KNOWN_MODELS.SONNET.id, - sequence, - "system", + modelString: KNOWN_MODELS.SONNET.id, + historySequence: sequence, + system: "system", runtime, - `test-msg-${sequence}`, - undefined, - {} - ) + messageId: `test-msg-${sequence}`, + tools: {}, + }) ); // Wait for all to complete (they will fail due to dummy API key, but that's ok) @@ -2298,7 +2495,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => { let processCalled = false; let streamStartEmitted = false; - streamManager.on("stream-start", () => { + onTurnEngineEvent(streamManager, "stream-start", () => { streamStartEmitted = true; }); @@ -2368,18 +2565,18 @@ describe("StreamManager - Concurrent Stream Prevention", () => { const anthropic = createAnthropic({ apiKey: "dummy-key" }); const model = anthropic("claude-sonnet-4-5"); - const startPromise = streamManager.startStream( + const startPromise = streamManager.startStream({ workspaceId, - [{ role: "user", content: "test" }], + messages: [{ role: "user", content: "test" }], model, - KNOWN_MODELS.SONNET.id, - 1, - "system", + modelString: KNOWN_MODELS.SONNET.id, + historySequence: 1, + system: "system", runtime, - "test-msg-abort", - abortController.signal, - {} - ); + messageId: "test-msg-abort", + abortSignal: abortController.signal, + tools: {}, + }); await tempDirStarted; abortController.abort(); @@ -2402,10 +2599,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data); }); @@ -2488,10 +2685,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data); }); @@ -2561,10 +2758,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data); }); @@ -2627,10 +2824,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data); }); @@ -2684,10 +2881,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data); }); @@ -2767,7 +2964,7 @@ describe("StreamManager - empty stream completions", () => { test("zero-output refusal finishReason survives commit when usage is unavailable", async () => { const streamManager = new StreamManager(historyService); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -2839,7 +3036,7 @@ describe("StreamManager - empty stream completions", () => { recordHeadlessUsage, } as unknown as SessionUsageService; const streamManager = new StreamManager(historyService, sessionUsageService); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -2911,10 +3108,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data); }); @@ -3010,8 +3207,8 @@ describe("StreamManager - empty stream completions", () => { }; }> = []; - streamManager.on("error", (data) => errorEvents.push(data)); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => errorEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data as (typeof streamEndEvents)[number]); }); @@ -3163,8 +3360,8 @@ describe("StreamManager - empty stream completions", () => { parts?: Array<{ type: string; text?: string; toolName?: string }>; }> = []; - streamManager.on("error", (data) => errorEvents.push(data)); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => errorEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data as (typeof streamEndEvents)[number]); }); @@ -3300,8 +3497,8 @@ describe("StreamManager - empty stream completions", () => { }; }> = []; - streamManager.on("error", (data) => errorEvents.push(data)); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => errorEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data as (typeof streamEndEvents)[number]); }); @@ -3393,10 +3590,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => streamEndEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => streamEndEvents.push(data)); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -3477,8 +3674,8 @@ describe("StreamManager - empty stream completions", () => { }; }> = []; - streamManager.on("error", (data) => errorEvents.push(data)); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => errorEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data as (typeof streamEndEvents)[number]); }); @@ -3617,8 +3814,8 @@ describe("StreamManager - empty stream completions", () => { parts?: Array<{ type: string; text?: string; toolName?: string }>; }> = []; - streamManager.on("error", (data) => errorEvents.push(data)); - streamManager.on("stream-end", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => errorEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => { streamEndEvents.push(data as (typeof streamEndEvents)[number]); }); @@ -3777,10 +3974,10 @@ describe("StreamManager - empty stream completions", () => { const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; const streamEndEvents: unknown[] = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); - streamManager.on("stream-end", (data) => streamEndEvents.push(data)); + onTurnEngineEvent(streamManager, "stream-end", (data) => streamEndEvents.push(data)); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -3894,7 +4091,7 @@ describe("StreamManager - empty stream completions", () => { const streamManager = new StreamManager(historyService); const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); @@ -3971,7 +4168,7 @@ describe("StreamManager - empty stream completions", () => { const streamManager = new StreamManager(historyService); const errorEvents: Array<{ messageId: string; error: string; errorType?: string }> = []; - streamManager.on("error", (data) => { + onTurnEngineEvent(streamManager, "error", (data) => { errorEvents.push(data as { messageId: string; error: string; errorType?: string }); }); @@ -4133,13 +4330,13 @@ describe("StreamManager - TTFT metadata persistence", () => { }) { const streamManager = params.streamManager ?? new StreamManager(historyService); // Suppress error events from bubbling up as uncaught exceptions during tests - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); if (params.onStreamStart) { - streamManager.on("stream-start", params.onStreamStart); + onTurnEngineEvent(streamManager, "stream-start", params.onStreamStart); } if (params.onStreamEnd) { - streamManager.on("stream-end", params.onStreamEnd); + onTurnEngineEvent(streamManager, "stream-end", params.onStreamEnd); } const replaceTokenTrackerResult = Reflect.set(streamManager, "tokenTracker", { @@ -4853,7 +5050,7 @@ describe("StreamManager - replayStream", () => { function createReplayStreamManager(): StreamManager { const streamManager = new StreamManager(historyService); // Suppress error events from bubbling up as uncaught exceptions during tests. - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); return streamManager; } @@ -4881,17 +5078,21 @@ describe("StreamManager - replayStream", () => { const streamManager = createReplayStreamManager(); let sawStreamStart = false; - streamManager.on("stream-start", (event: { replay?: boolean | undefined }) => { + onTurnEngineEvent(streamManager, "stream-start", (event: { replay?: boolean | undefined }) => { sawStreamStart = true; expect(event.replay).toBe(true); }); const workspaceId = "ws-replay-snapshot"; const deltas: string[] = []; - streamManager.on("stream-delta", (event: { delta: string; replay?: boolean | undefined }) => { - expect(event.replay).toBe(true); - deltas.push(event.delta); - }); + onTurnEngineEvent( + streamManager, + "stream-delta", + (event: { delta: string; replay?: boolean | undefined }) => { + expect(event.replay).toBe(true); + deltas.push(event.delta); + } + ); const streamInfo = { state: "streaming", @@ -4934,7 +5135,8 @@ describe("StreamManager - replayStream", () => { const workspaceId = "ws-replay-tool-filter"; const replayedToolEnds: string[] = []; - streamManager.on( + onTurnEngineEvent( + streamManager, "tool-call-end", (event: { replay?: boolean | undefined; toolCallId: string }) => { expect(event.replay).toBe(true); @@ -4990,7 +5192,8 @@ describe("StreamManager - replayStream", () => { const workspaceId = "ws-replay-exec-start"; const replayedToolStarts: Array<{ toolCallId: string; executionStartedAt?: number }> = []; - streamManager.on( + onTurnEngineEvent( + streamManager, "tool-call-start", (event: { replay?: boolean | undefined; @@ -5051,26 +5254,11 @@ describe("StreamManager - replayStream", () => { const streamManager = createReplayStreamManager(); const workspaceId = "ws-replay-usage"; - const usageEvents: Array<{ - replay?: boolean; - usage: { inputTokens: number; outputTokens: number; totalTokens: number }; - providerMetadata?: Record; - cumulativeUsage: { inputTokens: number; outputTokens: number; totalTokens: number }; - cumulativeProviderMetadata?: Record; - }> = []; + const usageEvents: Array> = []; - streamManager.on( - "usage-delta", - (event: { - replay?: boolean; - usage: { inputTokens: number; outputTokens: number; totalTokens: number }; - providerMetadata?: Record; - cumulativeUsage: { inputTokens: number; outputTokens: number; totalTokens: number }; - cumulativeProviderMetadata?: Record; - }) => { - usageEvents.push(event); - } - ); + onTurnEngineEvent(streamManager, "usage-delta", (event) => { + usageEvents.push(event); + }); setReplayStreamInfo(streamManager, workspaceId, { state: "streaming", @@ -5114,7 +5302,7 @@ describe("StreamManager - replayStream", () => { const workspaceId = "ws-replay-usage-incremental"; const usageEvents: Array<{ replay?: boolean }> = []; - streamManager.on("usage-delta", (event: { replay?: boolean }) => { + onTurnEngineEvent(streamManager, "usage-delta", (event: { replay?: boolean }) => { usageEvents.push(event); }); @@ -5293,9 +5481,13 @@ describe("StreamManager - stopStream", () => { // Track emitted events const abortEvents: Array<{ workspaceId: string; messageId: string }> = []; - streamManager.on("stream-abort", (data: { workspaceId: string; messageId: string }) => { - abortEvents.push(data); - }); + onTurnEngineEvent( + streamManager, + "stream-abort", + (data: { workspaceId: string; messageId: string }) => { + abortEvents.push(data); + } + ); // Stop a stream that doesn't exist (simulates interrupt before stream-start) const result = await streamManager.stopStream("test-workspace"); @@ -5329,7 +5521,7 @@ describe("StreamManager - aborted stream usage persistence", () => { test("stamps cumulative usage on the partial so committed history rows stay billable", async () => { const streamManager = new StreamManager(historyService); - streamManager.on("stream-abort", () => undefined); + onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-usage-workspace"; const messageId = "abort-usage-message"; await appendPartialAssistantForTests(workspaceId, messageId, 1); @@ -5365,7 +5557,7 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("stream-abort", () => undefined); + onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-tool-only-workspace"; const usage = { inputTokens: 500, outputTokens: 0, totalTokens: 500 }; @@ -5424,7 +5616,7 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("stream-abort", () => undefined); + onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-commit-worthy-workspace"; await appendPartialAssistantForTests(workspaceId, "abort-commit-worthy-message", 1); @@ -5457,7 +5649,7 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); const workspaceId = "error-nondurable-workspace"; const usage = { inputTokens: 900, outputTokens: 0, totalTokens: 900 }; @@ -5504,7 +5696,7 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("error", () => undefined); + onTurnEngineEvent(streamManager, "error", () => undefined); const workspaceId = "error-commit-worthy-workspace"; const usage = { inputTokens: 900, outputTokens: 40, totalTokens: 940 }; @@ -5554,7 +5746,7 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("stream-abort", () => undefined); + onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-abandon-workspace"; const messageId = "abort-abandon-message"; diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 5ee4e80a336..049458fbc64 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1,4 +1,3 @@ -import { EventEmitter } from "events"; import * as path from "path"; import { PlatformPaths } from "@/common/utils/paths"; import { eventSpine } from "@/node/services/events/eventSpine"; @@ -22,11 +21,18 @@ import { Ok, Err } from "@/common/types/result"; import { log, type Logger } from "./log"; import type { StreamStartEvent, + StreamDeltaEvent, StreamEndEvent, + StreamAbortEvent, StreamAbortReason, + ErrorEvent, UsageDeltaEvent, + ToolCallStartEvent, + ToolCallDeltaEvent, ToolCallEndEvent, ToolCallExecutionStartEvent, + ReasoningDeltaEvent, + ReasoningEndEvent, CompletedMessagePart, WorkflowRunAttachedEvent, } from "@/common/types/stream"; @@ -181,6 +187,95 @@ type ToolCallMap = Map; type WorkspaceId = string & { __brand: "WorkspaceId" }; type StreamToken = string & { __brand: "StreamToken" }; +export type TurnEngineEvent = + | StreamStartEvent + | StreamDeltaEvent + | StreamEndEvent + | StreamAbortEvent + | ErrorEvent + | UsageDeltaEvent + | ToolCallStartEvent + | ToolCallExecutionStartEvent + | ToolCallDeltaEvent + | ToolCallEndEvent + | ReasoningDeltaEvent + | ReasoningEndEvent + | WorkflowRunAttachedEvent; + +export type TurnEngineEventSink = (event: TurnEngineEvent) => void | Promise; + +export type TurnCompletion = + | { status: "completed"; messageId: string } + | { status: "aborted"; messageId: string; abortReason: StreamAbortReason } + | { + status: "failed"; + messageId: string; + error: SendMessageError; + streamError: StreamErrorPayload & { errorType: StreamErrorType }; + }; + +export interface TurnStreamHandle { + streamToken: string; + messageId: string; + completion: Promise; +} + +interface TurnCompletionController { + promise: Promise; + settle: (completion: TurnCompletion) => void; +} + +function createTurnCompletionController(): TurnCompletionController { + let settled = false; + let resolveCompletion!: (completion: TurnCompletion) => void; + const promise = new Promise((resolve) => { + resolveCompletion = resolve; + }); + return { + promise, + settle: (completion) => { + if (settled) return; + settled = true; + resolveCompletion(completion); + }, + }; +} + +export interface TurnExecutionOptions { + workspaceId: string; + messages: ModelMessage[]; + model: LanguageModel; + modelString: string; + historySequence: number; + system: string; + runtime: Runtime; + messageId: string; + abortSignal?: AbortSignal; + tools?: Record; + initialMetadata?: Partial; + providerOptions?: Record; + maxOutputTokens?: number; + toolPolicy?: ToolPolicy; + providedStreamToken?: StreamToken; + hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean; + workspaceName?: string; + thinkingLevel?: string; + headers?: Record; + anthropicCacheTtlOverride?: AnthropicCacheTtl; + callSettingsOverrides?: ResolvedCallSettingsOverrides; + onChunk?: StreamTextOnChunk; + onStepMessages?: (messages: ModelMessage[]) => void; + providedRuntimeTempDir?: string; + modelFallback?: ModelFallbackOptions; + toolSearchState?: ToolSearchStreamState; + thinkingOverrideState?: ActiveTurnThinkingOverride; + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel; + forcedFirstStepToolNames?: string[]; + providersConfigSnapshot?: ProvidersConfigMap; + onStreamConstructed?: () => Promise; + rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel; +} + // Stream request config for start/retry interface StepMessageTracker { @@ -651,6 +746,8 @@ interface WorkspaceStreamInfo { lastStepUsage?: LanguageModelV2Usage; // Last step's provider metadata (for context window cache display) lastStepProviderMetadata?: Record; + completionController: TurnCompletionController; + terminalCompletion?: TurnCompletion; } // Ensure per-stream part timestamps are strictly monotonic. @@ -672,8 +769,10 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { * - Only one active stream per workspace at any time * - Atomic stream creation/cancellation operations * - Guaranteed resource cleanup in all code paths + * + * Physical inlining into AIService is intentionally deferred to a mechanical follow-up. */ -export class StreamManager extends EventEmitter { +export class StreamManager { private workspaceStreams = new Map(); private streamLocks = new Map(); private readonly PARTIAL_WRITE_THROTTLE_MS = 500; @@ -681,6 +780,7 @@ export class StreamManager extends EventEmitter { private mcpServerManager?: MCPServerManager; private readonly sessionUsageService?: SessionUsageService; private readonly getProvidersConfig: () => ProvidersConfigMap | null; + private readonly eventSink: TurnEngineEventSink; // Token tracker for live streaming statistics private tokenTracker = new StreamingTokenTracker(); // Track OpenAI previousResponseIds that have been invalidated @@ -690,12 +790,17 @@ export class StreamManager extends EventEmitter { constructor( historyService: HistoryService, sessionUsageService?: SessionUsageService, - getProvidersConfig?: () => ProvidersConfigMap | null + getProvidersConfig?: () => ProvidersConfigMap | null, + eventSink: TurnEngineEventSink = () => undefined ) { - super(); this.historyService = historyService; this.sessionUsageService = sessionUsageService; this.getProvidersConfig = getProvidersConfig ?? (() => null); + this.eventSink = eventSink; + } + + private emitTurnEvent(event: TurnEngineEvent): void { + void this.eventSink(event); } private getWorkspaceLogger( @@ -769,7 +874,7 @@ export class StreamManager extends EventEmitter { toolCallId: string; attachment: WorkflowRunToolAttachment; }): void { - this.emit("workflow-run-attached", { + this.emitTurnEvent({ type: "workflow-run-attached", workspaceId: input.workspaceId as string, messageId: input.messageId, @@ -835,7 +940,7 @@ export class StreamManager extends EventEmitter { assert(part.type === "dynamic-tool", "applyToolExecutionStart matched a non-tool part"); streamInfo.parts[partIndex] = { ...part, executionStartedAt: timestamp }; - this.emit("tool-call-execution-start", { + this.emitTurnEvent({ type: "tool-call-execution-start", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -1055,7 +1160,7 @@ export class StreamManager extends EventEmitter { const tokens = await this.tokenTracker.countTokens(deltaText); const timestamp = Date.now(); - this.emit("tool-call-delta", { + this.emitTurnEvent({ type: "tool-call-delta", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -1270,7 +1375,7 @@ export class StreamManager extends EventEmitter { if (part.type === "text") { const tokens = await this.tokenTracker.countTokens(part.text); - this.emit("stream-delta", { + this.emitTurnEvent({ type: "stream-delta", workspaceId: workspaceId as string, messageId, @@ -1281,7 +1386,7 @@ export class StreamManager extends EventEmitter { }); } else if (part.type === "reasoning") { const tokens = await this.tokenTracker.countTokens(part.text); - this.emit("reasoning-delta", { + this.emitTurnEvent({ type: "reasoning-delta", workspaceId: workspaceId as string, messageId, @@ -1294,7 +1399,7 @@ export class StreamManager extends EventEmitter { } else if (part.type === "dynamic-tool") { const inputText = JSON.stringify(part.input); const tokens = await this.tokenTracker.countTokens(inputText); - this.emit("tool-call-start", { + this.emitTurnEvent({ type: "tool-call-start", workspaceId: workspaceId as string, messageId, @@ -1312,7 +1417,7 @@ export class StreamManager extends EventEmitter { }); if (part.workflowRun != null) { - this.emit("workflow-run-attached", { + this.emitTurnEvent({ type: "workflow-run-attached", workspaceId: workspaceId as string, messageId, @@ -1326,7 +1431,7 @@ export class StreamManager extends EventEmitter { // If tool has output, emit completion if (part.state === "output-available") { - this.emit("tool-call-end", { + this.emitTurnEvent({ type: "tool-call-end", workspaceId: workspaceId as string, messageId, @@ -1378,7 +1483,7 @@ export class StreamManager extends EventEmitter { } streamInfo.parts.push(partToPersist); if (pendingExecutionStart !== undefined && part.type === "dynamic-tool") { - this.emit("tool-call-execution-start", { + this.emitTurnEvent({ type: "tool-call-execution-start", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -1563,8 +1668,9 @@ export class StreamManager extends EventEmitter { } } - // Emit abort event with usage if available - this.emitStreamAbort( + // Emit abort asynchronously as before; completion settles only after the facade's + // partial cleanup and external stream-abort emission have finished. + const abortDelivery = this.emitStreamAbort( workspaceId, streamInfo.messageId, { usage, contextUsage, duration, providerMetadata, contextProviderMetadata }, @@ -1575,6 +1681,13 @@ export class StreamManager extends EventEmitter { // Clean up immediately this.workspaceStreams.delete(workspaceId); + void abortDelivery.finally(() => { + streamInfo.completionController?.settle({ + status: "aborted", + messageId: streamInfo.messageId, + abortReason, + }); + }); } /** @@ -2074,7 +2187,8 @@ export class StreamManager extends EventEmitter { rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel, forcedFirstStepToolNames?: string[], providersConfigSnapshot?: ProvidersConfigMap, - rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel + rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel, + completionController?: TurnCompletionController ): WorkspaceStreamInfo { // abortController is created and linked to the caller-provided abortSignal in startStream(). @@ -2158,6 +2272,7 @@ export class StreamManager extends EventEmitter { partialWritePromise: undefined, // No write in flight initially processingPromise: Promise.resolve(), // Placeholder, overwritten in startStream softInterrupt: { pending: false }, + completionController: completionController ?? createTurnCompletionController(), runtimeTempDir, // Stream-scoped temp directory for tool outputs runtime, // Runtime for temp directory cleanup // Initialize cumulative tracking for multi-step streams @@ -2248,7 +2363,7 @@ export class StreamManager extends EventEmitter { const completionTimestamp = nextPartTimestamp(streamInfo); streamInfo.toolCompletionTimestamps ??= new Map(); streamInfo.toolCompletionTimestamps.set(toolCallId, completionTimestamp); - this.emit("tool-call-end", { + this.emitTurnEvent({ type: "tool-call-end", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -2388,7 +2503,7 @@ export class StreamManager extends EventEmitter { // Emit to frontend if (event.type === "tool-call-start") { - this.emit("tool-call-start", { + this.emitTurnEvent({ type: "tool-call-start", workspaceId, messageId, @@ -2400,7 +2515,7 @@ export class StreamManager extends EventEmitter { parentToolCallId: event.parentToolCallId, }); } else if (event.type === "tool-call-end") { - this.emit("tool-call-end", { + this.emitTurnEvent({ type: "tool-call-end", workspaceId, messageId, @@ -2434,7 +2549,7 @@ export class StreamManager extends EventEmitter { streamInfo.model.startsWith("mux-gateway:"); const routeProvider = streamInfo.initialMetadata?.routeProvider; - this.emit("stream-start", { + this.emitTurnEvent({ type: "stream-start", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -2474,16 +2589,18 @@ export class StreamManager extends EventEmitter { abortReason: StreamAbortReason, abandonPartial?: boolean, acpPromptId?: string - ): void { - this.emit("stream-abort", { - type: "stream-abort", - workspaceId: workspaceId as string, - messageId, - abortReason, - metadata, - abandonPartial, - acpPromptId, - }); + ): Promise { + return Promise.resolve( + this.eventSink({ + type: "stream-abort", + workspaceId: workspaceId as string, + messageId, + abortReason, + metadata, + abandonPartial, + acpPromptId, + }) + ); } private async handleEmptyStreamCompletion( @@ -3164,7 +3281,7 @@ export class StreamManager extends EventEmitter { ); // Emit signature update event for Anthropic UI consumers. if (signature) { - this.emit("reasoning-delta", { + this.emitTurnEvent({ type: "reasoning-delta", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -3218,7 +3335,7 @@ export class StreamManager extends EventEmitter { } } - this.emit("reasoning-end", { + this.emitTurnEvent({ type: "reasoning-end", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -3499,7 +3616,7 @@ export class StreamManager extends EventEmitter { costsIncluded: streamInfo.initialMetadata?.costsIncluded, }); streamInfo.currentStepStartIndex = streamInfo.parts.length; - this.emit("usage-delta", usageEvent); + this.emitTurnEvent(usageEvent); await this.checkSoftCancelStream(workspaceId, streamInfo); break; } @@ -3695,7 +3812,11 @@ export class StreamManager extends EventEmitter { // Compaction handler listens to this event and clears history - if we emit // before updateHistory completes, compaction can clear the file and then // updateHistory writes stale data back. - this.emit("stream-end", streamEndEvent); + this.emitTurnEvent(streamEndEvent); + streamInfo.terminalCompletion = { + status: "completed", + messageId: streamInfo.messageId, + }; } break; } catch (error) { @@ -3755,6 +3876,10 @@ export class StreamManager extends EventEmitter { workspaceId: workspaceId as string, messageId: streamInfo.messageId, }); + + if (streamInfo.terminalCompletion != null) { + streamInfo.completionController?.settle(streamInfo.terminalCompletion); + } } } @@ -3777,7 +3902,13 @@ export class StreamManager extends EventEmitter { this.recordLostResponseIdIfApplicable(workspaceId, error, streamInfo, workspaceLog); const errorPayload = this.buildStreamErrorPayload(streamInfo, error); - await this.persistStreamError(workspaceId, streamInfo, errorPayload); + const persistedPayload = await this.persistStreamError(workspaceId, streamInfo, errorPayload); + streamInfo.terminalCompletion = { + status: "failed", + messageId: streamInfo.messageId, + error: { type: "unknown", raw: persistedPayload.error }, + streamError: persistedPayload, + }; } private buildStreamErrorPayload( @@ -3912,7 +4043,7 @@ export class StreamManager extends EventEmitter { workspaceId: WorkspaceId, streamInfo: WorkspaceStreamInfo, payload: StreamErrorPayload & { errorType: StreamErrorType } - ): Promise { + ): Promise { // Clamp at the single choke point every stream error payload passes // through before persist/emit, including buildStreamErrorPayload's // early-return branches (e.g. ModelRefusalError, whose fallbackNote can @@ -4006,8 +4137,9 @@ export class StreamManager extends EventEmitter { log.error("Failed to record errored-stream usage in headless sidecar", { error }); } - // Emit error event. - this.emit("error", createErrorEvent(workspaceId as string, payload)); + // Emit error event before completion settles so recovery bookkeeping is ready for waiters. + this.emitTurnEvent(createErrorEvent(workspaceId as string, payload)); + return payload; } private getOpenAIPreviousResponseId( @@ -4393,48 +4525,49 @@ export class StreamManager extends EventEmitter { * 3. No race conditions in stream registration or cleanup */ async startStream( - workspaceId: string, - messages: ModelMessage[], - model: LanguageModel, - modelString: string, - historySequence: number, - system: string, - runtime: Runtime, - messageId: string, - abortSignal?: AbortSignal, - tools?: Record, - initialMetadata?: Partial, - providerOptions?: Record, - maxOutputTokens?: number, - toolPolicy?: ToolPolicy, - providedStreamToken?: StreamToken, - hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean, - workspaceName?: string, - thinkingLevel?: string, - headers?: Record, - anthropicCacheTtlOverride?: AnthropicCacheTtl, - callSettingsOverrides?: ResolvedCallSettingsOverrides, - onChunk?: StreamTextOnChunk, - onStepMessages?: (messages: ModelMessage[]) => void, - providedRuntimeTempDir?: string, - modelFallback?: ModelFallbackOptions, - toolSearchState?: ToolSearchStreamState, - thinkingOverrideState?: ActiveTurnThinkingOverride, - rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames?: string[], - // Pinned providers-config snapshot the request was assembled from (see - // AIService's pinCoderWireProvidersConfig): request-config building and - // metadata resolution must not re-read live config after model creation. - providersConfigSnapshot?: ProvidersConfigMap, - // Invoked once the stream is constructed and registered (before - // processing). Durable side effects describing this request — the turn - // envelope — belong here: earlier emission persists phantom rows when - // setup aborts or fails before any provider request exists. Must not throw. - onStreamConstructed?: () => Promise, - // Step-0 message rebuild for thinking overrides that race stream setup - // (see RebuildFirstStepForThinkingLevel). - rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel - ): Promise> { + options: TurnExecutionOptions + ): Promise> { + const { + workspaceId, + messages, + model, + modelString, + historySequence, + system, + runtime, + messageId, + abortSignal, + tools, + initialMetadata, + providerOptions, + maxOutputTokens, + toolPolicy, + providedStreamToken, + hasQueuedMessages, + workspaceName, + thinkingLevel, + headers, + anthropicCacheTtlOverride, + callSettingsOverrides, + onChunk, + onStepMessages, + providedRuntimeTempDir, + modelFallback, + toolSearchState, + thinkingOverrideState, + rebuildProviderOptionsForThinkingLevel, + forcedFirstStepToolNames, + providersConfigSnapshot, + onStreamConstructed, + rebuildFirstStepForThinkingLevel, + } = options; + const completionController = createTurnCompletionController(); + const createHandle = (streamToken: StreamToken): TurnStreamHandle => ({ + streamToken, + messageId, + completion: completionController.promise, + }); + const typedWorkspaceId = workspaceId as WorkspaceId; if (messages.length === 0) { @@ -4477,7 +4610,8 @@ export class StreamManager extends EventEmitter { // If the stream was interrupted while we were waiting on async setup (mutex, // temp dir creation, etc), avoid starting the stream entirely. if (streamAbortController.signal.aborted) { - return Ok(streamToken); + completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + return Ok(createHandle(streamToken)); } // Step 3: Create temp directory for this stream using runtime. @@ -4487,7 +4621,8 @@ export class StreamManager extends EventEmitter { providedRuntimeTempDir ?? (await this.createTempDirForStream(streamToken, runtime)); if (streamAbortController.signal.aborted) { - return Ok(streamToken); + completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + return Ok(createHandle(streamToken)); } // Step 4: Atomic stream creation and registration @@ -4522,7 +4657,8 @@ export class StreamManager extends EventEmitter { rebuildProviderOptionsForThinkingLevel, forcedFirstStepToolNames, providersConfigSnapshot, - rebuildFirstStepForThinkingLevel + rebuildFirstStepForThinkingLevel, + completionController ); // Guard against a narrow race: @@ -4532,7 +4668,8 @@ export class StreamManager extends EventEmitter { // In that case, immediately drop the registered stream and rely on the caller to handle UI. if (streamAbortController.signal.aborted) { this.workspaceStreams.delete(typedWorkspaceId); - return Ok(streamToken); + completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + return Ok(createHandle(streamToken)); } streamInfo.unlinkAbortSignal = unlinkAbortSignal; @@ -4557,7 +4694,8 @@ export class StreamManager extends EventEmitter { this.workspaceStreams.delete(typedWorkspaceId); } streamRegistered = false; - return Ok(streamToken); + completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + return Ok(createHandle(streamToken)); } // Step 5: Track the processing promise for guaranteed cleanup @@ -4570,7 +4708,7 @@ export class StreamManager extends EventEmitter { log.error("Unexpected error in stream processing:", error); }); - return Ok(streamToken); + return Ok(createHandle(streamToken)); } finally { if (!streamRegistered) { runLanguageModelCleanup(model); @@ -4741,7 +4879,7 @@ export class StreamManager extends EventEmitter { // Emit abort event so frontend clears pending stream state. // This handles the case where user interrupts before stream-start arrives. // Use empty messageId - frontend handles gracefully (just clears pendingStreamStartTime). - this.emitStreamAbort(typedWorkspaceId, "", {}, abortReason, options?.abandonPartial); + void this.emitStreamAbort(typedWorkspaceId, "", {}, abortReason, options?.abandonPartial); return Ok(undefined); } @@ -4952,7 +5090,7 @@ export class StreamManager extends EventEmitter { // Replays must preserve gateway-billed zero-cost behavior from the original stream. costsIncluded: streamInfo.initialMetadata?.costsIncluded, }); - this.emit("usage-delta", usageEvent); + this.emitTurnEvent(usageEvent); } } From 0d64704e8c6c874deb46b944a274dd711807d7ce Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:24:44 +0000 Subject: [PATCH 22/42] =?UTF-8?q?=F0=9F=A4=96=20test(streaming):=20return?= =?UTF-8?q?=20turn=20handles=20from=20session=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update stale AIService doubles to satisfy the new successful-start contract and provide the completion promise consumed by AgentSession. --- src/node/services/agentSession.budgetGate.test.ts | 11 ++++++++++- .../agentSession.fileChangeNotification.test.ts | 11 ++++++++++- .../services/agentSession.preTurnMessages.test.ts | 11 ++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/node/services/agentSession.budgetGate.test.ts b/src/node/services/agentSession.budgetGate.test.ts index 5481647bb8b..d06a1be49ed 100644 --- a/src/node/services/agentSession.budgetGate.test.ts +++ b/src/node/services/agentSession.budgetGate.test.ts @@ -16,6 +16,7 @@ import { registerNoopContinuationBridgeForTest } from "./testDispatchHelpers"; import { Ok } from "@/common/types/result"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { GoalRecordV1 } from "@/common/types/goal"; +import type { TurnStreamHandle } from "./streamManager"; const PROJECT_PATH = "/tmp/mux-agent-session-budget-gate-test-project"; const PRICED_OPTIONS: SendMessageOptions = { model: "openai:gpt-4o-mini", agentId: "exec" }; @@ -24,6 +25,14 @@ const UNPRICED_OPTIONS: SendMessageOptions = { agentId: "exec", }; +function createStartedTurnHandle(): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId: "test-assistant", + completion: new Promise(() => undefined), + }; +} + interface SessionHarness { historyService: HistoryService; session: AgentSession; @@ -48,7 +57,7 @@ function createAiService(workspaceId: string): AIService { return Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), - streamMessage: mock((_request: unknown) => Promise.resolve(Ok(undefined))), + streamMessage: mock((_request: unknown) => Promise.resolve(Ok(createStartedTurnHandle()))), getStreamInfo: mock((_workspaceId: string) => null), getProvidersConfig: mock(() => null), getWorkspaceMetadata: mock((_workspaceId: string) => diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index f0af096130d..a2086315ce0 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -12,6 +12,15 @@ import type { AIService, StreamMessageOptions } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { InitStateManager } from "./initStateManager"; import { createTestHistoryService } from "./testHistoryService"; +import type { TurnStreamHandle } from "./streamManager"; + +function createStartedTurnHandle(): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId: "test-assistant", + completion: new Promise(() => undefined), + }; +} /** * Log purity: externally-edited files must produce a durable @@ -47,7 +56,7 @@ describe("AgentSession file-change notification (turn start)", () => { const capturedRequests: MuxMessage[][] = []; const streamMessage = mock((opts: StreamMessageOptions) => { capturedRequests.push(opts.messages); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const aiService: AIService = { on: mock(() => aiService), diff --git a/src/node/services/agentSession.preTurnMessages.test.ts b/src/node/services/agentSession.preTurnMessages.test.ts index 42bcecfa7c1..158693186df 100644 --- a/src/node/services/agentSession.preTurnMessages.test.ts +++ b/src/node/services/agentSession.preTurnMessages.test.ts @@ -8,6 +8,15 @@ import { createMuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; +import type { TurnStreamHandle } from "./streamManager"; + +function createStartedTurnHandle(): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId: "test-assistant", + completion: new Promise(() => undefined), + }; +} const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { @@ -26,7 +35,7 @@ describe("AgentSession.sendMessage (preTurnMessages)", () => { const { historyService, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; - const streamMessage = mock(() => Promise.resolve(Ok(undefined))); + const streamMessage = mock(() => Promise.resolve(Ok(createStartedTurnHandle()))); const aiService = Object.assign(new EventEmitter(), { isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), From 26a00ff463ed4d60ed87b6849998bfc7a6875894 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:19:43 +0000 Subject: [PATCH 23/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20tid?= =?UTF-8?q?y=20turn-engine=20seam=20declarations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move AgentSessionResult below the import block with a doc comment and document why completion settlement optional-chains the controller. --- src/node/services/agentSession.ts | 13 +++++++++---- src/node/services/streamManager.ts | 1 + 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index a0c6a40f565..e19f3a1531c 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -66,10 +66,6 @@ import { } from "@/node/services/utils/fileChangeTracker"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; - -type AgentSessionResult = - | { success: true; data: T } - | { success: false; error: SendMessageError; failureHandled?: true }; import { coerceOpenAIReasoningMode, coerceThinkingLevel, @@ -197,6 +193,15 @@ import { parseSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelo import { getErrorMessage } from "@/common/utils/errors"; import { CompactionMonitor, type CompactionStatusEvent } from "./compactionMonitor"; +/** + * Result shape for turn-starting session methods. failureHandled marks errors + * whose retry/abandon bookkeeping already ran inside streamWithHistory, so + * callers must not re-handle them (would double-increment backoff attempts). + */ +type AgentSessionResult = + | { success: true; data: T } + | { success: false; error: SendMessageError; failureHandled?: true }; + /** * Tracked file state for detecting external edits. * Uses timestamp-based polling with diff injection. diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 049458fbc64..3503d92f245 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -3878,6 +3878,7 @@ export class StreamManager { }); if (streamInfo.terminalCompletion != null) { + // Optional-chained: whitebox test fixtures register stream infos without a controller. streamInfo.completionController?.settle(streamInfo.terminalCompletion); } } From 856eef29087b513bfb27d3f8d6bda7f7d763ca34 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:05:18 +0000 Subject: [PATCH 24/42] =?UTF-8?q?=F0=9F=A4=96=20fix(streaming):=20deliver?= =?UTF-8?q?=20handle-less=20turn=20failures=20exactly=20once?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review round 1: settle a failed completion for debug-injected stream errors, and hand pre-start error events (runtime readiness, strict agent resolution) to the returned-failure path so each is handled once and its recovery decision resolves. --- .../agentSession.preStreamError.test.ts | 45 ++++++ src/node/services/agentSession.ts | 149 ++++++++++++------ src/node/services/streamManager.test.ts | 47 ++++++ src/node/services/streamManager.ts | 10 +- 4 files changed, 206 insertions(+), 45 deletions(-) diff --git a/src/node/services/agentSession.preStreamError.test.ts b/src/node/services/agentSession.preStreamError.test.ts index a2619c076c6..57ce869399d 100644 --- a/src/node/services/agentSession.preStreamError.test.ts +++ b/src/node/services/agentSession.preStreamError.test.ts @@ -1204,4 +1204,49 @@ describe("AgentSession pre-stream errors", () => { expect(replayInit).toHaveBeenCalledWith(workspaceId); expect(events.some((event) => "type" in event && event.type === "caught-up")).toBe(true); }); + + it("handles a pre-start error event once and resolves its recovery decision", async () => { + const workspaceId = "ws-prestart-error-event"; + const preStartMessageId = "assistant-prestart-error"; + + // Mirrors AIService's runtime-readiness failure: the error event fires for + // fire-and-forget senders, then streamMessage returns Err with no handle. + const aiEmitter = new EventEmitter(); + const streamMessage = mock((_history: MuxMessage[]) => { + aiEmitter.emit("error", { + workspaceId, + messageId: preStartMessageId, + error: "Runtime unavailable.", + errorType: "runtime_not_ready", + }); + return Promise.resolve(Err({ type: "runtime_not_ready", message: "Runtime unavailable." })); + }); + + const harness = await createAgentSessionHarness({ + workspaceId, + aiEmitter, + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + captureEvents: true, + }); + historyCleanup = harness.cleanup; + + await harness.session.sendMessage("hello", { + model: "anthropic:claude-3-5-sonnet-latest", + agentId: "exec", + }); + + // The decision opened at event emission must resolve through the returned + // failure path; an unresolved decision would hang settlement waiters. + expect(await harness.session.waitForPendingStreamErrorRecoveryDecision(preStartMessageId)).toBe( + "terminal" + ); + + const streamErrors = harness.events.filter( + (event): event is StreamErrorMessage => + "type" in event && event.type === "stream-error" && event.messageId === preStartMessageId + ); + expect(streamErrors).toHaveLength(1); + }); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index e19f3a1531c..01f3158867f 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -9,7 +9,7 @@ import { log } from "@/node/services/log"; import { eventSpine } from "@/node/services/events/eventSpine"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; -import type { TurnCompletion } from "@/node/services/streamManager"; +import type { TurnStreamHandle } from "@/node/services/streamManager"; import type { HistoryService } from "@/node/services/historyService"; import type { SessionUsageService } from "@/node/services/sessionUsageService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -1152,6 +1152,20 @@ export class AgentSession { decision.resolve(handled); } + /** Turn handle whose completion consumeTurnCompletion() is currently observing. */ + private activeTurnStreamHandle: TurnStreamHandle | null = null; + + /** + * Capture list for error events observed while this session's streamMessage + * call is in flight and no turn handle owns them (pre-start failures such as + * runtime readiness or strict agent resolution). AIService emits these for + * fire-and-forget senders and then returns Err, so no completion will ever + * deliver them; the Err path handles each exactly once via the event's own + * messageId. Null outside the in-flight window so unrelated error events + * (e.g. a later mid-turn failure) are never captured. + */ + private preStartErrorCapture: StreamErrorPayload[] | null = null; + private beginStreamErrorRecoveryDecision(messageId: string): void { // Duplicate error events for the same attempt share one decision. if (this.streamErrorRecoveryDecisions.has(messageId)) { @@ -4833,8 +4847,28 @@ export class AgentSession { private async handleStreamWithHistoryFailure( error: SendMessageError, - acpPromptId?: string + acpPromptId?: string, + preStartErrors?: StreamErrorPayload[] | null ): Promise> { + // Pre-start failures that AIService also announced as error events (for + // fire-and-forget senders) have no turn handle, so their handling and + // recovery-decision resolution must run here, keyed by each event's own + // messageId. Handling them first keeps this the single owner: the branches + // below only cover failures that produced no error event. + if (preStartErrors != null && preStartErrors.length > 0) { + for (const payload of preStartErrors) { + try { + await this.handleStreamError({ + ...payload, + acpPromptId: payload.acpPromptId ?? acpPromptId, + }); + } finally { + this.resolveStreamErrorRecoveryDecision(payload.messageId, "terminal"); + } + } + return { success: false, error, failureHandled: true }; + } + const failureType = error.type; if (failureType === "runtime_not_ready" || failureType === "runtime_start_failed") { @@ -4853,8 +4887,9 @@ export class AgentSession { return { success: false, error, failureHandled: true }; } - private consumeTurnCompletion(completion: Promise): void { - void completion + private consumeTurnCompletion(handle: TurnStreamHandle): void { + this.activeTurnStreamHandle = handle; + void handle.completion .then(async (outcome) => { if (outcome.status !== "failed") return; @@ -4869,6 +4904,11 @@ export class AgentSession { workspaceId: this.workspaceId, error: getErrorMessage(error), }); + }) + .finally(() => { + if (this.activeTurnStreamHandle === handle) { + this.activeTurnStreamHandle = null; + } }); } @@ -5082,50 +5122,64 @@ export class AgentSession { normalizeDelegatedToolNames(options?.delegatedToolNames) ?? extractAcpDelegatedTools(optionsMuxMetadata); - const streamResult = await this.aiService.streamMessage({ - messages: requestMessages, - workspaceId: this.workspaceId, - modelString, - abortSignal, - thinkingLevel: effectiveThinkingLevel, - // Orthogonal to thinking level; buildRequestHeaders gates it per model. - reasoningMode: options?.reasoningMode, - toolPolicy: options?.toolPolicy, - additionalSystemContext: options?.additionalSystemContext, - additionalSystemInstructions: options?.additionalSystemInstructions, - maxOutputTokens: options?.maxOutputTokens, - muxProviderOptions: options?.providerOptions, - agentInitiated, - agentId: options?.agentId, - acpPromptId, - delegatedToolNames, - muxMetadata: streamMuxMetadata, - recordFileState, - postCompactionAttachments, - // Invoked by AIService after runtime.ensureReady() (project-scope - // listing needs a running runtime). Still ordered after the - // post-compaction check above: a just-consumed compaction boundary has - // already reset the segment cache, so this stream recomputes the context. - resolveMemoryContext: (forModelString, memoryOptions) => - this.resolveMemoryContext(forModelString, memoryOptions), - allowAgentSetGoal: options?.allowAgentSetGoal === true, - workspaceGoalService: this.workspaceGoalService, - experiments: options?.experiments, - disableWorkspaceAgents: options?.disableWorkspaceAgents, - strictAgentResolution: options?.strictAgentResolution, - hasQueuedMessages: this.hasQueuedMessages.bind(this), - openaiTruncationModeOverride, - // Mid-turn thinking overrides clamp against the same floor as the - // send-time level above (single source of truth for the floor). - minThinkingLevel, - activeTurnThinkingOverride, - }); + // Capture pre-start error events emitted during this call (see + // preStartErrorCapture); the window closes before the result is handled. + this.preStartErrorCapture = []; + let capturedPreStartErrors: StreamErrorPayload[] | null = null; + let streamResult: Awaited>; + try { + streamResult = await this.aiService.streamMessage({ + messages: requestMessages, + workspaceId: this.workspaceId, + modelString, + abortSignal, + thinkingLevel: effectiveThinkingLevel, + // Orthogonal to thinking level; buildRequestHeaders gates it per model. + reasoningMode: options?.reasoningMode, + toolPolicy: options?.toolPolicy, + additionalSystemContext: options?.additionalSystemContext, + additionalSystemInstructions: options?.additionalSystemInstructions, + maxOutputTokens: options?.maxOutputTokens, + muxProviderOptions: options?.providerOptions, + agentInitiated, + agentId: options?.agentId, + acpPromptId, + delegatedToolNames, + muxMetadata: streamMuxMetadata, + recordFileState, + postCompactionAttachments, + // Invoked by AIService after runtime.ensureReady() (project-scope + // listing needs a running runtime). Still ordered after the + // post-compaction check above: a just-consumed compaction boundary has + // already reset the segment cache, so this stream recomputes the context. + resolveMemoryContext: (forModelString, memoryOptions) => + this.resolveMemoryContext(forModelString, memoryOptions), + allowAgentSetGoal: options?.allowAgentSetGoal === true, + workspaceGoalService: this.workspaceGoalService, + experiments: options?.experiments, + disableWorkspaceAgents: options?.disableWorkspaceAgents, + strictAgentResolution: options?.strictAgentResolution, + hasQueuedMessages: this.hasQueuedMessages.bind(this), + openaiTruncationModeOverride, + // Mid-turn thinking overrides clamp against the same floor as the + // send-time level above (single source of truth for the floor). + minThinkingLevel, + activeTurnThinkingOverride, + }); + } finally { + capturedPreStartErrors = this.preStartErrorCapture; + this.preStartErrorCapture = null; + } if (!streamResult.success) { - return await this.handleStreamWithHistoryFailure(streamResult.error, acpPromptId); + return await this.handleStreamWithHistoryFailure( + streamResult.error, + acpPromptId, + capturedPreStartErrors + ); } - this.consumeTurnCompletion(streamResult.data.completion); + this.consumeTurnCompletion(streamResult.data); return Ok(undefined); } @@ -6124,6 +6178,13 @@ export class AgentSession { // Begin synchronously at event emission so completion waiters always find // this attempt's decision before they run. this.beginStreamErrorRecoveryDecision(data.messageId); + if ( + this.preStartErrorCapture != null && + this.activeTurnStreamHandle?.messageId !== data.messageId + ) { + const { workspaceId: _workspaceId, ...payload } = data; + this.preStartErrorCapture.push(payload); + } }; this.aiListeners.push({ event: "error", handler: errorHandler }); diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index ef711e1b19b..c1305f8a0e5 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -2160,6 +2160,53 @@ describe("StreamManager - turn completion", () => { abortReason: "user", }); }); + + test("debug-injected stream errors settle a failed completion", async () => { + const streamManager = new StreamManager(historyService); + stubTokenTracker(streamManager); + Reflect.set( + streamManager, + "createStreamResult", + (_request: unknown, abortController: AbortController) => + createStreamResultForTests( + (async function* () { + await new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + yield* []; + })() + ) + ); + await appendPartialAssistantForTests( + "completion-debug-error-workspace", + "completion-debug-error-message", + 1 + ); + const result = await streamManager.startStream({ + workspaceId: "completion-debug-error-workspace", + messages: [{ role: "user", content: "hello" }], + model: createTestLanguageModel(), + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", + runtime, + messageId: "completion-debug-error-message", + providedRuntimeTempDir: "", + }); + expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected stream to start"); + + const triggered = await streamManager.debugTriggerStreamError( + "completion-debug-error-workspace", + "debug injected failure" + ); + expect(triggered).toBe(true); + + const completion = await result.data.completion; + expect(completion.status).toBe("failed"); + if (completion.status !== "failed") throw new Error("Expected failed completion"); + expect(completion.streamError.error).toBe("debug injected failure"); + }); }); describe("StreamManager - stripEncryptedContent", () => { diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 3503d92f245..63669149a01 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -5129,11 +5129,19 @@ export class StreamManager { }; // Write error state to partial.json (same as real error handling) - await this.persistStreamError(typedWorkspaceId, streamInfo, { + const persistedPayload = await this.persistStreamError(typedWorkspaceId, streamInfo, { messageId: streamInfo.messageId, error: errorMessage, errorType: "network", }); + // Debug-injected failures bypass handleStreamFailure, so record the failed + // completion here or cleanup would never settle the turn handle. + streamInfo.terminalCompletion = { + status: "failed", + messageId: streamInfo.messageId, + error: { type: "unknown", raw: persistedPayload.error }, + streamError: persistedPayload, + }; // Wait for the stream processing to complete (cleanup) await streamInfo.processingPromise; From a80d4abf9a9bb89d5252cefa16e2de13f2d99ac0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:33:15 +0000 Subject: [PATCH 25/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20col?= =?UTF-8?q?lapse=20facade=20completion=20plumbing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-simplification pass: delete the facade completion observer (mock playback and simulations settle their handles directly; the session in-flight capture drains synthetic error events on the Ok path with handle-owned events excluded), and drop the unused error field from failed completions. --- src/node/services/agentSession.ts | 51 ++++++---- src/node/services/aiService.ts | 129 ++++++-------------------- src/node/services/streamManager.ts | 3 - src/node/services/streamSimulation.ts | 20 ++-- 4 files changed, 73 insertions(+), 130 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 01f3158867f..55dee94484e 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4850,22 +4850,12 @@ export class AgentSession { acpPromptId?: string, preStartErrors?: StreamErrorPayload[] | null ): Promise> { - // Pre-start failures that AIService also announced as error events (for - // fire-and-forget senders) have no turn handle, so their handling and - // recovery-decision resolution must run here, keyed by each event's own - // messageId. Handling them first keeps this the single owner: the branches - // below only cover failures that produced no error event. - if (preStartErrors != null && preStartErrors.length > 0) { - for (const payload of preStartErrors) { - try { - await this.handleStreamError({ - ...payload, - acpPromptId: payload.acpPromptId ?? acpPromptId, - }); - } finally { - this.resolveStreamErrorRecoveryDecision(payload.messageId, "terminal"); - } - } + // Pre-start and synthetic failures that AIService announced as error + // events (for fire-and-forget senders) have no turn handle, so their + // handling and recovery-decision resolution run here, keyed by each + // event's own messageId. When any were captured they own this failure: + // the branches below only cover failures that produced no error event. + if (await this.handleCapturedStreamErrors(preStartErrors, acpPromptId)) { return { success: false, error, failureHandled: true }; } @@ -4887,6 +4877,27 @@ export class AgentSession { return { success: false, error, failureHandled: true }; } + /** Handle captured handle-less error events exactly once each (see preStartErrorCapture). */ + private async handleCapturedStreamErrors( + captured: StreamErrorPayload[] | null | undefined, + acpPromptId?: string + ): Promise { + if (captured == null || captured.length === 0) { + return false; + } + for (const payload of captured) { + try { + await this.handleStreamError({ + ...payload, + acpPromptId: payload.acpPromptId ?? acpPromptId, + }); + } finally { + this.resolveStreamErrorRecoveryDecision(payload.messageId, "terminal"); + } + } + return true; + } + private consumeTurnCompletion(handle: TurnStreamHandle): void { this.activeTurnStreamHandle = handle; void handle.completion @@ -5180,6 +5191,14 @@ export class AgentSession { } this.consumeTurnCompletion(streamResult.data); + // Mock playback returns Ok after emitting synthetic error events under its + // own message IDs; drain those so the failures are still handled. Events + // owned by the returned handle are excluded: completion delivers them, so + // a stream failing inside the capture window is not handled twice. + await this.handleCapturedStreamErrors( + capturedPreStartErrors?.filter((event) => event.messageId !== streamResult.data.messageId), + acpPromptId + ); return Ok(undefined); } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index e7c7562780e..6458814c509 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -164,12 +164,7 @@ import type { RebuildProviderOptionsForThinkingLevel, } from "@/node/services/thinkingOverride"; -import type { - ErrorEvent, - StreamAbortEvent, - StreamAbortReason, - StreamEndEvent, -} from "@/common/types/stream"; +import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import { computeActiveToolNames, @@ -861,80 +856,6 @@ export class AIService extends EventEmitter { }; } - private observeFacadeTurnCompletion(input: { - workspaceId: string; - messageId: string; - adoptStreamStartMessageId?: boolean; - }): { handle: TurnStreamHandle; cancel: () => void } { - let messageId = input.messageId; - let settled = false; - let resolveCompletion!: (completion: TurnCompletion) => void; - const completion = new Promise((resolve) => { - resolveCompletion = resolve; - }); - - const cleanup = (): void => { - this.off("stream-start", onStreamStart as never); - this.off("stream-end", onStreamEnd as never); - this.off("stream-abort", onStreamAbort as never); - this.off("error", onError as never); - }; - const settle = (outcome: TurnCompletion): void => { - if (settled) return; - settled = true; - cleanup(); - resolveCompletion(outcome); - }; - const matches = (event: { workspaceId: string; messageId: string }): boolean => - event.workspaceId === input.workspaceId && event.messageId === messageId; - const onStreamStart = (event: TurnEngineEvent): void => { - if ( - input.adoptStreamStartMessageId === true && - event.type === "stream-start" && - event.workspaceId === input.workspaceId - ) { - messageId = event.messageId; - } - }; - const onStreamEnd = (event: StreamEndEvent): void => { - if (matches(event)) settle({ status: "completed", messageId }); - }; - const onStreamAbort = (event: StreamAbortEvent): void => { - if (matches(event)) { - settle({ status: "aborted", messageId, abortReason: event.abortReason ?? "system" }); - } - }; - const onError = (event: ErrorEvent): void => { - if (matches(event)) { - settle({ - status: "failed", - messageId, - error: { type: "unknown", raw: event.error }, - streamError: { - messageId, - error: event.error, - errorType: event.errorType ?? "unknown", - acpPromptId: event.acpPromptId, - }, - }); - } - }; - - this.on("stream-start", onStreamStart as never); - this.on("stream-end", onStreamEnd as never); - this.on("stream-abort", onStreamAbort as never); - this.on("error", onError as never); - - const handle: TurnStreamHandle = { - streamToken: this.streamManager.generateStreamToken(), - get messageId() { - return messageId; - }, - completion, - }; - return { handle, cancel: cleanup }; - } - private trackPendingDevToolsRunMetadata( messageId: string, workspaceId: string, @@ -1458,11 +1379,9 @@ export class AIService extends EventEmitter { }) ); } - const observed = this.observeFacadeTurnCompletion({ - workspaceId, - messageId: syntheticMessageId, - adoptStreamStartMessageId: true, - }); + // play() resolves after the scripted playback (including any error + // events, which the session drains from its in-flight capture), so the + // handle can settle immediately. const result = await this.mockAiStreamPlayer.play(messages, workspaceId, { model: modelString, agentId, @@ -1471,10 +1390,14 @@ export class AIService extends EventEmitter { abortSignal: combinedAbortSignal, }); if (!result.success) { - observed.cancel(); return result; } - return Ok(observed.handle); + return Ok( + this.createSettledTurnHandle(syntheticMessageId, { + status: "completed", + messageId: syntheticMessageId, + }) + ); } // DEBUG: Log streamMessage call @@ -3131,10 +3054,6 @@ export class AIService extends EventEmitter { effectiveMuxProviderOptions.openai?.simulateToolPolicyNoop === true; if (forceContextLimitError || simulateToolPolicyNoopFlag) { - const observed = this.observeFacadeTurnCompletion({ - workspaceId, - messageId: assistantMessageId, - }); const simulationCtx: SimulationContext = { workspaceId, assistantMessageId, @@ -3150,17 +3069,25 @@ export class AIService extends EventEmitter { emit: (event, data) => this.emit(event, data), }; - try { - if (forceContextLimitError) { - await simulateContextLimitError(simulationCtx, this.historyService); - } else { - await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); - } - return Ok(observed.handle); - } catch (error) { - observed.cancel(); - throw error; + // Simulations emit their synthetic events before returning, so the + // handle settles immediately with the matching terminal outcome. + if (forceContextLimitError) { + const streamError = await simulateContextLimitError(simulationCtx, this.historyService); + return Ok( + this.createSettledTurnHandle(assistantMessageId, { + status: "failed", + messageId: assistantMessageId, + streamError, + }) + ); } + await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); + return Ok( + this.createSettledTurnHandle(assistantMessageId, { + status: "completed", + messageId: assistantMessageId, + }) + ); } // Build provider options based on thinking level and request-sliced message history. diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 63669149a01..995a60201b0 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -210,7 +210,6 @@ export type TurnCompletion = | { status: "failed"; messageId: string; - error: SendMessageError; streamError: StreamErrorPayload & { errorType: StreamErrorType }; }; @@ -3907,7 +3906,6 @@ export class StreamManager { streamInfo.terminalCompletion = { status: "failed", messageId: streamInfo.messageId, - error: { type: "unknown", raw: persistedPayload.error }, streamError: persistedPayload, }; } @@ -5139,7 +5137,6 @@ export class StreamManager { streamInfo.terminalCompletion = { status: "failed", messageId: streamInfo.messageId, - error: { type: "unknown", raw: persistedPayload.error }, streamError: persistedPayload, }; diff --git a/src/node/services/streamSimulation.ts b/src/node/services/streamSimulation.ts index 34f8971905f..2db6c46e582 100644 --- a/src/node/services/streamSimulation.ts +++ b/src/node/services/streamSimulation.ts @@ -13,9 +13,10 @@ import type { MuxMessage, MuxTextPart } from "@/common/types/message"; import { createMuxMessage } from "@/common/types/message"; import type { ThinkingLevel } from "@/common/types/thinking"; import type { StreamDeltaEvent, StreamEndEvent, StreamStartEvent } from "@/common/types/stream"; +import type { StreamErrorType } from "@/common/types/errors"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { HistoryService } from "./historyService"; -import { createErrorEvent } from "./utils/sendMessageError"; +import { createErrorEvent, type StreamErrorPayload } from "./utils/sendMessageError"; // --------------------------------------------------------------------------- // Shared context for both simulation paths @@ -68,7 +69,7 @@ function createSimulatedStreamStart(ctx: SimulationContext): StreamStartEvent { export async function simulateContextLimitError( ctx: SimulationContext, historyService: HistoryService -): Promise { +): Promise { const errorMessage = "Context length exceeded: the conversation is too long to send to this OpenAI model. Please shorten the history and try again."; @@ -94,15 +95,14 @@ export async function simulateContextLimitError( await historyService.writePartial(ctx.workspaceId, errorPartialMessage); + const payload = { + messageId: ctx.assistantMessageId, + error: errorMessage, + errorType: "context_exceeded", + } as const; ctx.emit("stream-start", createSimulatedStreamStart(ctx)); - ctx.emit( - "error", - createErrorEvent(ctx.workspaceId, { - messageId: ctx.assistantMessageId, - error: errorMessage, - errorType: "context_exceeded", - }) - ); + ctx.emit("error", createErrorEvent(ctx.workspaceId, payload)); + return payload; } // --------------------------------------------------------------------------- From da9d7ab6c975e72544e119967ae95697938d3407 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 11:39:45 +0000 Subject: [PATCH 26/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20collap?= =?UTF-8?q?se=20repeated=20startStream=20option=20literals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/streamManager.test.ts | 167 +++++++++++------------- 1 file changed, 79 insertions(+), 88 deletions(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index c1305f8a0e5..d5358c8b4cd 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -20,6 +20,7 @@ import { type ModelFallbackPrepareOptions, type TurnEngineEvent, type TurnEngineEventSink, + type TurnExecutionOptions, } from "./streamManager"; import type { ActiveTurnThinkingOverride, @@ -132,6 +133,21 @@ const TEST_STREAM_MODEL_ID = KNOWN_MODELS.SONNET.id; const TEST_USAGE = { inputTokens: 1, outputTokens: 1, totalTokens: 2 }; const LOCAL_TEST_RUNTIME = createRuntime({ type: "local", srcBaseDir: "/tmp" }); +/** Base startStream options; scenarios override only what they assert. */ +function testStartOptions( + overrides: Partial & + Pick +): TurnExecutionOptions { + return { + messages: [{ role: "user", content: "hello" }], + modelString: "openai:gpt-4.1-mini", + historySequence: 1, + system: "system", + runtime: LOCAL_TEST_RUNTIME, + ...overrides, + }; +} + type ProcessStreamWithCleanupForTests = ( workspaceId: string, streamInfo: unknown, @@ -1896,17 +1912,14 @@ describe("StreamManager - language model cleanup", () => { const abortController = new AbortController(); abortController.abort(new Error("pre-abort")); - const result = await streamManager.startStream({ - workspaceId: "cleanup-preabort-workspace", - messages: [{ role: "user", content: "hello" }], - model, - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "cleanup-preabort-message", - abortSignal: abortController.signal, - }); + const result = await streamManager.startStream( + testStartOptions({ + workspaceId: "cleanup-preabort-workspace", + messageId: "cleanup-preabort-message", + model, + abortSignal: abortController.signal, + }) + ); expect(result.success).toBe(true); expect(getCleanupCalls()).toBe(1); @@ -1931,17 +1944,14 @@ describe("StreamManager - language model cleanup", () => { streams.set(workspaceId, replacementSentinel); }; - const result = await streamManager.startStream({ - workspaceId, - messages: [{ role: "user", content: "hello" }], - model, - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "constructed-abort-message", - onStreamConstructed, - }); + const result = await streamManager.startStream( + testStartOptions({ + workspaceId, + messageId: "constructed-abort-message", + model, + onStreamConstructed, + }) + ); expect(result.success).toBe(true); // The canceled stream must never start processing: stream-start after the @@ -1963,16 +1973,13 @@ describe("StreamManager - language model cleanup", () => { }); expect(replaceCreateStreamResult).toBe(true); - const result = await streamManager.startStream({ - workspaceId: "cleanup-create-throw-workspace", - messages: [{ role: "user", content: "hello" }], - model, - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "cleanup-create-throw-message", - }); + const result = await streamManager.startStream( + testStartOptions({ + workspaceId: "cleanup-create-throw-workspace", + messageId: "cleanup-create-throw-message", + model, + }) + ); expect(result.success).toBe(false); expect(getCleanupCalls()).toBe(1); @@ -1980,8 +1987,6 @@ describe("StreamManager - language model cleanup", () => { }); describe("StreamManager - turn completion", () => { - const runtime = LOCAL_TEST_RUNTIME; - function stubTokenTracker(streamManager: StreamManager): void { Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(), @@ -2004,17 +2009,14 @@ describe("StreamManager - turn completion", () => { ); await appendPartialAssistantForTests(input.workspaceId, input.messageId, 1); - const result = await streamManager.startStream({ - workspaceId: input.workspaceId, - messages: [{ role: "user", content: "hello" }], - model: createTestLanguageModel(), - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: input.messageId, - providedRuntimeTempDir: "", - }); + const result = await streamManager.startStream( + testStartOptions({ + workspaceId: input.workspaceId, + messageId: input.messageId, + model: createTestLanguageModel(), + providedRuntimeTempDir: "", + }) + ); expect(result.success).toBe(true); if (!result.success) throw new Error("Expected stream to start"); return { streamManager, handle: result.data }; @@ -2023,31 +2025,26 @@ describe("StreamManager - turn completion", () => { test("pre-start failures return Err while successful startup owns an aborted completion", async () => { const streamManager = new StreamManager(historyService); const model = createTestLanguageModel(); - const failed = await streamManager.startStream({ - workspaceId: "completion-prestart-failure", - messages: [], - model, - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "prestart-failure-message", - }); + const failed = await streamManager.startStream( + testStartOptions({ + workspaceId: "completion-prestart-failure", + messageId: "prestart-failure-message", + model, + messages: [], + }) + ); expect(failed.success).toBe(false); const abortController = new AbortController(); abortController.abort(); - const aborted = await streamManager.startStream({ - workspaceId: "completion-prestart-abort", - messages: [{ role: "user", content: "hello" }], - model, - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "prestart-abort-message", - abortSignal: abortController.signal, - }); + const aborted = await streamManager.startStream( + testStartOptions({ + workspaceId: "completion-prestart-abort", + messageId: "prestart-abort-message", + model, + abortSignal: abortController.signal, + }) + ); expect(aborted.success).toBe(true); if (!aborted.success) throw new Error("Expected aborted startup handle"); expect(await aborted.data.completion).toEqual({ @@ -2131,17 +2128,14 @@ describe("StreamManager - turn completion", () => { "completion-abort-message", 1 ); - const result = await streamManager.startStream({ - workspaceId: "completion-abort-workspace", - messages: [{ role: "user", content: "hello" }], - model: createTestLanguageModel(), - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "completion-abort-message", - providedRuntimeTempDir: "", - }); + const result = await streamManager.startStream( + testStartOptions({ + workspaceId: "completion-abort-workspace", + messageId: "completion-abort-message", + model: createTestLanguageModel(), + providedRuntimeTempDir: "", + }) + ); expect(result.success).toBe(true); if (!result.success) throw new Error("Expected stream to start"); @@ -2182,17 +2176,14 @@ describe("StreamManager - turn completion", () => { "completion-debug-error-message", 1 ); - const result = await streamManager.startStream({ - workspaceId: "completion-debug-error-workspace", - messages: [{ role: "user", content: "hello" }], - model: createTestLanguageModel(), - modelString: "openai:gpt-4.1-mini", - historySequence: 1, - system: "system", - runtime, - messageId: "completion-debug-error-message", - providedRuntimeTempDir: "", - }); + const result = await streamManager.startStream( + testStartOptions({ + workspaceId: "completion-debug-error-workspace", + messageId: "completion-debug-error-message", + model: createTestLanguageModel(), + providedRuntimeTempDir: "", + }) + ); expect(result.success).toBe(true); if (!result.success) throw new Error("Expected stream to start"); From 49a9a05ca875eaf54462ddd05f6d8a63cd035ab0 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:14:06 +0000 Subject: [PATCH 27/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20share?= =?UTF-8?q?=20turn-handle=20helpers=20via=20test=20harness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.autoCompaction.test.ts | 11 +------ .../services/agentSession.budgetGate.test.ts | 10 +----- .../agentSession.editMessageId.test.ts | 9 +----- ...gentSession.fileChangeNotification.test.ts | 10 +----- .../agentSession.goalAutoPause.test.ts | 29 ++++------------- .../agentSession.postCompactionRetry.test.ts | 31 ++++--------------- .../agentSession.preTurnMessages.test.ts | 10 +----- .../agentSession.startupAutoRetry.test.ts | 11 +------ src/node/services/agentSession.testHarness.ts | 31 +++++++++++++++---- 9 files changed, 43 insertions(+), 109 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index fd546f0ad63..0e08d219e4d 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -15,22 +15,13 @@ import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; import { Ok, Err } from "@/common/types/result"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; -import type { TurnStreamHandle } from "./streamManager"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { InitStateManager } from "@/node/services/initStateManager"; import { AgentSession } from "./agentSession"; import type { CompactionMonitor } from "./compactionMonitor"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import { createTestHistoryService } from "./testHistoryService"; -function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId, - completion: new Promise(() => undefined), - }; -} - describe("AgentSession on-send auto-compaction snapshot deferral", () => { let historyCleanup: (() => Promise) | undefined; diff --git a/src/node/services/agentSession.budgetGate.test.ts b/src/node/services/agentSession.budgetGate.test.ts index d06a1be49ed..000f2b18680 100644 --- a/src/node/services/agentSession.budgetGate.test.ts +++ b/src/node/services/agentSession.budgetGate.test.ts @@ -7,6 +7,7 @@ import type { HistoryService } from "./historyService"; import type { InitStateManager } from "./initStateManager"; import { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; +import { createStartedTurnHandle } from "./agentSession.testHarness"; import { WorkspaceGoalService } from "./workspaceGoalService"; // Registers a no-op goal-continuation consumer so the in-AS pricing gate // path runs end-to-end (DEREM-52). Bridge registration alone is now @@ -16,7 +17,6 @@ import { registerNoopContinuationBridgeForTest } from "./testDispatchHelpers"; import { Ok } from "@/common/types/result"; import type { SendMessageOptions } from "@/common/orpc/types"; import type { GoalRecordV1 } from "@/common/types/goal"; -import type { TurnStreamHandle } from "./streamManager"; const PROJECT_PATH = "/tmp/mux-agent-session-budget-gate-test-project"; const PRICED_OPTIONS: SendMessageOptions = { model: "openai:gpt-4o-mini", agentId: "exec" }; @@ -25,14 +25,6 @@ const UNPRICED_OPTIONS: SendMessageOptions = { agentId: "exec", }; -function createStartedTurnHandle(): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId: "test-assistant", - completion: new Promise(() => undefined), - }; -} - interface SessionHarness { historyService: HistoryService; session: AgentSession; diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index 3f7796c4bc4..09644eb2813 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -7,20 +7,13 @@ import type { Config } from "@/node/config"; import { createMuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; -import type { TurnStreamHandle } from "./streamManager"; import { createTestHistoryService } from "./testHistoryService"; +import { createStartedTurnHandle } from "./agentSession.testHarness"; type StreamMessageHandler = AIService["streamMessage"]; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; -function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId, - completion: new Promise(() => undefined), - }; -} const config = { srcDir: "/tmp", getSessionDir: (_workspaceId: string) => "/tmp", diff --git a/src/node/services/agentSession.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index a2086315ce0..eee36a733dd 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -12,15 +12,7 @@ import type { AIService, StreamMessageOptions } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { InitStateManager } from "./initStateManager"; import { createTestHistoryService } from "./testHistoryService"; -import type { TurnStreamHandle } from "./streamManager"; - -function createStartedTurnHandle(): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId: "test-assistant", - completion: new Promise(() => undefined), - }; -} +import { createStartedTurnHandle } from "./agentSession.testHarness"; /** * Log purity: externally-edited files must produce a durable diff --git a/src/node/services/agentSession.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index b3cafd51d9f..8fcd623a236 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import type { AIService } from "./aiService"; -import type { TurnStreamHandle } from "./streamManager"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import { ExtensionMetadataService } from "./ExtensionMetadataService"; import type { HistoryService } from "./historyService"; @@ -20,27 +19,7 @@ import { } from "@/constants/goals"; import { waitForCondition } from "./testDispatchHelpers"; import { IdleDispatcher } from "./idleDispatcher"; - -function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId, - completion: new Promise(() => undefined), - }; -} - -function createFailedTurnHandle(messageId: string, error: string): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId, - completion: Promise.resolve({ - status: "failed", - messageId, - error: { type: "unknown", raw: error }, - streamError: { messageId, error, errorType: "unknown" }, - }), - }; -} +import { createFailedTurnHandle, createStartedTurnHandle } from "./agentSession.testHarness"; const PROJECT_PATH = "/tmp/mux-agent-session-goal-test-project"; const SEND_OPTIONS: SendMessageOptions = { model: "openai:gpt-4o", agentId: "exec" }; @@ -1023,7 +1002,11 @@ describe("AgentSession goal safety hooks", () => { error: "boom", errorType: "unknown", }); - return Promise.resolve(Ok(createFailedTurnHandle("assistant-stream-error", "boom"))); + return Promise.resolve( + Ok( + createFailedTurnHandle("assistant-stream-error", { error: "boom", errorType: "unknown" }) + ) + ); }) as unknown as AIService["streamMessage"]; const eventTypes: string[] = []; session.onChatEvent((event) => { diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index e255eb14bac..c1f2e807a3e 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -7,32 +7,13 @@ import * as path from "path"; import { AgentSession } from "./agentSession"; import type { Config } from "@/node/config"; import type { AIService } from "./aiService"; -import type { TurnStreamHandle } from "./streamManager"; import type { InitStateManager } from "./initStateManager"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; import { createTestHistoryService } from "./testHistoryService"; - -function createTurnHandle( - messageId: string, - failure?: { error: string; errorType: "context_exceeded" | "model_refusal" } -): TurnStreamHandle { - return { - streamToken: "token-" + messageId, - messageId, - completion: - failure == null - ? new Promise(() => undefined) - : Promise.resolve({ - status: "failed" as const, - messageId, - error: { type: "unknown" as const, raw: failure.error }, - streamError: { messageId, ...failure }, - }), - }; -} +import { createFailedTurnHandle, createStartedTurnHandle } from "./agentSession.testHarness"; function createPersistedPostCompactionState(options: { filePath: string; @@ -113,7 +94,7 @@ describe("AgentSession post-compaction context retry", () => { return Promise.resolve({ success: true as const, - data: createTurnHandle("assistant-ctx-exceeded", { + data: createFailedTurnHandle("assistant-ctx-exceeded", { error: "Context length exceeded", errorType: "context_exceeded", }), @@ -123,7 +104,7 @@ describe("AgentSession post-compaction context retry", () => { resolveSecondCall?.(); return Promise.resolve({ success: true as const, - data: createTurnHandle("assistant-retry"), + data: createStartedTurnHandle("assistant-retry"), }); }); @@ -268,7 +249,7 @@ describe("AgentSession post-compaction context retry", () => { }); return { success: true as const, - data: createTurnHandle("assistant-ctx-exceeded", { + data: createFailedTurnHandle("assistant-ctx-exceeded", { error: "Context length exceeded", errorType: "context_exceeded", }), @@ -409,7 +390,7 @@ describe("AgentSession post-compaction context retry", () => { }); return Promise.resolve({ success: true as const, - data: createTurnHandle("assistant-attempt-1", { + data: createFailedTurnHandle("assistant-attempt-1", { error: "Context length exceeded", errorType: "context_exceeded", }), @@ -425,7 +406,7 @@ describe("AgentSession post-compaction context retry", () => { }); return Promise.resolve({ success: true as const, - data: createTurnHandle("assistant-attempt-2", { + data: createFailedTurnHandle("assistant-attempt-2", { error: "The model refused to continue", errorType: "model_refusal", }), diff --git a/src/node/services/agentSession.preTurnMessages.test.ts b/src/node/services/agentSession.preTurnMessages.test.ts index 158693186df..b57038de012 100644 --- a/src/node/services/agentSession.preTurnMessages.test.ts +++ b/src/node/services/agentSession.preTurnMessages.test.ts @@ -8,15 +8,7 @@ import { createMuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; -import type { TurnStreamHandle } from "./streamManager"; - -function createStartedTurnHandle(): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId: "test-assistant", - completion: new Promise(() => undefined), - }; -} +import { createStartedTurnHandle } from "./agentSession.testHarness"; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index af3f61113d6..b28be2166b8 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1,12 +1,11 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; import { AgentSession, clearProviderConfigFixableAbandonMarkers } from "./agentSession"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import { createTestHistoryService } from "./testHistoryService"; import type { AIService } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { HistoryService } from "./historyService"; -import type { TurnStreamHandle } from "./streamManager"; import type { Config } from "@/node/config"; import type { InitStateManager } from "./initStateManager"; import type { WorkspaceChatMessage, SendMessageOptions } from "@/common/orpc/types"; @@ -18,14 +17,6 @@ import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; import { formatSubagentReportEnvelope } from "@/common/utils/subagentReportEnvelope"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId, - completion: new Promise(() => undefined), - }; -} - interface AutoRetryResumeRequest { options: SendMessageOptions; agentInitiated?: boolean; diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 1bd2275b6b8..5b19c9a8962 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -15,6 +15,30 @@ import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; import { createTestHistoryService } from "@/node/services/testHistoryService"; +import type { StreamErrorType } from "@/common/types/errors"; + +export function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: new Promise(() => undefined), + }; +} + +export function createFailedTurnHandle( + messageId: string, + failure: { error: string; errorType: StreamErrorType } +): TurnStreamHandle { + return { + streamToken: "test-stream-token", + messageId, + completion: Promise.resolve({ + status: "failed", + messageId, + streamError: { messageId, ...failure }, + }), + }; +} function createAgentSessionTestConfig(sessionDir = "/tmp"): Config { return { @@ -56,12 +80,7 @@ function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partia return result; } - const handle: TurnStreamHandle = { - streamToken: "test-stream-token", - messageId: "test-assistant-message", - completion: new Promise(() => undefined), - }; - return Ok(handle); + return Ok(createStartedTurnHandle("test-assistant-message")); } ); From 9e9cf3022387caa72368dbce1f383e7d056931e7 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:17:38 +0000 Subject: [PATCH 28/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20drop?= =?UTF-8?q?=20EventEmitter-era=20scaffolding,=20share=20sink=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...reamManager.modelOnlyNotifications.test.ts | 24 +----------- src/node/services/streamManager.test.ts | 37 +------------------ .../services/streamManager.testHarness.ts | 24 ++++++++++++ 3 files changed, 27 insertions(+), 58 deletions(-) create mode 100644 src/node/services/streamManager.testHarness.ts diff --git a/src/node/services/streamManager.modelOnlyNotifications.test.ts b/src/node/services/streamManager.modelOnlyNotifications.test.ts index 91e876d93cc..93369e4db45 100644 --- a/src/node/services/streamManager.modelOnlyNotifications.test.ts +++ b/src/node/services/streamManager.modelOnlyNotifications.test.ts @@ -1,31 +1,11 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; -import { StreamManager, type TurnEngineEvent, type TurnEngineEventSink } from "./streamManager"; +import { StreamManager } from "./streamManager"; +import { onTurnEngineEvent } from "./streamManager.testHarness"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; -type TurnEngineEventOfType = Extract< - TurnEngineEvent, - { type: T } ->; - -function onTurnEngineEvent( - streamManager: StreamManager, - type: T, - listener: (event: TurnEngineEventOfType) => void -): void { - const internals = streamManager as unknown as { eventSink: TurnEngineEventSink }; - const previous = internals.eventSink; - internals.eventSink = (event) => { - const result = previous(event); - if (event.type === type) { - listener(event as TurnEngineEventOfType); - } - return result; - }; -} - describe("StreamManager - model-only tool notifications", () => { let historyService: HistoryService; let historyCleanup: () => Promise; diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index d5358c8b4cd..f8d87b57b0f 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -19,7 +19,6 @@ import { StreamManager, type ModelFallbackPrepareOptions, type TurnEngineEvent, - type TurnEngineEventSink, type TurnExecutionOptions, } from "./streamManager"; import type { @@ -51,27 +50,7 @@ import type { ExecOptions, ExecStream, Runtime } from "@/node/runtime/Runtime"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { attachLanguageModelCleanup } from "./languageModelCleanup"; import { shellQuote } from "@/common/utils/shell"; - -type TurnEngineEventOfType = Extract< - TurnEngineEvent, - { type: T } ->; - -function onTurnEngineEvent( - streamManager: StreamManager, - type: T, - listener: (event: TurnEngineEventOfType) => void -): void { - const internals = streamManager as unknown as { eventSink: TurnEngineEventSink }; - const previous = internals.eventSink; - internals.eventSink = (event) => { - const result = previous(event); - if (event.type === type) { - listener(event as TurnEngineEventOfType); - } - return result; - }; -} +import { onTurnEngineEvent } from "./streamManager.testHarness"; function createTestLanguageModel(modelId = "cleanup-model"): LanguageModel { return { @@ -1260,7 +1239,6 @@ describe("StreamManager - OpenAI GPT-5.6 cached system instructions", () => { undefined, () => eligibleProvidersConfig ); - onTurnEngineEvent(streamManager, "error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), countTokens: () => Promise.resolve(0), @@ -1776,7 +1754,6 @@ describe("StreamManager - language model cleanup", () => { streamInfoOverrides?: Record; }): Promise { const streamManager = new StreamManager(historyService); - onTurnEngineEvent(streamManager, "error", () => undefined); const historySequence = 1; await appendPartialAssistantForTests(params.workspaceId, params.messageId, historySequence); @@ -1927,7 +1904,6 @@ describe("StreamManager - language model cleanup", () => { test("interrupt during onStreamConstructed skips processing and preserves a replacement registration", async () => { const streamManager = new StreamManager(historyService); - onTurnEngineEvent(streamManager, "error", () => undefined); const { model, getCleanupCalls } = createCleanupModel("constructed-abort-model"); const startEvents: unknown[] = []; onTurnEngineEvent(streamManager, "stream-start", (event) => startEvents.push(event)); @@ -2273,7 +2249,6 @@ describe("StreamManager - Concurrent Stream Prevention", () => { beforeEach(() => { streamManager = new StreamManager(historyService); // Suppress error events from bubbling up as uncaught exceptions during tests - onTurnEngineEvent(streamManager, "error", () => undefined); }); // Integration test - requires API key and TEST_INTEGRATION=1 @@ -3002,7 +2977,6 @@ describe("StreamManager - empty stream completions", () => { test("zero-output refusal finishReason survives commit when usage is unavailable", async () => { const streamManager = new StreamManager(historyService); - onTurnEngineEvent(streamManager, "error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -3074,7 +3048,6 @@ describe("StreamManager - empty stream completions", () => { recordHeadlessUsage, } as unknown as SessionUsageService; const streamManager = new StreamManager(historyService, sessionUsageService); - onTurnEngineEvent(streamManager, "error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -4368,7 +4341,6 @@ describe("StreamManager - TTFT metadata persistence", () => { }) { const streamManager = params.streamManager ?? new StreamManager(historyService); // Suppress error events from bubbling up as uncaught exceptions during tests - onTurnEngineEvent(streamManager, "error", () => undefined); if (params.onStreamStart) { onTurnEngineEvent(streamManager, "stream-start", params.onStreamStart); @@ -5088,7 +5060,6 @@ describe("StreamManager - replayStream", () => { function createReplayStreamManager(): StreamManager { const streamManager = new StreamManager(historyService); // Suppress error events from bubbling up as uncaught exceptions during tests. - onTurnEngineEvent(streamManager, "error", () => undefined); return streamManager; } @@ -5559,7 +5530,6 @@ describe("StreamManager - aborted stream usage persistence", () => { test("stamps cumulative usage on the partial so committed history rows stay billable", async () => { const streamManager = new StreamManager(historyService); - onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-usage-workspace"; const messageId = "abort-usage-message"; await appendPartialAssistantForTests(workspaceId, messageId, 1); @@ -5595,7 +5565,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-tool-only-workspace"; const usage = { inputTokens: 500, outputTokens: 0, totalTokens: 500 }; @@ -5654,7 +5623,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-commit-worthy-workspace"; await appendPartialAssistantForTests(workspaceId, "abort-commit-worthy-message", 1); @@ -5687,7 +5655,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - onTurnEngineEvent(streamManager, "error", () => undefined); const workspaceId = "error-nondurable-workspace"; const usage = { inputTokens: 900, outputTokens: 0, totalTokens: 900 }; @@ -5734,7 +5701,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - onTurnEngineEvent(streamManager, "error", () => undefined); const workspaceId = "error-commit-worthy-workspace"; const usage = { inputTokens: 900, outputTokens: 40, totalTokens: 940 }; @@ -5784,7 +5750,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - onTurnEngineEvent(streamManager, "stream-abort", () => undefined); const workspaceId = "abort-abandon-workspace"; const messageId = "abort-abandon-message"; diff --git a/src/node/services/streamManager.testHarness.ts b/src/node/services/streamManager.testHarness.ts new file mode 100644 index 00000000000..d585b6d40e5 --- /dev/null +++ b/src/node/services/streamManager.testHarness.ts @@ -0,0 +1,24 @@ +import type { StreamManager, TurnEngineEvent, TurnEngineEventSink } from "./streamManager"; + +type TurnEngineEventOfType = Extract< + TurnEngineEvent, + { type: T } +>; + +// Chains a listener onto StreamManager's private event sink so tests can +// observe engine events without wiring an AIService. +export function onTurnEngineEvent( + streamManager: StreamManager, + type: T, + listener: (event: TurnEngineEventOfType) => void +): void { + const internals = streamManager as unknown as { eventSink: TurnEngineEventSink }; + const previous = internals.eventSink; + internals.eventSink = (event) => { + const result = previous(event); + if (event.type === type) { + listener(event as TurnEngineEventOfType); + } + return result; + }; +} From c85c5f9434460a7c4a4fca1bc693cc5245f6b513 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:22:52 +0000 Subject: [PATCH 29/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20pas?= =?UTF-8?q?s=20TurnExecutionOptions=20through=20stream=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/streamManager.test.ts | 30 ++--- src/node/services/streamManager.ts | 161 ++++++++---------------- 2 files changed, 57 insertions(+), 134 deletions(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index f8d87b57b0f..92c3d4f8fc2 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -2403,22 +2403,8 @@ describe("StreamManager - Concurrent Stream Prevention", () => { streamManager, "createStreamAtomically", ( - wsId: string, - streamToken: string, - _runtimeTempDir: string, - _runtime: unknown, - _messages: unknown, - _modelArg: unknown, - modelString: string, - abortController: AbortController, - _system: string, - historySequence: number, - _messageId: string, - _tools?: Record, - initialMetadata?: Record, - _providerOptions?: Record, - _maxOutputTokens?: number, - _toolPolicy?: unknown + options: TurnExecutionOptions, + ctx: { streamToken: string; abortController: AbortController } ): WorkspaceStreamInfoStub => { operations.push("create"); @@ -2431,13 +2417,13 @@ describe("StreamManager - Concurrent Stream Prevention", () => { usage: Promise.resolve(undefined), providerMetadata: Promise.resolve(undefined), }, - abortController, + abortController: ctx.abortController, messageId: `test-${Math.random().toString(36).slice(2)}`, - token: streamToken, + token: ctx.streamToken, startTime: Date.now(), - model: modelString, - initialMetadata, - historySequence, + model: options.modelString, + initialMetadata: options.initialMetadata, + historySequence: options.historySequence, parts: [], lastPartialWriteTime: 0, partialWriteTimer: undefined, @@ -2445,7 +2431,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => { processingPromise: Promise.resolve(), }; - workspaceStreams.set(wsId, streamInfo); + workspaceStreams.set(options.workspaceId, streamInfo); return streamInfo; } ); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 995a60201b0..52d23b086d0 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -2156,75 +2156,61 @@ export class StreamManager { * Atomically creates a new stream with all necessary setup */ private createStreamAtomically( - workspaceId: WorkspaceId, - streamToken: StreamToken, - runtimeTempDir: string, - runtime: Runtime, - messages: ModelMessage[], - model: LanguageModel, - modelString: string, - abortController: AbortController, - system: string, - historySequence: number, - messageId: string, - tools?: Record, - initialMetadata?: Partial, - providerOptions?: Record, - maxOutputTokens?: number, - toolPolicy?: ToolPolicy, - callSettingsOverrides?: ResolvedCallSettingsOverrides, - hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean, - workspaceName?: string, - thinkingLevel?: string, - headers?: Record, - anthropicCacheTtlOverride?: AnthropicCacheTtl, - onChunk?: StreamTextOnChunk, - onStepMessages?: (messages: ModelMessage[]) => void, - modelFallback?: ModelFallbackOptions, - toolSearchState?: ToolSearchStreamState, - thinkingOverrideState?: ActiveTurnThinkingOverride, - rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames?: string[], - providersConfigSnapshot?: ProvidersConfigMap, - rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel, - completionController?: TurnCompletionController + options: TurnExecutionOptions, + ctx: { + streamToken: StreamToken; + runtimeTempDir: string; + abortController: AbortController; + completionController: TurnCompletionController; + } ): WorkspaceStreamInfo { - // abortController is created and linked to the caller-provided abortSignal in startStream(). - + // ctx.abortController is created and linked to the caller-provided abortSignal in startStream(). + const workspaceId = options.workspaceId as WorkspaceId; + const { + messageId, + modelString, + historySequence, + workspaceName, + thinkingLevel, + initialMetadata, + modelFallback, + maxOutputTokens, + runtime, + } = options; const stepTracker: StepMessageTracker = {}; - const metadataModel = this.resolveMetadataModel(modelString, providersConfigSnapshot); + const metadataModel = this.resolveMetadataModel(modelString, options.providersConfigSnapshot); const request = this.buildStreamRequestConfig( - model, + options.model, modelString, - messages, - system, + options.messages, + options.system, initialMetadata?.routeProvider, - tools, - providerOptions, + options.tools, + options.providerOptions, maxOutputTokens, - callSettingsOverrides, - toolPolicy, - hasQueuedMessages, - headers, - anthropicCacheTtlOverride, - onChunk, - onStepMessages, - toolSearchState, + options.callSettingsOverrides, + options.toolPolicy, + options.hasQueuedMessages, + options.headers, + options.anthropicCacheTtlOverride, + options.onChunk, + options.onStepMessages, + options.toolSearchState, (toolCallId) => this.handleToolExecutionStart(workspaceId, messageId, toolCallId), - thinkingOverrideState, - rebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames, - providersConfigSnapshot, - rebuildFirstStepForThinkingLevel + options.thinkingOverrideState, + options.rebuildProviderOptionsForThinkingLevel, + options.forcedFirstStepToolNames, + options.providersConfigSnapshot, + options.rebuildFirstStepForThinkingLevel ); // Start streaming - this can throw immediately if API key is missing let streamResult; try { - streamResult = this.createStreamResult(request, abortController, stepTracker); + streamResult = this.createStreamResult(request, ctx.abortController, stepTracker); } catch (error) { // Clean up abort controller if stream creation fails - abortController.abort(); + ctx.abortController.abort(); // Re-throw the error to be caught by startStream throw error; } @@ -2234,9 +2220,9 @@ export class StreamManager { state: StreamState.STARTING, streamResult, workspaceName, - abortController, + abortController: ctx.abortController, messageId, - token: streamToken, + token: ctx.streamToken, startTime, lastPartTimestamp: startTime, toolCompletionTimestamps: new Map(), @@ -2271,8 +2257,8 @@ export class StreamManager { partialWritePromise: undefined, // No write in flight initially processingPromise: Promise.resolve(), // Placeholder, overwritten in startStream softInterrupt: { pending: false }, - completionController: completionController ?? createTurnCompletionController(), - runtimeTempDir, // Stream-scoped temp directory for tool outputs + completionController: ctx.completionController, + runtimeTempDir: ctx.runtimeTempDir, // Stream-scoped temp directory for tool outputs runtime, // Runtime for temp directory cleanup // Initialize cumulative tracking for multi-step streams cumulativeUsage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, @@ -4532,33 +4518,12 @@ export class StreamManager { model, modelString, historySequence, - system, runtime, messageId, abortSignal, - tools, - initialMetadata, - providerOptions, - maxOutputTokens, - toolPolicy, providedStreamToken, - hasQueuedMessages, - workspaceName, - thinkingLevel, - headers, - anthropicCacheTtlOverride, - callSettingsOverrides, - onChunk, - onStepMessages, providedRuntimeTempDir, - modelFallback, - toolSearchState, - thinkingOverrideState, - rebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames, - providersConfigSnapshot, onStreamConstructed, - rebuildFirstStepForThinkingLevel, } = options; const completionController = createTurnCompletionController(); const createHandle = (streamToken: StreamToken): TurnStreamHandle => ({ @@ -4625,40 +4590,12 @@ export class StreamManager { } // Step 4: Atomic stream creation and registration - const streamInfo = this.createStreamAtomically( - typedWorkspaceId, + const streamInfo = this.createStreamAtomically(options, { streamToken, runtimeTempDir, - runtime, - messages, - model, - modelString, - streamAbortController, - system, - historySequence, - messageId, - tools, - initialMetadata, - providerOptions, - maxOutputTokens, - toolPolicy, - callSettingsOverrides, - hasQueuedMessages, - workspaceName, - thinkingLevel, - headers, - anthropicCacheTtlOverride, - onChunk, - onStepMessages, - modelFallback, - toolSearchState, - thinkingOverrideState, - rebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames, - providersConfigSnapshot, - rebuildFirstStepForThinkingLevel, - completionController - ); + abortController: streamAbortController, + completionController, + }); // Guard against a narrow race: // - stopStream() may abort while we're between the last aborted-check and stream registration. From 71c5c683cc04e62a1a2e74289e4f6dbd3cf9b051 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:08:55 +0000 Subject: [PATCH 30/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20dro?= =?UTF-8?q?p=20dead=20handle=20token,=20latch-era=20tests,=20mock=20shim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.preStreamError.test.ts | 86 ------------------- src/node/services/agentSession.testHarness.ts | 29 ++----- .../agentSession.thinkingOverride.test.ts | 10 ++- ...ntSession.workspaceTurnInheritance.test.ts | 12 ++- src/node/services/aiService.test.ts | 24 ------ src/node/services/aiService.ts | 56 +++--------- src/node/services/streamManager.test.ts | 23 ----- src/node/services/streamManager.ts | 17 ++-- 8 files changed, 38 insertions(+), 219 deletions(-) diff --git a/src/node/services/agentSession.preStreamError.test.ts b/src/node/services/agentSession.preStreamError.test.ts index 57ce869399d..10234c69ae8 100644 --- a/src/node/services/agentSession.preStreamError.test.ts +++ b/src/node/services/agentSession.preStreamError.test.ts @@ -443,92 +443,6 @@ describe("AgentSession pre-stream errors", () => { session.dispose(); }); - it("does not double-schedule auto-retry when a failed completion follows its error event", async () => { - const workspaceId = "ws-runtime-start-failed-pre-emitted-error"; - - const { historyService, config, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const aiEmitter = new EventEmitter(); - const messageId = "assistant-stream-startup-failed"; - const streamMessage = mock((_history: MuxMessage[]) => { - aiEmitter.emit("error", { - workspaceId, - messageId, - error: "Runtime is still starting", - errorType: "runtime_start_failed", - }); - - return Promise.resolve( - Ok({ - streamToken: "stream-token", - messageId, - completion: Promise.resolve({ - status: "failed" as const, - messageId, - error: { - type: "runtime_start_failed" as const, - message: "Runtime is still starting", - }, - streamError: { - messageId, - error: "Runtime is still starting", - errorType: "runtime_start_failed" as const, - }, - }), - }) - ); - }); - - const aiService = Object.assign(aiEmitter, { - isStreaming: mock((_workspaceId: string) => false), - stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }) as unknown as AIService; - - const initStateManager = new EventEmitter() as unknown as InitStateManager; - - const backgroundProcessManager = { - cleanup: mock((_workspaceId: string) => Promise.resolve()), - setMessageQueued: mock((_workspaceId: string, _queued: boolean) => { - void _queued; - }), - } as unknown as BackgroundProcessManager; - - const session = new AgentSession({ - workspaceId, - config, - historyService, - aiService, - initStateManager, - backgroundProcessManager, - }); - - const events: WorkspaceChatMessage[] = []; - session.onChatEvent((event) => { - events.push(event.message); - }); - - const result = await session.sendMessage("hello", { - model: "anthropic:claude-3-5-sonnet-latest", - agentId: "exec", - }); - - expect(result.success).toBe(true); - - await session.waitForIdle(); - - const scheduledRetries = events.filter( - (event): event is Extract => - event.type === "auto-retry-scheduled" - ); - - expect(scheduledRetries).toHaveLength(1); - expect(scheduledRetries[0]?.attempt).toBe(1); - - session.dispose(); - }); - it("replays init state for since-mode reconnects", async () => { const workspaceId = "ws-replay-init-since"; const { session, cleanup, replayInit } = await createReplaySessionHarness(workspaceId); diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 5b19c9a8962..238b28935e3 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -2,7 +2,6 @@ import { mock } from "bun:test"; import { EventEmitter } from "events"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; -import type { MuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import type { Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; @@ -18,11 +17,7 @@ import { createTestHistoryService } from "@/node/services/testHistoryService"; import type { StreamErrorType } from "@/common/types/errors"; export function createStartedTurnHandle(messageId = "test-assistant"): TurnStreamHandle { - return { - streamToken: "test-stream-token", - messageId, - completion: new Promise(() => undefined), - }; + return { messageId, completion: new Promise(() => undefined) }; } export function createFailedTurnHandle( @@ -30,7 +25,6 @@ export function createFailedTurnHandle( failure: { error: string; errorType: StreamErrorType } ): TurnStreamHandle { return { - streamToken: "test-stream-token", messageId, completion: Promise.resolve({ status: "failed", @@ -67,22 +61,7 @@ function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partia aiService: AIService; } { const aiEmitter = args?.emitter ?? new EventEmitter(); - const { streamMessage: streamMessageOverride, ...overrides } = args?.overrides ?? {}; - const streamMessage = - streamMessageOverride ?? - (mock((_history: MuxMessage[]) => - Promise.resolve(Ok(undefined)) - ) as unknown as AIService["streamMessage"]); - const normalizedStreamMessage = mock( - async (...streamArgs: Parameters) => { - const result = await streamMessage(...streamArgs); - if (!result.success || result.data != null) { - return result; - } - - return Ok(createStartedTurnHandle("test-assistant-message")); - } - ); + const overrides = args?.overrides ?? {}; return { aiEmitter, @@ -90,7 +69,9 @@ function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partia isStreaming: mock((_workspaceId: string) => false), stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), getStreamInfo: mock((_workspaceId: string) => null), - streamMessage: normalizedStreamMessage as AIService["streamMessage"], + streamMessage: mock(() => + Promise.resolve(Ok(createStartedTurnHandle("test-assistant-message"))) + ) as unknown as AIService["streamMessage"], ...overrides, }) as unknown as AIService, }; diff --git a/src/node/services/agentSession.thinkingOverride.test.ts b/src/node/services/agentSession.thinkingOverride.test.ts index e4ccca47b79..b3d45815bb7 100644 --- a/src/node/services/agentSession.thinkingOverride.test.ts +++ b/src/node/services/agentSession.thinkingOverride.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, mock } from "bun:test"; import type { MuxMessage } from "@/common/types/message"; import { Ok, Err } from "@/common/types/result"; import type { AIService, StreamMessageOptions } from "@/node/services/aiService"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import type { ActiveTurnThinkingOverride } from "./thinkingOverride"; const MODEL = "anthropic:claude-sonnet-4-5"; @@ -38,7 +38,7 @@ describe("AgentSession.setActiveTurnThinkingLevel", () => { expect(first).toEqual({ accepted: true }); expect(second).toEqual({ accepted: true }); expect(opts.activeTurnThinkingOverride?.pending).toBe("low"); - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session, cleanup } = await createAgentSessionHarness({ @@ -76,7 +76,7 @@ describe("AgentSession.setActiveTurnThinkingLevel", () => { let pendingSeenByStream: string | undefined; const streamMessage = mock((opts: StreamMessageOptions) => { pendingSeenByStream = opts.activeTurnThinkingOverride?.pending; - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session, cleanup } = await createAgentSessionHarness({ @@ -133,7 +133,9 @@ describe("AgentSession.setActiveTurnThinkingLevel", () => { }); it("clears the holder when an onAccepted failure aborts the turn before streaming", async () => { - const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_history: MuxMessage[]) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session, cleanup } = await createAgentSessionHarness({ workspaceId: "thinking-override-onaccepted-failure", aiServiceOverrides: { diff --git a/src/node/services/agentSession.workspaceTurnInheritance.test.ts b/src/node/services/agentSession.workspaceTurnInheritance.test.ts index c19efecd029..8a3ca3e925b 100644 --- a/src/node/services/agentSession.workspaceTurnInheritance.test.ts +++ b/src/node/services/agentSession.workspaceTurnInheritance.test.ts @@ -5,7 +5,7 @@ import { Ok } from "@/common/types/result"; import type { AIService, StreamMessageOptions } from "@/node/services/aiService"; import { inheritOpenWorkspaceTurnMetadata } from "./agentSession"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; const correlation = { type: "workspace-turn-task", @@ -142,7 +142,7 @@ describe("AgentSession workspace-turn correlation inheritance", () => { let streamedMuxMetadata: StreamMessageOptions["muxMetadata"]; const streamMessage = mock((opts: StreamMessageOptions) => { streamedMuxMetadata = opts.muxMetadata; - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId: "workspace-turn-inheritance", @@ -186,7 +186,9 @@ describe("AgentSession workspace-turn correlation inheritance", () => { test("workspace-turn correlation persists in startup retry options", async () => { const workspaceId = "workspace-turn-retry-metadata"; - const streamMessage = mock((_opts: StreamMessageOptions) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_opts: StreamMessageOptions) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, aiServiceOverrides: { @@ -226,7 +228,9 @@ describe("AgentSession workspace-turn correlation inheritance", () => { test("on-send compaction consuming a wake stamps the correlation on the follow-up", async () => { const workspaceId = "workspace-turn-compaction-stamp"; - const streamMessage = mock((_opts: StreamMessageOptions) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((_opts: StreamMessageOptions) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, aiServiceOverrides: { diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index a8c5486210e..eb4e9192b07 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -52,7 +52,6 @@ import type { RuntimeStatusEvent, StreamAbortEvent, StreamEndEvent, - WorkflowRunAttachedEvent, } from "@/common/types/stream"; import { log } from "./log"; import type { SessionUsageService } from "./sessionUsageService"; @@ -412,7 +411,6 @@ function stubCommonStreamMessageDependencies(args: { return { success: true, data: { - streamToken, messageId: options.messageId, completion: new Promise(() => undefined), }, @@ -717,28 +715,6 @@ describe("AIService turn engine events", () => { expect(internals.pendingDevToolsRunMetadataByMessageId.has("message-1")).toBe(true); }); - it("forwards workflow-run-attached events", async () => { - using harness = createForwardingHarness("ai-service-workflow-run-attached-forwarding"); - const { service, internals } = harness; - const event: WorkflowRunAttachedEvent = { - type: "workflow-run-attached", - workspaceId: "workspace-1", - messageId: "message-1", - toolCallId: "workflow-call-1", - runId: "wfr_forwarded", - timestamp: Date.now(), - }; - - const forwardedPromise = new Promise((resolve) => { - service.once("workflow-run-attached", (forwarded) => - resolve(forwarded as WorkflowRunAttachedEvent) - ); - }); - await internals.emitEngineEvent(event); - - expect(await forwardedPromise).toEqual(event); - }); - it.each([ { name: "stream error", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 6458814c509..d2f66eeb3ef 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -848,12 +848,12 @@ export class AIService extends EventEmitter { this.emit(event.type, event); } - private createSettledTurnHandle(messageId: string, completion: TurnCompletion): TurnStreamHandle { - return { - streamToken: this.streamManager.generateStreamToken(), - messageId, - completion: Promise.resolve(completion), - }; + private createSettledTurnHandle(completion: TurnCompletion): TurnStreamHandle { + return { messageId: completion.messageId, completion: Promise.resolve(completion) }; + } + + private createAbortedTurnHandle(messageId: string): TurnStreamHandle { + return this.createSettledTurnHandle({ status: "aborted", messageId, abortReason: "startup" }); } private trackPendingDevToolsRunMetadata( @@ -1371,13 +1371,7 @@ export class AIService extends EventEmitter { if (this.mockModeEnabled && this.mockAiStreamPlayer) { await this.initStateManager.waitForInit(workspaceId, combinedAbortSignal); if (combinedAbortSignal.aborted) { - return Ok( - this.createSettledTurnHandle(syntheticMessageId, { - status: "aborted", - messageId: syntheticMessageId, - abortReason: "startup", - }) - ); + return Ok(this.createAbortedTurnHandle(syntheticMessageId)); } // play() resolves after the scripted playback (including any error // events, which the session drains from its in-flight capture), so the @@ -1393,10 +1387,7 @@ export class AIService extends EventEmitter { return result; } return Ok( - this.createSettledTurnHandle(syntheticMessageId, { - status: "completed", - messageId: syntheticMessageId, - }) + this.createSettledTurnHandle({ status: "completed", messageId: syntheticMessageId }) ); } @@ -1760,13 +1751,7 @@ export class AIService extends EventEmitter { await this.initStateManager.waitForInit(workspaceId, combinedAbortSignal); recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); if (combinedAbortSignal.aborted) { - return Ok( - this.createSettledTurnHandle(syntheticMessageId, { - status: "aborted", - messageId: syntheticMessageId, - abortReason: "startup", - }) - ); + return Ok(this.createAbortedTurnHandle(syntheticMessageId)); } // Verify runtime is actually reachable after init completes. @@ -3013,13 +2998,7 @@ export class AIService extends EventEmitter { }); if (combinedAbortSignal.aborted) { - return Ok( - this.createSettledTurnHandle(assistantMessageId, { - status: "aborted", - messageId: assistantMessageId, - abortReason: "startup", - }) - ); + return Ok(this.createAbortedTurnHandle(assistantMessageId)); } const requestHistorySequence = providerRequestMessages.reduce( @@ -3074,7 +3053,7 @@ export class AIService extends EventEmitter { if (forceContextLimitError) { const streamError = await simulateContextLimitError(simulationCtx, this.historyService); return Ok( - this.createSettledTurnHandle(assistantMessageId, { + this.createSettledTurnHandle({ status: "failed", messageId: assistantMessageId, streamError, @@ -3083,10 +3062,7 @@ export class AIService extends EventEmitter { } await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); return Ok( - this.createSettledTurnHandle(assistantMessageId, { - status: "completed", - messageId: assistantMessageId, - }) + this.createSettledTurnHandle({ status: "completed", messageId: assistantMessageId }) ); } @@ -3362,13 +3338,7 @@ export class AIService extends EventEmitter { if (combinedAbortSignal.aborted) { await deleteAbortedPlaceholder(assistantMessageId); - return Ok( - this.createSettledTurnHandle(assistantMessageId, { - status: "aborted", - messageId: assistantMessageId, - abortReason: "startup", - }) - ); + return Ok(this.createAbortedTurnHandle(assistantMessageId)); } // Capture request payload for the debug modal, then delegate to StreamManager. diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 92c3d4f8fc2..3f9a19629bd 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5447,29 +5447,6 @@ describe("StreamManager - categorizeError", () => { }); } }); -describe("StreamManager - ask_user_question Partial Persistence", () => { - // Note: The ask_user_question tool blocks waiting for user input. - // If the app restarts during that wait, the partial must be persisted. - // The fix (flush partial immediately for ask_user_question) is verified - // by the code path in processStreamWithCleanup's tool-call handler: - // - // if (part.toolName === "ask_user_question") { - // await this.flushPartialWrite(workspaceId, streamInfo); - // } - // - // Full integration test would require mocking the entire streaming pipeline. - // Instead, we verify the StreamManager has the expected method signature. - - test("flushPartialWrite is a callable method", () => { - const streamManager = new StreamManager(historyService); - - // Verify the private method exists and is callable - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const flushMethod = Reflect.get(streamManager, "flushPartialWrite"); - expect(typeof flushMethod).toBe("function"); - }); -}); - describe("StreamManager - stopStream", () => { test("emits stream-abort when stopping non-existent stream", async () => { const streamManager = new StreamManager(historyService); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 52d23b086d0..f109c8602f1 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -214,7 +214,6 @@ export type TurnCompletion = }; export interface TurnStreamHandle { - streamToken: string; messageId: string; completion: Promise; } @@ -4526,11 +4525,7 @@ export class StreamManager { onStreamConstructed, } = options; const completionController = createTurnCompletionController(); - const createHandle = (streamToken: StreamToken): TurnStreamHandle => ({ - streamToken, - messageId, - completion: completionController.promise, - }); + const handle: TurnStreamHandle = { messageId, completion: completionController.promise }; const typedWorkspaceId = workspaceId as WorkspaceId; @@ -4575,7 +4570,7 @@ export class StreamManager { // temp dir creation, etc), avoid starting the stream entirely. if (streamAbortController.signal.aborted) { completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); - return Ok(createHandle(streamToken)); + return Ok(handle); } // Step 3: Create temp directory for this stream using runtime. @@ -4586,7 +4581,7 @@ export class StreamManager { if (streamAbortController.signal.aborted) { completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); - return Ok(createHandle(streamToken)); + return Ok(handle); } // Step 4: Atomic stream creation and registration @@ -4605,7 +4600,7 @@ export class StreamManager { if (streamAbortController.signal.aborted) { this.workspaceStreams.delete(typedWorkspaceId); completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); - return Ok(createHandle(streamToken)); + return Ok(handle); } streamInfo.unlinkAbortSignal = unlinkAbortSignal; @@ -4631,7 +4626,7 @@ export class StreamManager { } streamRegistered = false; completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); - return Ok(createHandle(streamToken)); + return Ok(handle); } // Step 5: Track the processing promise for guaranteed cleanup @@ -4644,7 +4639,7 @@ export class StreamManager { log.error("Unexpected error in stream processing:", error); }); - return Ok(createHandle(streamToken)); + return Ok(handle); } finally { if (!streamRegistered) { runLanguageModelCleanup(model); From 73c483987098573a52a1fceb09532d6f9f58b89a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:13:50 +0000 Subject: [PATCH 31/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20bui?= =?UTF-8?q?ld=20stream=20requests=20from=20one=20input=20object?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/streamManager.test.ts | 175 +++++++----------------- src/node/services/streamManager.ts | 164 ++++++++++++---------- 2 files changed, 140 insertions(+), 199 deletions(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 3f9a19629bd..d62b91d5ad9 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -1044,7 +1044,7 @@ describe("StreamManager - Anthropic cache TTL overrides", () => { providerOptions?: Record; } - type BuildStreamRequestConfig = (...args: unknown[]) => StreamRequestConfigForTests; + type BuildStreamRequestConfig = (input: Record) => StreamRequestConfigForTests; test("applies anthropicCacheTtlOverride to manual cache markers without top-level cacheControl", () => { const streamManager = new StreamManager(historyService); @@ -1058,8 +1058,8 @@ describe("StreamManager - Anthropic cache TTL overrides", () => { } // Rebind: buildStreamRequestConfig reads this.getProvidersConfig for // cache-marker eligibility; a detached Reflect.get reference loses `this`. - const buildRequestConfigBound: BuildStreamRequestConfig = (...args) => - buildRequestConfig.apply(streamManager, args); + const buildRequestConfigBound: BuildStreamRequestConfig = (input) => + buildRequestConfig.call(streamManager, input); const model = createAnthropic({ apiKey: "test" })("claude-sonnet-4-5"); const modelString = KNOWN_MODELS.SONNET.id; @@ -1083,21 +1083,15 @@ describe("StreamManager - Anthropic cache TTL overrides", () => { }), }; - const request = buildRequestConfigBound( + const request = buildRequestConfigBound({ model, modelString, messages, - "You are a helpful assistant", - undefined, // routeProvider + system: "You are a helpful assistant", tools, providerOptions, - undefined, - undefined, - undefined, - undefined, - undefined, - "1h" - ); + anthropicCacheTtlOverride: "1h", + }); expect(request.system).toBeUndefined(); expect(request.providerOptions).toEqual(providerOptions); @@ -1156,7 +1150,7 @@ describe("StreamManager - OpenAI GPT-5.6 cached system instructions", () => { system?: string | { role: string; content: string; providerOptions?: unknown }; } - type BuildStreamRequestConfig = (...args: unknown[]) => StreamRequestConfigForTests; + type BuildStreamRequestConfig = (input: Record) => StreamRequestConfigForTests; const eligibleProvidersConfig: ProvidersConfigMap = { openai: { apiKeySet: true, isEnabled: true, isConfigured: true }, @@ -1179,14 +1173,13 @@ describe("StreamManager - OpenAI GPT-5.6 cached system instructions", () => { "buildStreamRequestConfig" ); // .call: the transform reads this.getProvidersConfig for route eligibility. - return buildRequestConfig.call( - streamManager, - createTestLanguageModel(), + return buildRequestConfig.call(streamManager, { + model: createTestLanguageModel(), modelString, - [{ role: "user", content: "hello" }], - "You are a helpful assistant", - routeProvider - ); + messages: [{ role: "user", content: "hello" }], + system: "You are a helpful assistant", + routeProvider, + }); } test("direct GPT-5.6 replaces the system string with a structured cached message", () => { @@ -1383,7 +1376,7 @@ describe("StreamManager - sequential tool execution", () => { toolChoice?: { type: "tool"; toolName: string }; } - type BuildStreamRequestConfig = (...args: unknown[]) => StreamRequestConfigForTests; + type BuildStreamRequestConfig = (input: Record) => StreamRequestConfigForTests; type CreateStreamResult = ( request: StreamRequestConfigForTests, abortController: AbortController @@ -1425,7 +1418,7 @@ describe("StreamManager - sequential tool execution", () => { return { // .apply: buildStreamRequestConfig reads this.getProvidersConfig for // cache-marker eligibility; a detached Reflect.get reference loses `this`. - buildRequestConfig: (...args) => buildRequestConfig.apply(streamManager, args), + buildRequestConfig: (input) => buildRequestConfig.call(streamManager, input), createStreamResult: (request, abortController) => createStreamResultMethod.call(streamManager, request, abortController), }; @@ -1481,22 +1474,14 @@ describe("StreamManager - sequential tool execution", () => { steps: Promise.resolve([]), } as unknown as ReturnType); - const request = buildRequestConfig( + const request = buildRequestConfig({ model, - KNOWN_MODELS.SONNET.id, - [{ role: "user", content: "hello" }], - "system", - undefined, // routeProvider + modelString: KNOWN_MODELS.SONNET.id, + messages: [{ role: "user", content: "hello" }], + system: "system", tools, - undefined, - undefined, - undefined, - undefined, - false, - () => false, - undefined, - undefined - ); + hasQueuedMessages: () => false, + }); createStreamResult(request, new AbortController()); expect(streamTextSpy).toHaveBeenCalledTimes(1); @@ -1543,7 +1528,7 @@ describe("StreamManager - call settings overrides", () => { onChunk?: NonNullable[0]["onChunk"]>; } - type BuildStreamRequestConfig = (...args: unknown[]) => StreamRequestConfigForTests; + type BuildStreamRequestConfig = (input: Record) => StreamRequestConfigForTests; type CreateStreamResult = ( request: StreamRequestConfigForTests, abortController: AbortController @@ -1574,7 +1559,7 @@ describe("StreamManager - call settings overrides", () => { return { // .apply: buildStreamRequestConfig reads this.getProvidersConfig for // cache-marker eligibility; a detached Reflect.get reference loses `this`. - buildRequestConfig: (...args) => buildRequestConfig.apply(streamManager, args), + buildRequestConfig: (input) => buildRequestConfig.call(streamManager, input), createStreamResult: (request, abortController) => createStreamResultMethod.call(streamManager, request, abortController), }; @@ -1591,21 +1576,14 @@ describe("StreamManager - call settings overrides", () => { }; } ): StreamRequestConfigForTests { - return buildRequestConfig( + return buildRequestConfig({ model, modelString, messages, - "system", - undefined, // routeProvider - undefined, - undefined, - options.maxOutputTokens, - options.callSettingsOverrides, - undefined, - undefined, - undefined, - undefined - ); + system: "system", + maxOutputTokens: options.maxOutputTokens, + callSettingsOverrides: options.callSettingsOverrides, + }); } function setupStreamTextSpy() { @@ -1693,23 +1671,7 @@ describe("StreamManager - call settings overrides", () => { const streamTextSpy = setupStreamTextSpy(); const onChunk = mock(() => undefined); - const request = buildRequestConfig( - model, - modelString, - messages, - "system", - undefined, // routeProvider - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - onChunk, - undefined - ); + const request = buildRequestConfig({ model, modelString, messages, system: "system", onChunk }); createStreamResult(request, new AbortController()); @@ -5758,7 +5720,7 @@ describe("StreamManager - tool search activeTools scoping", () => { toolSearchState?: ToolSearchStreamState; } - type BuildStreamRequestConfig = (...args: unknown[]) => StreamRequestConfigForTests; + type BuildStreamRequestConfig = (input: Record) => StreamRequestConfigForTests; type CreateStreamResult = ( request: StreamRequestConfigForTests, abortController: AbortController @@ -5794,7 +5756,7 @@ describe("StreamManager - tool search activeTools scoping", () => { return { // .apply: buildStreamRequestConfig reads this.getProvidersConfig for // cache-marker eligibility; a detached Reflect.get reference loses `this`. - buildRequestConfig: (...args) => buildRequestConfig.apply(streamManager, args), + buildRequestConfig: (input) => buildRequestConfig.call(streamManager, input), createStreamResult: (request, abortController) => createStreamResultMethod.call(streamManager, request, abortController), }; @@ -5887,24 +5849,13 @@ describe("StreamManager - tool search activeTools scoping", () => { activatedToolNames: new Set(), }; - const request = buildRequestConfig( + const request = buildRequestConfig({ model, - KNOWN_MODELS.SONNET.id, + modelString: KNOWN_MODELS.SONNET.id, messages, - "system", - undefined, // routeProvider - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - toolSearchState - ); + system: "system", + toolSearchState, + }); // Same reference, not a copy. tool_catalog_search.execute mutations must be // visible to prepareStep. @@ -5924,7 +5875,7 @@ describe("StreamManager - mid-turn thinking override", () => { onStepMessages?: (stepMessages: ModelMessage[]) => void; } - type BuildStreamRequestConfig = (...args: unknown[]) => OverrideRequestForTests; + type BuildStreamRequestConfig = (input: Record) => OverrideRequestForTests; type CreateStreamResult = ( request: OverrideRequestForTests, abortController: AbortController @@ -5949,7 +5900,7 @@ describe("StreamManager - mid-turn thinking override", () => { createStreamResult: CreateStreamResult; } { const buildRequestConfig = Reflect.get(streamManager, "buildStreamRequestConfig") as - | ((...args: unknown[]) => OverrideRequestForTests) + | ((input: Record) => OverrideRequestForTests) | undefined; const createStreamResultMethod = Reflect.get(streamManager, "createStreamResult") as | CreateStreamResult @@ -5960,7 +5911,7 @@ describe("StreamManager - mid-turn thinking override", () => { throw new Error("Expected StreamManager private helpers to exist"); } return { - buildRequestConfig: (...args) => buildRequestConfig.apply(streamManager, args), + buildRequestConfig: (input) => buildRequestConfig.call(streamManager, input), createStreamResult: (request, abortController) => createStreamResultMethod.call(streamManager, request, abortController), }; @@ -6140,50 +6091,26 @@ describe("StreamManager - mid-turn thinking override", () => { const rebuild: RebuildProviderOptionsForThinkingLevel = () => null; // Without the closure, an absent providerOptions stays absent (no behavior change). - const plainRequest = buildRequestConfig( + const plainRequest = buildRequestConfig({ model, - KNOWN_MODELS.SONNET.id, + modelString: KNOWN_MODELS.SONNET.id, messages, - "system", - undefined, // routeProvider - undefined, // tools - undefined, // providerOptions - undefined, // maxOutputTokens - undefined, // callSettingsOverrides - undefined, // toolPolicy - undefined, // hasQueuedMessages - undefined, // headers - undefined, // anthropicCacheTtlOverride - undefined, // onChunk - undefined, // onStepMessages - undefined // toolSearchState - ); + system: "system", + }); expect(plainRequest.providerOptions).toBeUndefined(); // With the closure, undefined normalizes to a mutable object whose identity // is exactly what streamText() captures — otherwise in-place mutation at // prepareStep time would be unobservable to the SDK's per-step merge. - const request = buildRequestConfig( + const request = buildRequestConfig({ model, - KNOWN_MODELS.SONNET.id, + modelString: KNOWN_MODELS.SONNET.id, messages, - "system", - undefined, - undefined, - undefined, // providerOptions intentionally absent - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, // onToolExecutionStart - state, - rebuild - ); + system: "system", + // providerOptions intentionally absent + thinkingOverrideState: state, + rebuildProviderOptionsForThinkingLevel: rebuild, + }); expect(request.providerOptions).toEqual({}); expect(request.thinkingOverrideState).toBe(state); expect(request.rebuildProviderOptionsForThinkingLevel).toBe(rebuild); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index f109c8602f1..57011c80cef 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -276,6 +276,39 @@ export interface TurnExecutionOptions { // Stream request config for start/retry +// Request-construction inputs shared by the primary turn (sourced from +// TurnExecutionOptions) and model-fallback hops (sourced from the prepared +// fallback). routeProvider is the backend-resolved route +// (initialMetadata.routeProvider for the primary request, +// initialMetadataPatch.routeProvider for fallbacks); missing route metadata +// fails closed for OpenAI explicit prompt caching. +type StreamRequestInput = Pick< + TurnExecutionOptions, + | "model" + | "modelString" + | "messages" + | "system" + | "tools" + | "providerOptions" + | "maxOutputTokens" + | "callSettingsOverrides" + | "toolPolicy" + | "hasQueuedMessages" + | "headers" + | "anthropicCacheTtlOverride" + | "onChunk" + | "onStepMessages" + | "toolSearchState" + | "thinkingOverrideState" + | "rebuildProviderOptionsForThinkingLevel" + | "forcedFirstStepToolNames" + | "providersConfigSnapshot" + | "rebuildFirstStepForThinkingLevel" +> & { + routeProvider?: string; + onToolExecutionStart?: (toolCallId: string) => void; +}; + interface StepMessageTracker { latestMessages?: ModelMessage[]; } @@ -1795,33 +1828,31 @@ export class StreamManager { : normalizeUsageModelKey(model, this.getProvidersConfig()); } - private buildStreamRequestConfig( - model: LanguageModel, - modelString: string, - messages: ModelMessage[], - system: string, - // Backend-resolved route provider (initialMetadata.routeProvider for the - // primary request, initialMetadataPatch.routeProvider for fallbacks). - // Missing route metadata fails closed for OpenAI explicit prompt caching. - routeProvider?: string, - tools?: Record, - providerOptions?: Record, - maxOutputTokens?: number, - callSettingsOverrides?: ResolvedCallSettingsOverrides, - toolPolicy?: ToolPolicy, - hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean, - headers?: Record, - anthropicCacheTtlOverride?: AnthropicCacheTtl, - onChunk?: StreamTextOnChunk, - onStepMessages?: (messages: ModelMessage[]) => void, - toolSearchState?: ToolSearchStreamState, - onToolExecutionStart?: (toolCallId: string) => void, - thinkingOverrideState?: ActiveTurnThinkingOverride, - rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames?: string[], - providersConfigSnapshot?: ProvidersConfigMap, - rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel - ): StreamRequestConfig { + private buildStreamRequestConfig(input: StreamRequestInput): StreamRequestConfig { + const { + model, + modelString, + messages, + system, + routeProvider, + tools, + providerOptions, + maxOutputTokens, + callSettingsOverrides, + toolPolicy, + hasQueuedMessages, + headers, + anthropicCacheTtlOverride, + onChunk, + onStepMessages, + toolSearchState, + onToolExecutionStart, + thinkingOverrideState, + rebuildProviderOptionsForThinkingLevel, + forcedFirstStepToolNames, + providersConfigSnapshot, + rebuildFirstStepForThinkingLevel, + } = input; // The request's pinned providers-config snapshot (when the caller has // one): cache wrappers and type-derived output limits below must resolve // Coder instance metadata against the SAME config that created the SDK @@ -2178,30 +2209,12 @@ export class StreamManager { } = options; const stepTracker: StepMessageTracker = {}; const metadataModel = this.resolveMetadataModel(modelString, options.providersConfigSnapshot); - const request = this.buildStreamRequestConfig( - options.model, - modelString, - options.messages, - options.system, - initialMetadata?.routeProvider, - options.tools, - options.providerOptions, - maxOutputTokens, - options.callSettingsOverrides, - options.toolPolicy, - options.hasQueuedMessages, - options.headers, - options.anthropicCacheTtlOverride, - options.onChunk, - options.onStepMessages, - options.toolSearchState, - (toolCallId) => this.handleToolExecutionStart(workspaceId, messageId, toolCallId), - options.thinkingOverrideState, - options.rebuildProviderOptionsForThinkingLevel, - options.forcedFirstStepToolNames, - options.providersConfigSnapshot, - options.rebuildFirstStepForThinkingLevel - ); + const request = this.buildStreamRequestConfig({ + ...options, + routeProvider: initialMetadata?.routeProvider, + onToolExecutionStart: (toolCallId) => + this.handleToolExecutionStart(workspaceId, messageId, toolCallId), + }); // Start streaming - this can throw immediately if API key is missing let streamResult; @@ -2932,38 +2945,39 @@ export class StreamManager { // Build the swapped request/stream into locals first so a failure here // leaves streamInfo unmodified (the terminal error then names the model // that actually refused, not a half-applied fallback). - const nextRequest = this.buildStreamRequestConfig( - prepared.data.model, - prepared.data.modelString, - prepared.data.messages, - prepared.data.system, + const nextRequest = this.buildStreamRequestConfig({ + model: prepared.data.model, + modelString: prepared.data.modelString, + messages: prepared.data.messages, + system: prepared.data.system, // Use the fallback's freshly-resolved route (not stale source metadata, // which streamInfo.initialMetadata still holds at this point) so the // OpenAI cached-system transform evaluates the fallback route. - prepared.data.initialMetadataPatch?.routeProvider, - prepared.data.tools, - prepared.data.providerOptions, - fallbackState.original.maxOutputTokens, - prepared.data.callSettingsOverrides, - streamInfo.request.toolPolicy, - streamInfo.request.hasQueuedMessages, - prepared.data.headers, - prepared.data.anthropicCacheTtl, - streamInfo.request.onChunk, - streamInfo.request.onStepMessages, + routeProvider: prepared.data.initialMetadataPatch?.routeProvider, + tools: prepared.data.tools, + providerOptions: prepared.data.providerOptions, + maxOutputTokens: fallbackState.original.maxOutputTokens, + callSettingsOverrides: prepared.data.callSettingsOverrides, + toolPolicy: streamInfo.request.toolPolicy, + hasQueuedMessages: streamInfo.request.hasQueuedMessages, + headers: prepared.data.headers, + anthropicCacheTtlOverride: prepared.data.anthropicCacheTtl, + onChunk: streamInfo.request.onChunk, + onStepMessages: streamInfo.request.onStepMessages, // Same state object: aiService's fallback prepare() rebuilt it in place // against the fallback toolset, so prepareStep keeps reading live state. - streamInfo.request.toolSearchState, - (toolCallId) => this.handleToolExecutionStart(workspaceId, streamInfo.messageId, toolCallId), + toolSearchState: streamInfo.request.toolSearchState, + onToolExecutionStart: (toolCallId) => + this.handleToolExecutionStart(workspaceId, streamInfo.messageId, toolCallId), // Same holder object (the session's setter keeps working across the // hop) with a closure bound to the FALLBACK model. Attached before // createStreamResult below in case the SDK eagerly prepares step 1. - streamInfo.request.thinkingOverrideState, - prepared.data.rebuildProviderOptionsForThinkingLevel, - prepared.data.forcedFirstStepToolNames, - prepared.data.providersConfig, - prepared.data.rebuildFirstStepForThinkingLevel - ); + thinkingOverrideState: streamInfo.request.thinkingOverrideState, + rebuildProviderOptionsForThinkingLevel: prepared.data.rebuildProviderOptionsForThinkingLevel, + forcedFirstStepToolNames: prepared.data.forcedFirstStepToolNames, + providersConfigSnapshot: prepared.data.providersConfig, + rebuildFirstStepForThinkingLevel: prepared.data.rebuildFirstStepForThinkingLevel, + }); // createStreamResult may eagerly prepare the first fallback step and update // latestMessages. Clear stale source-step messages before starting it so a // later disk-reset await cannot wipe freshly prepared fallback messages. From 6858f22e4d3364638882dc2d6a51a581d28d27d2 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:15:34 +0000 Subject: [PATCH 32/42] =?UTF-8?q?=F0=9F=A4=96=20chore(streaming):=20trim?= =?UTF-8?q?=20repeated=20capture=20narration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentSession.ts | 24 ++++++++---------------- src/node/services/streamManager.ts | 2 -- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 55dee94484e..c50a90a97ba 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1156,13 +1156,10 @@ export class AgentSession { private activeTurnStreamHandle: TurnStreamHandle | null = null; /** - * Capture list for error events observed while this session's streamMessage - * call is in flight and no turn handle owns them (pre-start failures such as - * runtime readiness or strict agent resolution). AIService emits these for - * fire-and-forget senders and then returns Err, so no completion will ever - * deliver them; the Err path handles each exactly once via the event's own - * messageId. Null outside the in-flight window so unrelated error events - * (e.g. a later mid-turn failure) are never captured. + * Error events observed while this session's streamMessage call is in + * flight and no turn handle owns them (pre-start failures such as runtime + * readiness). No completion will ever deliver them, so the Err path handles + * each exactly once. Null outside the in-flight window. */ private preStartErrorCapture: StreamErrorPayload[] | null = null; @@ -4850,11 +4847,8 @@ export class AgentSession { acpPromptId?: string, preStartErrors?: StreamErrorPayload[] | null ): Promise> { - // Pre-start and synthetic failures that AIService announced as error - // events (for fire-and-forget senders) have no turn handle, so their - // handling and recovery-decision resolution run here, keyed by each - // event's own messageId. When any were captured they own this failure: - // the branches below only cover failures that produced no error event. + // Captured pre-start error events own this failure; the branches below + // only cover failures that produced no error event. if (await this.handleCapturedStreamErrors(preStartErrors, acpPromptId)) { return { success: false, error, failureHandled: true }; } @@ -5191,10 +5185,8 @@ export class AgentSession { } this.consumeTurnCompletion(streamResult.data); - // Mock playback returns Ok after emitting synthetic error events under its - // own message IDs; drain those so the failures are still handled. Events - // owned by the returned handle are excluded: completion delivers them, so - // a stream failing inside the capture window is not handled twice. + // Drain mock playback's synthetic error events (emitted under their own + // message IDs); events owned by the returned handle stay with completion. await this.handleCapturedStreamErrors( capturedPreStartErrors?.filter((event) => event.messageId !== streamResult.data.messageId), acpPromptId diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 57011c80cef..d4f8f88308e 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -800,8 +800,6 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { * - Only one active stream per workspace at any time * - Atomic stream creation/cancellation operations * - Guaranteed resource cleanup in all code paths - * - * Physical inlining into AIService is intentionally deferred to a mechanical follow-up. */ export class StreamManager { private workspaceStreams = new Map(); From d487ad48a5719149a509674d48301387b11c8ee6 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:57:17 +0000 Subject: [PATCH 33/42] =?UTF-8?q?=F0=9F=A4=96=20fix(streaming):=20settle?= =?UTF-8?q?=20mock=20playback=20turns,=20drop=20post-dispose=20failed=20co?= =?UTF-8?q?mpletions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/agentSession.disposeRace.test.ts | 47 ++++++++++++++++++- src/node/services/agentSession.ts | 3 +- src/node/services/aiService.ts | 10 ++-- .../services/mock/mockAiStreamPlayer.test.ts | 14 ++++++ src/node/services/mock/mockAiStreamPlayer.ts | 39 +++++++++++---- src/node/services/streamManager.ts | 4 +- 6 files changed, 99 insertions(+), 18 deletions(-) diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index ab65098f998..a0840ec8559 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, mock } from "bun:test"; +import { describe, expect, test, mock, spyOn } from "bun:test"; import { existsSync } from "node:fs"; import * as fs from "node:fs/promises"; import * as nodePath from "node:path"; @@ -17,6 +17,9 @@ import { startAbandonedBranchSummaryInBackground, type BranchSummaryAiService, } from "./branchSummary"; +import { createAgentSessionHarness } from "./agentSession.testHarness"; +import type { StreamMessageOptions } from "./aiService"; +import type { TurnCompletion } from "./streamManager"; function createDeferred(): { promise: Promise; @@ -504,6 +507,48 @@ describe("AgentSession disposal race conditions", () => { expect(setEnabled).toHaveBeenCalledTimes(0); }); + test("drops failed turn completions delivered after disposal", async () => { + let settleCompletion: (completion: TurnCompletion) => void = () => undefined; + const streamMessage = mock((_opts: StreamMessageOptions) => + Promise.resolve( + Ok({ + messageId: "assistant-post-dispose", + completion: new Promise((resolve) => { + settleCompletion = resolve; + }), + }) + ) + ); + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId: "ws-dispose-turn-completion", + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + }); + try { + const result = await session.sendMessage("hello", { + model: "anthropic:claude-3-5-sonnet-latest", + agentId: "exec", + }); + expect(result.success).toBe(true); + + const errorSink = session as unknown as { + handleStreamError: (data: unknown) => Promise; + }; + const handleStreamErrorSpy = spyOn(errorSink, "handleStreamError"); + session.dispose(); + settleCompletion({ + status: "failed", + messageId: "assistant-post-dispose", + streamError: { messageId: "assistant-post-dispose", error: "boom", errorType: "api" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(handleStreamErrorSpy).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } + }); + test("preserves synthetic flag when flushing queued messages", () => { const aiService: AIService = { on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index c50a90a97ba..5051e2e7a20 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4896,7 +4896,8 @@ export class AgentSession { this.activeTurnStreamHandle = handle; void handle.completion .then(async (outcome) => { - if (outcome.status !== "failed") return; + // A disposed session must not persist retry/goal state post-teardown. + if (outcome.status !== "failed" || this.disposed) return; try { await this.handleStreamError(outcome.streamError); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index d2f66eeb3ef..4c4b879f36f 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1373,9 +1373,9 @@ export class AIService extends EventEmitter { if (combinedAbortSignal.aborted) { return Ok(this.createAbortedTurnHandle(syntheticMessageId)); } - // play() resolves after the scripted playback (including any error - // events, which the session drains from its in-flight capture), so the - // handle can settle immediately. + // play() resolves at stream-start; the scripted turn settles its own + // completion from the terminal scripted event, which arrives later. + // Pre-start aborts never schedule a turn and settle aborted here. const result = await this.mockAiStreamPlayer.play(messages, workspaceId, { model: modelString, agentId, @@ -1386,9 +1386,7 @@ export class AIService extends EventEmitter { if (!result.success) { return result; } - return Ok( - this.createSettledTurnHandle({ status: "completed", messageId: syntheticMessageId }) - ); + return Ok(result.data ?? this.createAbortedTurnHandle(syntheticMessageId)); } // DEBUG: Log streamMessage call diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index c6c3c6a1fd6..a9722c9e058 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -59,6 +59,8 @@ describe("MockAiStreamPlayer", () => { test("appends assistant placeholder even when router turn ends with stream error", async () => { const aiServiceStub = new EventEmitter(); + // Bare EventEmitters throw on unobserved "error" emits (production always subscribes). + aiServiceStub.on("error", () => undefined); const player = new MockAiStreamPlayer({ historyService, @@ -98,6 +100,11 @@ describe("MockAiStreamPlayer", () => { workspaceId ); expect(secondResult.success).toBe(true); + if (!secondResult.success || !secondResult.data) throw new Error("expected a stream handle"); + await expect(secondResult.data.completion).resolves.toMatchObject({ + status: "failed", + messageId: secondResult.data.messageId, + }); // Read back all messages and check the assistant placeholders const allResult = await historyService.getLastMessages(workspaceId, 100); @@ -684,6 +691,11 @@ describe("MockAiStreamPlayer", () => { expect(playResult.success).toBe(true); await waitForCondition(() => !player.isStreaming(workspaceId), 2000); + if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); + await expect(playResult.data.completion).resolves.toMatchObject({ + status: "completed", + messageId: playResult.data.messageId, + }); const partial = await historyService.readPartial(workspaceId); expect(partial).toBeNull(); @@ -800,5 +812,7 @@ describe("MockAiStreamPlayer", () => { expect(deltaCount).toBe(deltasAtStop); expect(abortCount).toBe(1); + if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); + await expect(playResult.data.completion).resolves.toMatchObject({ status: "aborted" }); }); }); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 62193d7cfa8..083664527f9 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -7,6 +7,11 @@ import { Ok, Err } from "@/common/types/result"; import type { SendMessageError } from "@/common/types/errors"; import type { AIService } from "@/node/services/aiService"; import { createErrorEvent } from "@/node/services/utils/sendMessageError"; +import { + createTurnCompletionController, + type TurnCompletion, + type TurnStreamHandle, +} from "@/node/services/streamManager"; import { log } from "@/node/services/log"; import type { MockAssistantEvent, @@ -135,6 +140,7 @@ interface ActiveStream { eventQueue: Array<() => Promise>; isProcessing: boolean; cancelled: boolean; + settleCompletion: (completion: TurnCompletion) => void; } export class MockAiStreamPlayer { @@ -281,7 +287,7 @@ export class MockAiStreamPlayer { muxMetadata?: MuxMessageMetadata; abortSignal?: AbortSignal; } - ): Promise> { + ): Promise> { const abortSignal = options?.abortSignal; if (abortSignal?.aborted) { return Ok(undefined); @@ -402,14 +408,16 @@ export class MockAiStreamPlayer { return Ok(undefined); } - this.scheduleEvents(workspaceId, events, messageId, historySequence, options?.muxMetadata); + const handle = this.scheduleEvents( + workspaceId, + events, + messageId, + historySequence, + options?.muxMetadata + ); await streamStartPromise; - if (abortSignal?.aborted) { - return Ok(undefined); - } - - return Ok(undefined); + return Ok(handle); } async replayStream(_workspaceId: string): Promise { @@ -422,11 +430,12 @@ export class MockAiStreamPlayer { messageId: string, historySequence: number, muxMetadata?: MuxMessageMetadata - ): void { + ): TurnStreamHandle { const timers: Array> = []; const streamStart = events.find( (event): event is MockStreamStartEvent => event.kind === "stream-start" ); + const completionController = createTurnCompletionController(); this.activeStreams.set(workspaceId, { timers, messageId, @@ -442,6 +451,7 @@ export class MockAiStreamPlayer { eventQueue: [], isProcessing: false, cancelled: false, + settleCompletion: completionController.settle, }); for (const event of events) { @@ -452,6 +462,7 @@ export class MockAiStreamPlayer { }, event.delay); timers.push(timer); } + return { messageId, completion: completionController.promise }; } private enqueueEvent(workspaceId: string, messageId: string, handler: () => Promise): void { @@ -804,6 +815,11 @@ export class MockAiStreamPlayer { errorType: payload.errorType, }) ); + active.settleCompletion({ + status: "failed", + messageId, + streamError: { messageId, error: payload.error, errorType: payload.errorType }, + }); this.cleanup(workspaceId); break; } @@ -868,6 +884,7 @@ export class MockAiStreamPlayer { if (!this.isCurrentActiveStream(workspaceId, active)) return; this.deps.aiService.emit("stream-end", payload); + active.settleCompletion({ status: "completed", messageId }); this.cleanup(workspaceId); break; } @@ -879,6 +896,12 @@ export class MockAiStreamPlayer { if (!active) return; active.cancelled = true; + // Settle-once backstop: terminal events settled above; cancels settle here. + active.settleCompletion({ + status: "aborted", + messageId: active.messageId, + abortReason: "user", + }); if (active.partialWriteTimer) { clearTimeout(active.partialWriteTimer); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index d4f8f88308e..fab15989872 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -218,12 +218,12 @@ export interface TurnStreamHandle { completion: Promise; } -interface TurnCompletionController { +export interface TurnCompletionController { promise: Promise; settle: (completion: TurnCompletion) => void; } -function createTurnCompletionController(): TurnCompletionController { +export function createTurnCompletionController(): TurnCompletionController { let settled = false; let resolveCompletion!: (completion: TurnCompletion) => void; const promise = new Promise((resolve) => { From d164514ba49e625e474ed931d31227796fe11286 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:01:48 +0000 Subject: [PATCH 34/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20drop?= =?UTF-8?q?=20real-API=20concurrency=20duplicate,=20compress=20completion?= =?UTF-8?q?=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/streamManager.test.ts | 274 ++++++------------------ 1 file changed, 63 insertions(+), 211 deletions(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index d62b91d5ad9..dd8a2879800 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -64,7 +64,6 @@ function createTestLanguageModel(modelId = "cleanup-model"): LanguageModel { } // Skip integration tests if TEST_INTEGRATION is not set -const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; // Validate API keys before running tests if (shouldRunIntegrationTests()) { @@ -1932,18 +1931,39 @@ describe("StreamManager - turn completion", () => { }); } + /** Stream body that stays open until its turn abort controller fires. */ + const hangUntilAbort = (_request: unknown, abortController: AbortController) => + createStreamResultForTests( + (async function* () { + await new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + yield* []; + })() + ); + async function startWithStreamResult(input: { workspaceId: string; messageId: string; - fullStream: AsyncGenerator; + fullStream?: AsyncGenerator; + createStreamResult?: (request: unknown, abortController: AbortController) => unknown; + sink?: (event: TurnEngineEvent) => void | Promise; events?: TurnEngineEvent[]; }) { - const streamManager = new StreamManager(historyService, undefined, undefined, (event) => { - input.events?.push(event); - }); + const streamManager = new StreamManager( + historyService, + undefined, + undefined, + input.sink ?? + ((event) => { + input.events?.push(event); + }) + ); stubTokenTracker(streamManager); - Reflect.set(streamManager, "createStreamResult", () => - createStreamResultForTests(input.fullStream) + Reflect.set( + streamManager, + "createStreamResult", + input.createStreamResult ?? (() => createStreamResultForTests(input.fullStream)) ); await appendPartialAssistantForTests(input.workspaceId, input.messageId, 1); @@ -2044,41 +2064,15 @@ describe("StreamManager - turn completion", () => { const abortDelivery = new Promise((resolve) => { releaseAbortDelivery = resolve; }); - const streamManager = new StreamManager(historyService, undefined, undefined, (event) => - event.type === "stream-abort" ? abortDelivery : undefined - ); - stubTokenTracker(streamManager); - Reflect.set( - streamManager, - "createStreamResult", - (_request: unknown, abortController: AbortController) => - createStreamResultForTests( - (async function* () { - await new Promise((resolve) => { - abortController.signal.addEventListener("abort", () => resolve(), { once: true }); - }); - yield* []; - })() - ) - ); - await appendPartialAssistantForTests( - "completion-abort-workspace", - "completion-abort-message", - 1 - ); - const result = await streamManager.startStream( - testStartOptions({ - workspaceId: "completion-abort-workspace", - messageId: "completion-abort-message", - model: createTestLanguageModel(), - providedRuntimeTempDir: "", - }) - ); - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected stream to start"); + const { streamManager, handle } = await startWithStreamResult({ + workspaceId: "completion-abort-workspace", + messageId: "completion-abort-message", + createStreamResult: hangUntilAbort, + sink: (event) => (event.type === "stream-abort" ? abortDelivery : undefined), + }); let settled = false; - void result.data.completion.then(() => { + void handle.completion.then(() => { settled = true; }); await streamManager.stopStream("completion-abort-workspace", { abortReason: "user" }); @@ -2086,7 +2080,7 @@ describe("StreamManager - turn completion", () => { expect(settled).toBe(false); releaseAbortDelivery(); - expect(await result.data.completion).toEqual({ + expect(await handle.completion).toEqual({ status: "aborted", messageId: "completion-abort-message", abortReason: "user", @@ -2094,36 +2088,11 @@ describe("StreamManager - turn completion", () => { }); test("debug-injected stream errors settle a failed completion", async () => { - const streamManager = new StreamManager(historyService); - stubTokenTracker(streamManager); - Reflect.set( - streamManager, - "createStreamResult", - (_request: unknown, abortController: AbortController) => - createStreamResultForTests( - (async function* () { - await new Promise((resolve) => { - abortController.signal.addEventListener("abort", () => resolve(), { once: true }); - }); - yield* []; - })() - ) - ); - await appendPartialAssistantForTests( - "completion-debug-error-workspace", - "completion-debug-error-message", - 1 - ); - const result = await streamManager.startStream( - testStartOptions({ - workspaceId: "completion-debug-error-workspace", - messageId: "completion-debug-error-message", - model: createTestLanguageModel(), - providedRuntimeTempDir: "", - }) - ); - expect(result.success).toBe(true); - if (!result.success) throw new Error("Expected stream to start"); + const { streamManager, handle } = await startWithStreamResult({ + workspaceId: "completion-debug-error-workspace", + messageId: "completion-debug-error-message", + createStreamResult: hangUntilAbort, + }); const triggered = await streamManager.debugTriggerStreamError( "completion-debug-error-workspace", @@ -2131,10 +2100,10 @@ describe("StreamManager - turn completion", () => { ); expect(triggered).toBe(true); - const completion = await result.data.completion; - expect(completion.status).toBe("failed"); - if (completion.status !== "failed") throw new Error("Expected failed completion"); - expect(completion.streamError.error).toBe("debug injected failure"); + expect(await handle.completion).toMatchObject({ + status: "failed", + streamError: { error: "debug injected failure" }, + }); }); }); @@ -2213,88 +2182,6 @@ describe("StreamManager - Concurrent Stream Prevention", () => { // Suppress error events from bubbling up as uncaught exceptions during tests }); - // Integration test - requires API key and TEST_INTEGRATION=1 - describeIntegration("with real API", () => { - test("should prevent concurrent streams for the same workspace", async () => { - const workspaceId = "test-workspace-concurrent"; - const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY }); - const model = anthropic("claude-sonnet-4-5"); - - // Track when streams are actively processing - const streamStates: Record = {}; - let firstMessageId: string | undefined; - - onTurnEngineEvent( - streamManager, - "stream-start", - (data: { messageId: string; historySequence: number }) => { - streamStates[data.messageId] = { started: true, finished: false }; - if (data.historySequence === 1) { - firstMessageId = data.messageId; - } - } - ); - - onTurnEngineEvent(streamManager, "stream-end", (data: { messageId: string }) => { - if (streamStates[data.messageId]) { - streamStates[data.messageId].finished = true; - } - }); - - onTurnEngineEvent(streamManager, "stream-abort", (data: { messageId: string }) => { - if (streamStates[data.messageId]) { - streamStates[data.messageId].finished = true; - } - }); - - // Start first stream - const result1 = await streamManager.startStream({ - workspaceId, - messages: [{ role: "user", content: "Say hello and nothing else" }], - model, - modelString: KNOWN_MODELS.SONNET.id, - historySequence: 1, - system: "You are a helpful assistant", - runtime, - messageId: "test-msg-1", - tools: {}, - }); - - expect(result1.success).toBe(true); - - // Wait for first stream to actually start - await new Promise((resolve) => setTimeout(resolve, 200)); - - // Start second stream - should cancel first - const result2 = await streamManager.startStream({ - workspaceId, - messages: [{ role: "user", content: "Say goodbye and nothing else" }], - model, - modelString: KNOWN_MODELS.SONNET.id, - historySequence: 2, - system: "You are a helpful assistant", - runtime, - messageId: "test-msg-2", - tools: {}, - }); - - expect(result2.success).toBe(true); - - // Wait for second stream to complete - await new Promise((resolve) => setTimeout(resolve, 5000)); - - // Verify: first stream should have been cancelled before second stream started - expect(firstMessageId).toBeDefined(); - const trackedFirstMessageId = firstMessageId!; - expect(streamStates[trackedFirstMessageId]).toBeDefined(); - expect(streamStates[trackedFirstMessageId].started).toBe(true); - expect(streamStates[trackedFirstMessageId].finished).toBe(true); - - // Verify no streams are active after completion - expect(streamManager.isStreaming(workspaceId)).toBe(false); - }, 10000); - }); - // Unit test - doesn't require API key test("should serialize multiple rapid startStream calls", async () => { // This is a simpler test that doesn't require API key @@ -2467,61 +2354,26 @@ describe("StreamManager - Concurrent Stream Prevention", () => { tempDirStartedResolve = resolve; }); - const replaceTempDirResult = Reflect.set( - streamManager, - "createTempDirForStream", - (_streamToken: string, _runtime: unknown): Promise => { - tempDirStartedResolve?.(); - return new Promise((resolve) => { - abortController.signal.addEventListener("abort", () => resolve("/tmp/mock-stream-temp"), { - once: true, - }); - }); - } - ); - - if (!replaceTempDirResult) { - throw new Error("Failed to mock StreamManager.createTempDirForStream"); - } - let cleanupCalled = false; - const replaceCleanupResult = Reflect.set( - streamManager, - "cleanupStreamTempDir", - (..._args: unknown[]): void => { - cleanupCalled = true; - } - ); - - if (!replaceCleanupResult) { - throw new Error("Failed to mock StreamManager.cleanupStreamTempDir"); - } - - const replaceCreateResult = Reflect.set( - streamManager, - "createStreamAtomically", - (..._args: unknown[]): never => { - createCalled = true; - throw new Error("createStreamAtomically should not be called"); - } - ); - - if (!replaceCreateResult) { - throw new Error("Failed to mock StreamManager.createStreamAtomically"); - } - - const replaceProcessResult = Reflect.set( - streamManager, - "processStreamWithCleanup", - (..._args: unknown[]): Promise => { - processCalled = true; - return Promise.resolve(); - } - ); - - if (!replaceProcessResult) { - throw new Error("Failed to mock StreamManager.processStreamWithCleanup"); - } + Reflect.set(streamManager, "createTempDirForStream", (): Promise => { + tempDirStartedResolve?.(); + return new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve("/tmp/mock-stream-temp"), { + once: true, + }); + }); + }); + Reflect.set(streamManager, "cleanupStreamTempDir", (): void => { + cleanupCalled = true; + }); + Reflect.set(streamManager, "createStreamAtomically", (): never => { + createCalled = true; + throw new Error("createStreamAtomically should not be called"); + }); + Reflect.set(streamManager, "processStreamWithCleanup", (): Promise => { + processCalled = true; + return Promise.resolve(); + }); const anthropic = createAnthropic({ apiKey: "dummy-key" }); const model = anthropic("claude-sonnet-4-5"); From 953b557be0f275eee60be703ce60cca0c04d0b5c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:05:45 +0000 Subject: [PATCH 35/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20lean?= =?UTF-8?q?=20on=20harness=20default=20stream=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.autoCompaction.test.ts | 56 +++---------------- ...ntSession.workspaceTurnInheritance.test.ts | 12 ---- 2 files changed, 7 insertions(+), 61 deletions(-) diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 0e08d219e4d..cf7546048a4 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -184,13 +184,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("does not materialize skill snapshots (or run their directives) on deferred on-send compaction turns", async () => { const workspaceId = "ws-auto-compaction-skill-snapshot-deferral"; - const streamMessage = mock((_history: MuxMessage[]) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); - const { session } = await createSessionHarness({ - workspaceId, - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }); + const { session } = await createSessionHarness({ workspaceId }); const internals = session as unknown as { materializeAgentSkillSnapshots: (...args: unknown[]) => Promise; @@ -238,12 +232,8 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { workspaceId: string; experiments?: SendMessageOptions["experiments"]; }) => { - const streamMessage = mock((_history: MuxMessage[]) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); const { session, historyService } = await createSessionHarness({ workspaceId: args.workspaceId, - streamMessage: streamMessage as unknown as AIService["streamMessage"], }); // Seed a prior turn so the keep-recent selector has a safe user boundary @@ -568,13 +558,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction model inherit uses caller-provided baseOptions.model when no preferred model configured", async () => { const workspaceId = "ws-auto-compaction-inherit-base-options-model"; - const streamMessage = mock((_request: unknown) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); - const { session } = await createSessionHarness({ - workspaceId, - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }); + const { session } = await createSessionHarness({ workspaceId }); const inheritedModel = "anthropic:claude-sonnet-4-6"; const baseOptions: SendMessageOptions = { @@ -619,13 +603,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("clears strictAgentResolution on the internal compact request", async () => { const workspaceId = "ws-auto-compaction-clears-strict"; - const streamMessage = mock((_request: unknown) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); - const { session } = await createSessionHarness({ - workspaceId, - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }); + const { session } = await createSessionHarness({ workspaceId }); // A strict explicit-agent workspace turn hitting auto-compaction: the internal // request intentionally runs the hidden compact agent, so the strict gate must not @@ -664,9 +642,6 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction model explicit override takes priority over baseOptions.model", async () => { const workspaceId = "ws-auto-compaction-explicit-model-overrides-base-model"; - const streamMessage = mock((_request: unknown) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); const compactionModel = "openai:gpt-5.5"; const config = { srcDir: "/tmp", @@ -675,11 +650,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { agentAiDefaults: { compact: { modelString: compactionModel } }, }), } as unknown as Config; - const { session } = await createSessionHarness({ - workspaceId, - config, - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }); + const { session } = await createSessionHarness({ workspaceId, config }); const baseOptions: SendMessageOptions = { model: "anthropic:claude-opus-4-6", @@ -723,9 +694,6 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction thinking level prefers compact agent default over baseOptions", async () => { const workspaceId = "ws-auto-compaction-compact-thinking-default"; - const streamMessage = mock((_request: unknown) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); const config = { srcDir: "/tmp", getSessionDir: (_workspaceId: string) => "/tmp", @@ -735,11 +703,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }, }), } as unknown as Config; - const { session } = await createSessionHarness({ - workspaceId, - config, - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }); + const { session } = await createSessionHarness({ workspaceId, config }); const baseOptions: SendMessageOptions = { model: "anthropic:claude-opus-4-6", @@ -778,13 +742,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("compaction thinking level falls back to baseOptions when compact default is unset", async () => { const workspaceId = "ws-auto-compaction-base-thinking-fallback"; - const streamMessage = mock((_request: unknown) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); - const { session } = await createSessionHarness({ - workspaceId, - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }); + const { session } = await createSessionHarness({ workspaceId }); const baseOptions: SendMessageOptions = { model: "anthropic:claude-opus-4-6", @@ -987,7 +945,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { ); const aiService = Object.assign(aiEmitter, { isStreaming: mock((_workspaceId: string) => false), - stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(createStartedTurnHandle()))), + stopStream: mock((_workspaceId: string) => Promise.resolve(Ok(undefined))), streamMessage: streamMessage as unknown as ( ...args: Parameters ) => Promise, diff --git a/src/node/services/agentSession.workspaceTurnInheritance.test.ts b/src/node/services/agentSession.workspaceTurnInheritance.test.ts index 8a3ca3e925b..707a2a3242b 100644 --- a/src/node/services/agentSession.workspaceTurnInheritance.test.ts +++ b/src/node/services/agentSession.workspaceTurnInheritance.test.ts @@ -186,14 +186,8 @@ describe("AgentSession workspace-turn correlation inheritance", () => { test("workspace-turn correlation persists in startup retry options", async () => { const workspaceId = "workspace-turn-retry-metadata"; - const streamMessage = mock((_opts: StreamMessageOptions) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, - aiServiceOverrides: { - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }, }); try { const result = await session.sendMessage( @@ -228,14 +222,8 @@ describe("AgentSession workspace-turn correlation inheritance", () => { test("on-send compaction consuming a wake stamps the correlation on the follow-up", async () => { const workspaceId = "workspace-turn-compaction-stamp"; - const streamMessage = mock((_opts: StreamMessageOptions) => - Promise.resolve(Ok(createStartedTurnHandle())) - ); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, - aiServiceOverrides: { - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }, }); try { await historyService.appendToHistory(workspaceId, turnPrompt("delegated-prompt")); From ea4de8a86e356613d29831cbc9c6ada20d210d88 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:12:16 +0000 Subject: [PATCH 36/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20col?= =?UTF-8?q?lect=20pre-start=20errors=20per=20call=20instead=20of=20a=20ses?= =?UTF-8?q?sion=20capture=20window?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.preStreamError.test.ts | 16 ++- src/node/services/agentSession.ts | 116 +++++++----------- src/node/services/aiService.ts | 27 ++-- src/node/services/streamManager.test.ts | 2 +- 4 files changed, 72 insertions(+), 89 deletions(-) diff --git a/src/node/services/agentSession.preStreamError.test.ts b/src/node/services/agentSession.preStreamError.test.ts index 10234c69ae8..621cfe7b1c5 100644 --- a/src/node/services/agentSession.preStreamError.test.ts +++ b/src/node/services/agentSession.preStreamError.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, mock, afterEach } from "bun:test"; import { EventEmitter } from "events"; import { PROVIDER_DISPLAY_NAMES } from "@/common/constants/providers"; -import type { AIService } from "@/node/services/aiService"; +import type { AIService, StreamMessageOptions } from "@/node/services/aiService"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { InitStateManager } from "@/node/services/initStateManager"; import type { SendMessageError } from "@/common/types/errors"; @@ -1124,15 +1124,19 @@ describe("AgentSession pre-stream errors", () => { const preStartMessageId = "assistant-prestart-error"; // Mirrors AIService's runtime-readiness failure: the error event fires for - // fire-and-forget senders, then streamMessage returns Err with no handle. + // fire-and-forget senders (and the caller's onPreStartError collector), + // then streamMessage returns Err with no handle. const aiEmitter = new EventEmitter(); - const streamMessage = mock((_history: MuxMessage[]) => { - aiEmitter.emit("error", { + const streamMessage = mock((opts: StreamMessageOptions) => { + const errorEvent = { + type: "error" as const, workspaceId, messageId: preStartMessageId, error: "Runtime unavailable.", - errorType: "runtime_not_ready", - }); + errorType: "runtime_not_ready" as const, + }; + aiEmitter.emit("error", errorEvent); + opts.onPreStartError?.(errorEvent); return Promise.resolve(Err({ type: "runtime_not_ready", message: "Runtime unavailable." })); }); diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 5051e2e7a20..ce0c46f1833 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1155,14 +1155,6 @@ export class AgentSession { /** Turn handle whose completion consumeTurnCompletion() is currently observing. */ private activeTurnStreamHandle: TurnStreamHandle | null = null; - /** - * Error events observed while this session's streamMessage call is in - * flight and no turn handle owns them (pre-start failures such as runtime - * readiness). No completion will ever deliver them, so the Err path handles - * each exactly once. Null outside the in-flight window. - */ - private preStartErrorCapture: StreamErrorPayload[] | null = null; - private beginStreamErrorRecoveryDecision(messageId: string): void { // Duplicate error events for the same attempt share one decision. if (this.streamErrorRecoveryDecisions.has(messageId)) { @@ -4871,7 +4863,7 @@ export class AgentSession { return { success: false, error, failureHandled: true }; } - /** Handle captured handle-less error events exactly once each (see preStartErrorCapture). */ + /** Handle collected pre-start error events exactly once each. */ private async handleCapturedStreamErrors( captured: StreamErrorPayload[] | null | undefined, acpPromptId?: string @@ -5128,70 +5120,59 @@ export class AgentSession { normalizeDelegatedToolNames(options?.delegatedToolNames) ?? extractAcpDelegatedTools(optionsMuxMetadata); - // Capture pre-start error events emitted during this call (see - // preStartErrorCapture); the window closes before the result is handled. - this.preStartErrorCapture = []; - let capturedPreStartErrors: StreamErrorPayload[] | null = null; - let streamResult: Awaited>; - try { - streamResult = await this.aiService.streamMessage({ - messages: requestMessages, - workspaceId: this.workspaceId, - modelString, - abortSignal, - thinkingLevel: effectiveThinkingLevel, - // Orthogonal to thinking level; buildRequestHeaders gates it per model. - reasoningMode: options?.reasoningMode, - toolPolicy: options?.toolPolicy, - additionalSystemContext: options?.additionalSystemContext, - additionalSystemInstructions: options?.additionalSystemInstructions, - maxOutputTokens: options?.maxOutputTokens, - muxProviderOptions: options?.providerOptions, - agentInitiated, - agentId: options?.agentId, - acpPromptId, - delegatedToolNames, - muxMetadata: streamMuxMetadata, - recordFileState, - postCompactionAttachments, - // Invoked by AIService after runtime.ensureReady() (project-scope - // listing needs a running runtime). Still ordered after the - // post-compaction check above: a just-consumed compaction boundary has - // already reset the segment cache, so this stream recomputes the context. - resolveMemoryContext: (forModelString, memoryOptions) => - this.resolveMemoryContext(forModelString, memoryOptions), - allowAgentSetGoal: options?.allowAgentSetGoal === true, - workspaceGoalService: this.workspaceGoalService, - experiments: options?.experiments, - disableWorkspaceAgents: options?.disableWorkspaceAgents, - strictAgentResolution: options?.strictAgentResolution, - hasQueuedMessages: this.hasQueuedMessages.bind(this), - openaiTruncationModeOverride, - // Mid-turn thinking overrides clamp against the same floor as the - // send-time level above (single source of truth for the floor). - minThinkingLevel, - activeTurnThinkingOverride, - }); - } finally { - capturedPreStartErrors = this.preStartErrorCapture; - this.preStartErrorCapture = null; - } + // Fatal pre-start failures (runtime readiness, strict agent resolution) + // emit an error event for fire-and-forget senders and then return Err; + // collect them so the Err path resolves each exactly once. + const preStartErrors: StreamErrorPayload[] = []; + const streamResult = await this.aiService.streamMessage({ + messages: requestMessages, + workspaceId: this.workspaceId, + modelString, + abortSignal, + thinkingLevel: effectiveThinkingLevel, + // Orthogonal to thinking level; buildRequestHeaders gates it per model. + reasoningMode: options?.reasoningMode, + toolPolicy: options?.toolPolicy, + additionalSystemContext: options?.additionalSystemContext, + additionalSystemInstructions: options?.additionalSystemInstructions, + maxOutputTokens: options?.maxOutputTokens, + muxProviderOptions: options?.providerOptions, + agentInitiated, + agentId: options?.agentId, + acpPromptId, + delegatedToolNames, + muxMetadata: streamMuxMetadata, + recordFileState, + postCompactionAttachments, + // Invoked by AIService after runtime.ensureReady() (project-scope + // listing needs a running runtime). Still ordered after the + // post-compaction check above: a just-consumed compaction boundary has + // already reset the segment cache, so this stream recomputes the context. + resolveMemoryContext: (forModelString, memoryOptions) => + this.resolveMemoryContext(forModelString, memoryOptions), + allowAgentSetGoal: options?.allowAgentSetGoal === true, + workspaceGoalService: this.workspaceGoalService, + experiments: options?.experiments, + disableWorkspaceAgents: options?.disableWorkspaceAgents, + strictAgentResolution: options?.strictAgentResolution, + hasQueuedMessages: this.hasQueuedMessages.bind(this), + openaiTruncationModeOverride, + // Mid-turn thinking overrides clamp against the same floor as the + // send-time level above (single source of truth for the floor). + minThinkingLevel, + activeTurnThinkingOverride, + onPreStartError: ({ workspaceId: _workspaceId, ...payload }) => preStartErrors.push(payload), + }); if (!streamResult.success) { return await this.handleStreamWithHistoryFailure( streamResult.error, acpPromptId, - capturedPreStartErrors + preStartErrors ); } this.consumeTurnCompletion(streamResult.data); - // Drain mock playback's synthetic error events (emitted under their own - // message IDs); events owned by the returned handle stay with completion. - await this.handleCapturedStreamErrors( - capturedPreStartErrors?.filter((event) => event.messageId !== streamResult.data.messageId), - acpPromptId - ); return Ok(undefined); } @@ -6190,13 +6171,6 @@ export class AgentSession { // Begin synchronously at event emission so completion waiters always find // this attempt's decision before they run. this.beginStreamErrorRecoveryDecision(data.messageId); - if ( - this.preStartErrorCapture != null && - this.activeTurnStreamHandle?.messageId !== data.messageId - ) { - const { workspaceId: _workspaceId, ...payload } = data; - this.preStartErrorCapture.push(payload); - } }; this.aiListeners.push({ event: "error", handler: errorHandler }); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 4c4b879f36f..3cc9753719c 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -164,7 +164,7 @@ import type { RebuildProviderOptionsForThinkingLevel, } from "@/node/services/thinkingOverride"; -import type { StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; +import type { ErrorEvent, StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import { computeActiveToolNames, @@ -296,6 +296,8 @@ export interface StreamMessageOptions { strictAgentResolution?: SendMessageOptions["strictAgentResolution"]; /** ACP prompt correlation id used to match stream events to a specific request. */ acpPromptId?: string; + /** Invoked with each fatal pre-start error event this call emits before returning Err. */ + onPreStartError?: (event: ErrorEvent) => void; /** Tool names that should be delegated back to ACP clients for this request. */ delegatedToolNames?: string[]; recordFileState?: (filePath: string, state: FileState) => Promise; @@ -1317,6 +1319,7 @@ export class AIService extends EventEmitter { agentId, strictAgentResolution, acpPromptId, + onPreStartError, delegatedToolNames, recordFileState, postCompactionAttachments, @@ -1787,15 +1790,14 @@ export class AIService extends EventEmitter { // This mirrors the context_exceeded pattern - the fire-and-forget sendMessage // call in useCreationWorkspace.ts won't see the returned Err, but will receive // this event through the workspace chat subscription. - this.emit( - "error", - createErrorEvent(workspaceId, { - messageId: errorMessageId, - error: errorMessage, - errorType, - acpPromptId, - }) - ); + const errorEvent = createErrorEvent(workspaceId, { + messageId: errorMessageId, + error: errorMessage, + errorType, + acpPromptId, + }); + this.emit("error", errorEvent); + onPreStartError?.(errorEvent); logSlowStreamStartup?.({ outcome: "runtime_not_ready", @@ -1870,7 +1872,10 @@ export class AIService extends EventEmitter { disableWorkspaceAgents: disableWorkspaceAgents ?? false, callerToolPolicy: toolPolicy, cfg, - emitError: (event) => this.emit("error", event), + emitError: (event) => { + this.emit("error", event); + onPreStartError?.(event); + }, isAdvisorExperimentEnabled: advisorExperimentEnabled, includeAgentPlugins: agentPluginsExperimentEnabled, }); diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index dd8a2879800..b78173feb2d 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -1963,7 +1963,7 @@ describe("StreamManager - turn completion", () => { Reflect.set( streamManager, "createStreamResult", - input.createStreamResult ?? (() => createStreamResultForTests(input.fullStream)) + input.createStreamResult ?? (() => createStreamResultForTests(input.fullStream!)) ); await appendPartialAssistantForTests(input.workspaceId, input.messageId, 1); From c72c9da3305f66e06aae146fb223dda11c0abcae Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:28:25 +0000 Subject: [PATCH 37/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20tur?= =?UTF-8?q?n=20identity=20lives=20only=20on=20the=20handle?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/agentSession.disposeRace.test.ts | 1 - src/node/services/agentSession.testHarness.ts | 7 +-- src/node/services/agentSession.ts | 46 +++++---------- src/node/services/aiService.test.ts | 56 +++++++++---------- src/node/services/aiService.ts | 16 ++---- .../services/mock/mockAiStreamPlayer.test.ts | 10 +--- src/node/services/mock/mockAiStreamPlayer.ts | 9 +-- src/node/services/streamManager.test.ts | 44 +++++---------- src/node/services/streamManager.ts | 42 ++++---------- 9 files changed, 77 insertions(+), 154 deletions(-) diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index a0840ec8559..a04aa50205b 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -539,7 +539,6 @@ describe("AgentSession disposal race conditions", () => { session.dispose(); settleCompletion({ status: "failed", - messageId: "assistant-post-dispose", streamError: { messageId: "assistant-post-dispose", error: "boom", errorType: "api" }, }); await new Promise((resolve) => setTimeout(resolve, 0)); diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 238b28935e3..6bdff8ef9a7 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -27,8 +27,7 @@ export function createFailedTurnHandle( return { messageId, completion: Promise.resolve({ - status: "failed", - messageId, + status: "failed" as const, streamError: { messageId, ...failure }, }), }; @@ -61,8 +60,6 @@ function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partia aiService: AIService; } { const aiEmitter = args?.emitter ?? new EventEmitter(); - const overrides = args?.overrides ?? {}; - return { aiEmitter, aiService: Object.assign(aiEmitter, { @@ -72,7 +69,7 @@ function createMockAiService(args?: { emitter?: EventEmitter; overrides?: Partia streamMessage: mock(() => Promise.resolve(Ok(createStartedTurnHandle("test-assistant-message"))) ) as unknown as AIService["streamMessage"], - ...overrides, + ...args?.overrides, }) as unknown as AIService, }; } diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index ce0c46f1833..55492df977b 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1152,9 +1152,6 @@ export class AgentSession { decision.resolve(handled); } - /** Turn handle whose completion consumeTurnCompletion() is currently observing. */ - private activeTurnStreamHandle: TurnStreamHandle | null = null; - private beginStreamErrorRecoveryDecision(messageId: string): void { // Duplicate error events for the same attempt share one decision. if (this.streamErrorRecoveryDecisions.has(messageId)) { @@ -4839,9 +4836,19 @@ export class AgentSession { acpPromptId?: string, preStartErrors?: StreamErrorPayload[] | null ): Promise> { - // Captured pre-start error events own this failure; the branches below + // Collected pre-start error events own this failure; the branches below // only cover failures that produced no error event. - if (await this.handleCapturedStreamErrors(preStartErrors, acpPromptId)) { + if (preStartErrors != null && preStartErrors.length > 0) { + for (const payload of preStartErrors) { + try { + await this.handleStreamError({ + ...payload, + acpPromptId: payload.acpPromptId ?? acpPromptId, + }); + } finally { + this.resolveStreamErrorRecoveryDecision(payload.messageId, "terminal"); + } + } return { success: false, error, failureHandled: true }; } @@ -4863,29 +4870,7 @@ export class AgentSession { return { success: false, error, failureHandled: true }; } - /** Handle collected pre-start error events exactly once each. */ - private async handleCapturedStreamErrors( - captured: StreamErrorPayload[] | null | undefined, - acpPromptId?: string - ): Promise { - if (captured == null || captured.length === 0) { - return false; - } - for (const payload of captured) { - try { - await this.handleStreamError({ - ...payload, - acpPromptId: payload.acpPromptId ?? acpPromptId, - }); - } finally { - this.resolveStreamErrorRecoveryDecision(payload.messageId, "terminal"); - } - } - return true; - } - private consumeTurnCompletion(handle: TurnStreamHandle): void { - this.activeTurnStreamHandle = handle; void handle.completion .then(async (outcome) => { // A disposed session must not persist retry/goal state post-teardown. @@ -4894,7 +4879,7 @@ export class AgentSession { try { await this.handleStreamError(outcome.streamError); } finally { - this.resolveStreamErrorRecoveryDecision(outcome.messageId, "terminal"); + this.resolveStreamErrorRecoveryDecision(outcome.streamError.messageId, "terminal"); } }) .catch((error: unknown) => { @@ -4902,11 +4887,6 @@ export class AgentSession { workspaceId: this.workspaceId, error: getErrorMessage(error), }); - }) - .finally(() => { - if (this.activeTurnStreamHandle === handle) { - this.activeTurnStreamHandle = null; - } }); } diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index eb4e9192b07..9a5ffe4cb04 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -691,33 +691,9 @@ describe("AIService turn engine events", () => { expect(internals.pendingDevToolsRunMetadataByMessageId.has(abortEvent.messageId)).toBe(false); }); - it("forwards stream-abort with empty messageId without throwing", async () => { - using harness = createForwardingHarness("ai-service-stream-abort-empty-message-id"); - const { service, internals, clearPendingRunMetadataSpy } = harness; - internals.pendingDevToolsRunMetadataByMessageId.set("message-1", { - workspaceId: "workspace-1", - metadataId: "metadata-1", - }); - const abortEvent: StreamAbortEvent = { - type: "stream-abort", - workspaceId: "workspace-1", - messageId: "", - abandonPartial: true, - }; - - const forwardedAbortPromise = new Promise((resolve) => { - service.once("stream-abort", (event) => resolve(event as StreamAbortEvent)); - }); - await internals.emitEngineEvent(abortEvent); - - expect(await forwardedAbortPromise).toEqual(abortEvent); - expect(clearPendingRunMetadataSpy).not.toHaveBeenCalled(); - expect(internals.pendingDevToolsRunMetadataByMessageId.has("message-1")).toBe(true); - }); - it.each([ { - name: "stream error", + name: "stream error clears tracked metadata", eventName: "error" as const, event: { type: "error" as const, @@ -726,9 +702,10 @@ describe("AIService turn engine events", () => { error: "request failed", errorType: "rate_limit" as const, } satisfies ErrorEvent, + expectCleared: true, }, { - name: "stream-end", + name: "stream-end clears tracked metadata", eventName: "stream-end" as const, event: { type: "stream-end" as const, @@ -737,11 +714,24 @@ describe("AIService turn engine events", () => { metadata: { model: "anthropic:claude-opus-4-1" }, parts: [], } satisfies StreamEndEvent, + expectCleared: true, }, - ])("clears tracked devtools run metadata on $name", async ({ eventName, event }) => { + { + name: "stream-abort with empty messageId leaves unrelated metadata", + eventName: "stream-abort" as const, + event: { + type: "stream-abort" as const, + workspaceId: "workspace-1", + messageId: "", + abandonPartial: true, + } satisfies StreamAbortEvent, + expectCleared: false, + }, + ])("devtools run metadata: $name", async ({ eventName, event, expectCleared }) => { using harness = createForwardingHarness(`ai-service-${eventName}-devtools-cleanup`); const { service, internals, clearPendingRunMetadataSpy } = harness; - internals.pendingDevToolsRunMetadataByMessageId.set(event.messageId, { + const trackedMessageId = event.messageId || "message-1"; + internals.pendingDevToolsRunMetadataByMessageId.set(trackedMessageId, { workspaceId: event.workspaceId, metadataId: "metadata-1", }); @@ -752,8 +742,14 @@ describe("AIService turn engine events", () => { await internals.emitEngineEvent(event); expect(await forwardedPromise).toEqual(event); - expect(clearPendingRunMetadataSpy).toHaveBeenCalledWith(event.workspaceId, "metadata-1"); - expect(internals.pendingDevToolsRunMetadataByMessageId.has(event.messageId)).toBe(false); + if (expectCleared) { + expect(clearPendingRunMetadataSpy).toHaveBeenCalledWith(event.workspaceId, "metadata-1"); + } else { + expect(clearPendingRunMetadataSpy).not.toHaveBeenCalled(); + } + expect(internals.pendingDevToolsRunMetadataByMessageId.has(trackedMessageId)).toBe( + !expectCleared + ); }); }); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 3cc9753719c..e287a0f98fc 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -850,12 +850,12 @@ export class AIService extends EventEmitter { this.emit(event.type, event); } - private createSettledTurnHandle(completion: TurnCompletion): TurnStreamHandle { - return { messageId: completion.messageId, completion: Promise.resolve(completion) }; + private createSettledTurnHandle(messageId: string, completion: TurnCompletion): TurnStreamHandle { + return { messageId, completion: Promise.resolve(completion) }; } private createAbortedTurnHandle(messageId: string): TurnStreamHandle { - return this.createSettledTurnHandle({ status: "aborted", messageId, abortReason: "startup" }); + return this.createSettledTurnHandle(messageId, { status: "aborted", abortReason: "startup" }); } private trackPendingDevToolsRunMetadata( @@ -3056,17 +3056,11 @@ export class AIService extends EventEmitter { if (forceContextLimitError) { const streamError = await simulateContextLimitError(simulationCtx, this.historyService); return Ok( - this.createSettledTurnHandle({ - status: "failed", - messageId: assistantMessageId, - streamError, - }) + this.createSettledTurnHandle(assistantMessageId, { status: "failed", streamError }) ); } await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); - return Ok( - this.createSettledTurnHandle({ status: "completed", messageId: assistantMessageId }) - ); + return Ok(this.createSettledTurnHandle(assistantMessageId, { status: "completed" })); } // Build provider options based on thinking level and request-sliced message history. diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index a9722c9e058..556fb7b768b 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -101,10 +101,7 @@ describe("MockAiStreamPlayer", () => { ); expect(secondResult.success).toBe(true); if (!secondResult.success || !secondResult.data) throw new Error("expected a stream handle"); - await expect(secondResult.data.completion).resolves.toMatchObject({ - status: "failed", - messageId: secondResult.data.messageId, - }); + await expect(secondResult.data.completion).resolves.toMatchObject({ status: "failed" }); // Read back all messages and check the assistant placeholders const allResult = await historyService.getLastMessages(workspaceId, 100); @@ -692,10 +689,7 @@ describe("MockAiStreamPlayer", () => { await waitForCondition(() => !player.isStreaming(workspaceId), 2000); if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); - await expect(playResult.data.completion).resolves.toMatchObject({ - status: "completed", - messageId: playResult.data.messageId, - }); + await expect(playResult.data.completion).resolves.toMatchObject({ status: "completed" }); const partial = await historyService.readPartial(workspaceId); expect(partial).toBeNull(); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 083664527f9..59bb7d403b9 100644 --- a/src/node/services/mock/mockAiStreamPlayer.ts +++ b/src/node/services/mock/mockAiStreamPlayer.ts @@ -817,7 +817,6 @@ export class MockAiStreamPlayer { ); active.settleCompletion({ status: "failed", - messageId, streamError: { messageId, error: payload.error, errorType: payload.errorType }, }); this.cleanup(workspaceId); @@ -884,7 +883,7 @@ export class MockAiStreamPlayer { if (!this.isCurrentActiveStream(workspaceId, active)) return; this.deps.aiService.emit("stream-end", payload); - active.settleCompletion({ status: "completed", messageId }); + active.settleCompletion({ status: "completed" }); this.cleanup(workspaceId); break; } @@ -897,11 +896,7 @@ export class MockAiStreamPlayer { active.cancelled = true; // Settle-once backstop: terminal events settled above; cancels settle here. - active.settleCompletion({ - status: "aborted", - messageId: active.messageId, - abortReason: "user", - }); + active.settleCompletion({ status: "aborted", abortReason: "user" }); if (active.partialWriteTimer) { clearTimeout(active.partialWriteTimer); diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index b78173feb2d..db1a732d2b6 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -2005,11 +2005,7 @@ describe("StreamManager - turn completion", () => { ); expect(aborted.success).toBe(true); if (!aborted.success) throw new Error("Expected aborted startup handle"); - expect(await aborted.data.completion).toEqual({ - status: "aborted", - messageId: "prestart-abort-message", - abortReason: "startup", - }); + expect(await aborted.data.completion).toEqual({ status: "aborted", abortReason: "startup" }); }); test("completed and failed turns settle once after their terminal event", async () => { @@ -2028,10 +2024,7 @@ describe("StreamManager - turn completion", () => { void completed.handle.completion.then(() => { completedSettlements += 1; }); - expect(await completed.handle.completion).toEqual({ - status: "completed", - messageId: "completion-success-message", - }); + expect(await completed.handle.completion).toEqual({ status: "completed" }); await Promise.resolve(); expect(completedEvents.at(-1)?.type).toBe("stream-end"); expect(completedSettlements).toBe(1); @@ -2080,11 +2073,7 @@ describe("StreamManager - turn completion", () => { expect(settled).toBe(false); releaseAbortDelivery(); - expect(await handle.completion).toEqual({ - status: "aborted", - messageId: "completion-abort-message", - abortReason: "user", - }); + expect(await handle.completion).toEqual({ status: "aborted", abortReason: "user" }); }); test("debug-injected stream errors settle a failed completion", async () => { @@ -2375,27 +2364,24 @@ describe("StreamManager - Concurrent Stream Prevention", () => { return Promise.resolve(); }); - const anthropic = createAnthropic({ apiKey: "dummy-key" }); - const model = anthropic("claude-sonnet-4-5"); - - const startPromise = streamManager.startStream({ - workspaceId, - messages: [{ role: "user", content: "test" }], - model, - modelString: KNOWN_MODELS.SONNET.id, - historySequence: 1, - system: "system", - runtime, - messageId: "test-msg-abort", - abortSignal: abortController.signal, - tools: {}, - }); + const startPromise = streamManager.startStream( + testStartOptions({ + workspaceId, + messageId: "test-msg-abort", + model: createTestLanguageModel(), + runtime, + abortSignal: abortController.signal, + tools: {}, + }) + ); await tempDirStarted; abortController.abort(); const result = await startPromise; expect(result.success).toBe(true); + if (!result.success) throw new Error("Expected aborted startup handle"); + expect(await result.data.completion).toEqual({ status: "aborted", abortReason: "startup" }); expect(createCalled).toBe(false); expect(cleanupCalled).toBe(true); expect(processCalled).toBe(false); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index fab15989872..4d98597f9f7 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -204,14 +204,11 @@ export type TurnEngineEvent = export type TurnEngineEventSink = (event: TurnEngineEvent) => void | Promise; +// Turn identity lives on TurnStreamHandle.messageId; completions cannot diverge from it. export type TurnCompletion = - | { status: "completed"; messageId: string } - | { status: "aborted"; messageId: string; abortReason: StreamAbortReason } - | { - status: "failed"; - messageId: string; - streamError: StreamErrorPayload & { errorType: StreamErrorType }; - }; + | { status: "completed" } + | { status: "aborted"; abortReason: StreamAbortReason } + | { status: "failed"; streamError: StreamErrorPayload & { errorType: StreamErrorType } }; export interface TurnStreamHandle { messageId: string; @@ -1711,11 +1708,7 @@ export class StreamManager { // Clean up immediately this.workspaceStreams.delete(workspaceId); void abortDelivery.finally(() => { - streamInfo.completionController?.settle({ - status: "aborted", - messageId: streamInfo.messageId, - abortReason, - }); + streamInfo.completionController?.settle({ status: "aborted", abortReason }); }); } @@ -3809,10 +3802,7 @@ export class StreamManager { // before updateHistory completes, compaction can clear the file and then // updateHistory writes stale data back. this.emitTurnEvent(streamEndEvent); - streamInfo.terminalCompletion = { - status: "completed", - messageId: streamInfo.messageId, - }; + streamInfo.terminalCompletion = { status: "completed" }; } break; } catch (error) { @@ -3900,11 +3890,7 @@ export class StreamManager { const errorPayload = this.buildStreamErrorPayload(streamInfo, error); const persistedPayload = await this.persistStreamError(workspaceId, streamInfo, errorPayload); - streamInfo.terminalCompletion = { - status: "failed", - messageId: streamInfo.messageId, - streamError: persistedPayload, - }; + streamInfo.terminalCompletion = { status: "failed", streamError: persistedPayload }; } private buildStreamErrorPayload( @@ -4581,7 +4567,7 @@ export class StreamManager { // If the stream was interrupted while we were waiting on async setup (mutex, // temp dir creation, etc), avoid starting the stream entirely. if (streamAbortController.signal.aborted) { - completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + completionController.settle({ status: "aborted", abortReason: "startup" }); return Ok(handle); } @@ -4592,7 +4578,7 @@ export class StreamManager { providedRuntimeTempDir ?? (await this.createTempDirForStream(streamToken, runtime)); if (streamAbortController.signal.aborted) { - completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + completionController.settle({ status: "aborted", abortReason: "startup" }); return Ok(handle); } @@ -4611,7 +4597,7 @@ export class StreamManager { // In that case, immediately drop the registered stream and rely on the caller to handle UI. if (streamAbortController.signal.aborted) { this.workspaceStreams.delete(typedWorkspaceId); - completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + completionController.settle({ status: "aborted", abortReason: "startup" }); return Ok(handle); } @@ -4637,7 +4623,7 @@ export class StreamManager { this.workspaceStreams.delete(typedWorkspaceId); } streamRegistered = false; - completionController.settle({ status: "aborted", messageId, abortReason: "startup" }); + completionController.settle({ status: "aborted", abortReason: "startup" }); return Ok(handle); } @@ -5078,11 +5064,7 @@ export class StreamManager { }); // Debug-injected failures bypass handleStreamFailure, so record the failed // completion here or cleanup would never settle the turn handle. - streamInfo.terminalCompletion = { - status: "failed", - messageId: streamInfo.messageId, - streamError: persistedPayload, - }; + streamInfo.terminalCompletion = { status: "failed", streamError: persistedPayload }; // Wait for the stream processing to complete (cleanup) await streamInfo.processingPromise; From 8e3196bbe7ed2c4302071828e4a0faa0afffd35c Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:34:41 +0000 Subject: [PATCH 38/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20fold?= =?UTF-8?q?=20debug=20settlement=20into=20the=20completion=20suite,=20trim?= =?UTF-8?q?=20fixtures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/agentSession.disposeRace.test.ts | 13 ++----- .../services/mock/mockAiStreamPlayer.test.ts | 6 +-- src/node/services/streamManager.test.ts | 38 +++++++++---------- src/node/services/streamManager.ts | 2 - 4 files changed, 24 insertions(+), 35 deletions(-) diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index a04aa50205b..3d00ff1d2b6 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -508,16 +508,9 @@ describe("AgentSession disposal race conditions", () => { }); test("drops failed turn completions delivered after disposal", async () => { - let settleCompletion: (completion: TurnCompletion) => void = () => undefined; + const completion = createDeferred(); const streamMessage = mock((_opts: StreamMessageOptions) => - Promise.resolve( - Ok({ - messageId: "assistant-post-dispose", - completion: new Promise((resolve) => { - settleCompletion = resolve; - }), - }) - ) + Promise.resolve(Ok({ messageId: "assistant-post-dispose", completion: completion.promise })) ); const { session, cleanup } = await createAgentSessionHarness({ workspaceId: "ws-dispose-turn-completion", @@ -537,7 +530,7 @@ describe("AgentSession disposal race conditions", () => { }; const handleStreamErrorSpy = spyOn(errorSink, "handleStreamError"); session.dispose(); - settleCompletion({ + completion.resolve({ status: "failed", streamError: { messageId: "assistant-post-dispose", error: "boom", errorType: "api" }, }); diff --git a/src/node/services/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index 556fb7b768b..8417f0b1eb2 100644 --- a/src/node/services/mock/mockAiStreamPlayer.test.ts +++ b/src/node/services/mock/mockAiStreamPlayer.test.ts @@ -101,7 +101,7 @@ describe("MockAiStreamPlayer", () => { ); expect(secondResult.success).toBe(true); if (!secondResult.success || !secondResult.data) throw new Error("expected a stream handle"); - await expect(secondResult.data.completion).resolves.toMatchObject({ status: "failed" }); + expect(await secondResult.data.completion).toMatchObject({ status: "failed" }); // Read back all messages and check the assistant placeholders const allResult = await historyService.getLastMessages(workspaceId, 100); @@ -689,7 +689,7 @@ describe("MockAiStreamPlayer", () => { await waitForCondition(() => !player.isStreaming(workspaceId), 2000); if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); - await expect(playResult.data.completion).resolves.toMatchObject({ status: "completed" }); + expect(await playResult.data.completion).toMatchObject({ status: "completed" }); const partial = await historyService.readPartial(workspaceId); expect(partial).toBeNull(); @@ -807,6 +807,6 @@ describe("MockAiStreamPlayer", () => { expect(deltaCount).toBe(deltasAtStop); expect(abortCount).toBe(1); if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); - await expect(playResult.data.completion).resolves.toMatchObject({ status: "aborted" }); + expect(await playResult.data.completion).toMatchObject({ status: "aborted" }); }); }); diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index db1a732d2b6..43fc85f1302 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -2008,7 +2008,7 @@ describe("StreamManager - turn completion", () => { expect(await aborted.data.completion).toEqual({ status: "aborted", abortReason: "startup" }); }); - test("completed and failed turns settle once after their terminal event", async () => { + test("completed, failed, and debug-injected turns settle once after their terminal event", async () => { const completedEvents: TurnEngineEvent[] = []; const completed = await startWithStreamResult({ workspaceId: "completion-success-workspace", @@ -2050,6 +2050,23 @@ describe("StreamManager - turn completion", () => { await failed.streamManager.stopStream("completion-failure-workspace"); await Promise.resolve(); expect(failedSettlements).toBe(1); + + // Debug-injected failures reach the same terminal settlement path. + const debug = await startWithStreamResult({ + workspaceId: "completion-debug-error-workspace", + messageId: "completion-debug-error-message", + createStreamResult: hangUntilAbort, + }); + expect( + await debug.streamManager.debugTriggerStreamError( + "completion-debug-error-workspace", + "debug injected failure" + ) + ).toBe(true); + expect(await debug.handle.completion).toMatchObject({ + status: "failed", + streamError: { error: "debug injected failure" }, + }); }); test("aborted completion waits for asynchronous abort delivery", async () => { @@ -2075,25 +2092,6 @@ describe("StreamManager - turn completion", () => { releaseAbortDelivery(); expect(await handle.completion).toEqual({ status: "aborted", abortReason: "user" }); }); - - test("debug-injected stream errors settle a failed completion", async () => { - const { streamManager, handle } = await startWithStreamResult({ - workspaceId: "completion-debug-error-workspace", - messageId: "completion-debug-error-message", - createStreamResult: hangUntilAbort, - }); - - const triggered = await streamManager.debugTriggerStreamError( - "completion-debug-error-workspace", - "debug injected failure" - ); - expect(triggered).toBe(true); - - expect(await handle.completion).toMatchObject({ - status: "failed", - streamError: { error: "debug injected failure" }, - }); - }); }); describe("StreamManager - stripEncryptedContent", () => { diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 4d98597f9f7..6483fbb82a3 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -271,8 +271,6 @@ export interface TurnExecutionOptions { rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel; } -// Stream request config for start/retry - // Request-construction inputs shared by the primary turn (sourced from // TurnExecutionOptions) and model-fallback hops (sourced from the prepared // fallback). routeProvider is the backend-resolved route From 10268de1fc3814e1aa3fbc9447a257f4ebdac887 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:17:06 +0000 Subject: [PATCH 39/42] =?UTF-8?q?=F0=9F=A4=96=20tests(streaming):=20return?= =?UTF-8?q?=20turn=20handles=20from=20stale=20streamMessage=20mocks,=20ded?= =?UTF-8?q?upe=20session=20casts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.mcpPromptSnapshot.test.ts | 7 +++- .../agentSession.postCompactionRetry.test.ts | 34 +++++++--------- .../agentSession.queueDispatch.test.ts | 4 +- .../agentSession.startupAutoRetry.test.ts | 39 +++++++------------ src/node/services/workspaceService.test.ts | 6 ++- 5 files changed, 39 insertions(+), 51 deletions(-) diff --git a/src/node/services/agentSession.mcpPromptSnapshot.test.ts b/src/node/services/agentSession.mcpPromptSnapshot.test.ts index 0da729dd648..6dbae538c2b 100644 --- a/src/node/services/agentSession.mcpPromptSnapshot.test.ts +++ b/src/node/services/agentSession.mcpPromptSnapshot.test.ts @@ -3,7 +3,10 @@ import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { AIService } from "@/node/services/aiService"; import type { MCPServerManager } from "@/node/services/mcpServerManager"; -import { createAgentSessionHarness } from "@/node/services/agentSession.testHarness"; +import { + createAgentSessionHarness, + createStartedTurnHandle, +} from "@/node/services/agentSession.testHarness"; function promptMetadata() { return { @@ -229,7 +232,7 @@ describe("AgentSession MCP prompt snapshots", () => { test("excludes crash-orphaned snapshots from provider requests", async () => { const streamMessage = mock((_args: { messages: MuxMessage[] }) => - Promise.resolve(Ok(undefined)) + Promise.resolve(Ok(createStartedTurnHandle())) ); const harness = await createAgentSessionHarness({ workspaceId: "workspace", diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index c1f2e807a3e..f861e345d33 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -15,6 +15,16 @@ import type { SendMessageOptions } from "@/common/orpc/types"; import { createTestHistoryService } from "./testHistoryService"; import { createFailedTurnHandle, createStartedTurnHandle } from "./agentSession.testHarness"; +function contextExceededResult(messageId: string) { + return { + success: true as const, + data: createFailedTurnHandle(messageId, { + error: "Context length exceeded", + errorType: "context_exceeded", + }), + }; +} + function createPersistedPostCompactionState(options: { filePath: string; diffs: Array<{ path: string; diff: string; truncated: boolean }>; @@ -92,13 +102,7 @@ describe("AgentSession post-compaction context retry", () => { errorType: "context_exceeded", }); - return Promise.resolve({ - success: true as const, - data: createFailedTurnHandle("assistant-ctx-exceeded", { - error: "Context length exceeded", - errorType: "context_exceeded", - }), - }); + return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); } resolveSecondCall?.(); @@ -247,13 +251,7 @@ describe("AgentSession post-compaction context retry", () => { error: "Context length exceeded", errorType: "context_exceeded", }); - return { - success: true as const, - data: createFailedTurnHandle("assistant-ctx-exceeded", { - error: "Context length exceeded", - errorType: "context_exceeded", - }), - }; + return contextExceededResult("assistant-ctx-exceeded"); } // Retry startup in flight: hold it until the test releases, then fail // pre-stream (e.g. commitPartial / history read failure). @@ -388,13 +386,7 @@ describe("AgentSession post-compaction context retry", () => { error: "Context length exceeded", errorType: "context_exceeded", }); - return Promise.resolve({ - success: true as const, - data: createFailedTurnHandle("assistant-attempt-1", { - error: "Context length exceeded", - errorType: "context_exceeded", - }), - }); + return Promise.resolve(contextExceededResult("assistant-attempt-1")); } // The retry's startup succeeds, but the stream dies immediately with a // terminal error — emitted before the original retry path resumes. diff --git a/src/node/services/agentSession.queueDispatch.test.ts b/src/node/services/agentSession.queueDispatch.test.ts index 7ab7ccde171..a0396703a16 100644 --- a/src/node/services/agentSession.queueDispatch.test.ts +++ b/src/node/services/agentSession.queueDispatch.test.ts @@ -3,7 +3,7 @@ import { describe, expect, mock, spyOn, test } from "bun:test"; import type { MuxMessageMetadata } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import type { WorkspaceGoalService } from "./workspaceGoalService"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import type { AIService } from "./aiService"; const TEST_MODEL = "anthropic:claude-sonnet-4-5"; @@ -99,7 +99,7 @@ describe("AgentSession queued message tool-call dispatch", () => { turnId: "turn-different", }) === true, }; - return Promise.resolve(Ok(undefined)); + return Promise.resolve(Ok(createStartedTurnHandle())); }); const { session, cleanup } = await createAgentSessionHarness({ workspaceId: "queue-dispatch-preparing-predecessor", diff --git a/src/node/services/agentSession.startupAutoRetry.test.ts b/src/node/services/agentSession.startupAutoRetry.test.ts index b28be2166b8..a72fb1a03d8 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -23,6 +23,19 @@ interface AutoRetryResumeRequest { goalKind?: typeof GOAL_CONTINUATION_KIND; } +interface RetryableSessionForTests { + retryActiveStream: () => Promise; + lastAutoRetryResumeRequest?: AutoRetryResumeRequest; + resumeStream: (options: SendMessageOptions) => Promise< + | { success: true; data: { started: boolean } } + | { + success: false; + error: { type: "runtime_start_failed"; message: string }; + failureHandled?: true; + } + >; +} + interface SessionBundle { session: AgentSession; config: Config; @@ -1177,18 +1190,7 @@ describe("AgentSession startup auto-retry recovery", () => { const { session, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); - const privateSession = session as unknown as { - retryActiveStream: () => Promise; - lastAutoRetryResumeRequest?: AutoRetryResumeRequest; - resumeStream: (options: SendMessageOptions) => Promise< - | { success: true; data: { started: boolean } } - | { - success: false; - error: { type: "runtime_start_failed"; message: string }; - failureHandled?: true; - } - >; - }; + const privateSession = session as unknown as RetryableSessionForTests; privateSession.lastAutoRetryResumeRequest = { options: { @@ -1225,18 +1227,7 @@ describe("AgentSession startup auto-retry recovery", () => { const { session, events, cleanup } = await createSessionBundle(workspaceId); cleanups.push(cleanup); - const privateSession = session as unknown as { - retryActiveStream: () => Promise; - lastAutoRetryResumeRequest?: AutoRetryResumeRequest; - resumeStream: (options: SendMessageOptions) => Promise< - | { success: true; data: { started: boolean } } - | { - success: false; - error: { type: "runtime_start_failed"; message: string }; - failureHandled?: true; - } - >; - }; + const privateSession = session as unknown as RetryableSessionForTests; privateSession.lastAutoRetryResumeRequest = { options: { diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index fb98eb0e635..292987bd199 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -3,7 +3,7 @@ import { WorkspaceService, generateForkBranchName, generateForkTitle } from "./w import { registerInProcessWorkflowRun } from "@/node/services/workflows/workflowArchiveAdmission"; import type { IdleCompactionOutcome } from "./idleCompactionService"; import type { AgentSession } from "./agentSession"; -import { createAgentSessionHarness } from "./agentSession.testHarness"; +import { createAgentSessionHarness, createStartedTurnHandle } from "./agentSession.testHarness"; import type { AutoCompactionUsageState } from "@/common/utils/compaction/autoCompactionCheck"; import { createDisplayUsage } from "@/common/utils/tokens/displayUsage"; import { askUserQuestionManager } from "./askUserQuestionManager"; @@ -9752,7 +9752,9 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { test("start-here replacement does not auto-compact the next send from stale usage", async () => { const { config, historyService, workspaceService, cleanup } = await createServices(); const workspaceId = "start-here-clears-usage-state"; - const streamMessage = mock((..._args: unknown[]) => Promise.resolve(Ok(undefined))); + const streamMessage = mock((..._args: unknown[]) => + Promise.resolve(Ok(createStartedTurnHandle())) + ); const harness = await createAgentSessionHarness({ workspaceId, config, From 3eccf3321454f0574091becf7aefeb7adc4627a8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 15:40:23 +0000 Subject: [PATCH 40/42] =?UTF-8?q?=F0=9F=A4=96=20refactor(streaming):=20sha?= =?UTF-8?q?re=20request=20options=20between=20turn=20and=20fallback,=20ded?= =?UTF-8?q?upe=20startup=20abort=20settlement?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/streamManager.ts | 84 ++++++++++++------------------ 1 file changed, 32 insertions(+), 52 deletions(-) diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 6483fbb82a3..ba843b6caf5 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -236,70 +236,50 @@ export function createTurnCompletionController(): TurnCompletionController { }; } -export interface TurnExecutionOptions { - workspaceId: string; - messages: ModelMessage[]; +// Request-construction options shared by the primary turn and model-fallback +// hops (fallbacks rebuild these from the prepared fallback request). +interface StreamRequestOptions { model: LanguageModel; modelString: string; - historySequence: number; + messages: ModelMessage[]; system: string; - runtime: Runtime; - messageId: string; - abortSignal?: AbortSignal; tools?: Record; - initialMetadata?: Partial; providerOptions?: Record; maxOutputTokens?: number; + callSettingsOverrides?: ResolvedCallSettingsOverrides; toolPolicy?: ToolPolicy; - providedStreamToken?: StreamToken; hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean; - workspaceName?: string; - thinkingLevel?: string; headers?: Record; anthropicCacheTtlOverride?: AnthropicCacheTtl; - callSettingsOverrides?: ResolvedCallSettingsOverrides; onChunk?: StreamTextOnChunk; onStepMessages?: (messages: ModelMessage[]) => void; - providedRuntimeTempDir?: string; - modelFallback?: ModelFallbackOptions; toolSearchState?: ToolSearchStreamState; thinkingOverrideState?: ActiveTurnThinkingOverride; rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel; forcedFirstStepToolNames?: string[]; providersConfigSnapshot?: ProvidersConfigMap; - onStreamConstructed?: () => Promise; rebuildFirstStepForThinkingLevel?: RebuildFirstStepForThinkingLevel; } -// Request-construction inputs shared by the primary turn (sourced from -// TurnExecutionOptions) and model-fallback hops (sourced from the prepared -// fallback). routeProvider is the backend-resolved route -// (initialMetadata.routeProvider for the primary request, -// initialMetadataPatch.routeProvider for fallbacks); missing route metadata -// fails closed for OpenAI explicit prompt caching. -type StreamRequestInput = Pick< - TurnExecutionOptions, - | "model" - | "modelString" - | "messages" - | "system" - | "tools" - | "providerOptions" - | "maxOutputTokens" - | "callSettingsOverrides" - | "toolPolicy" - | "hasQueuedMessages" - | "headers" - | "anthropicCacheTtlOverride" - | "onChunk" - | "onStepMessages" - | "toolSearchState" - | "thinkingOverrideState" - | "rebuildProviderOptionsForThinkingLevel" - | "forcedFirstStepToolNames" - | "providersConfigSnapshot" - | "rebuildFirstStepForThinkingLevel" -> & { +export interface TurnExecutionOptions extends StreamRequestOptions { + workspaceId: string; + historySequence: number; + runtime: Runtime; + messageId: string; + abortSignal?: AbortSignal; + initialMetadata?: Partial; + providedStreamToken?: StreamToken; + workspaceName?: string; + thinkingLevel?: string; + providedRuntimeTempDir?: string; + modelFallback?: ModelFallbackOptions; + onStreamConstructed?: () => Promise; +} + +// routeProvider is the backend-resolved route (initialMetadata.routeProvider +// for the primary request, initialMetadataPatch.routeProvider for fallbacks); +// missing route metadata fails closed for OpenAI explicit prompt caching. +type StreamRequestInput = StreamRequestOptions & { routeProvider?: string; onToolExecutionStart?: (toolCallId: string) => void; }; @@ -4522,6 +4502,10 @@ export class StreamManager { } = options; const completionController = createTurnCompletionController(); const handle: TurnStreamHandle = { messageId, completion: completionController.promise }; + const settleStartupAbort = (): Result => { + completionController.settle({ status: "aborted", abortReason: "startup" }); + return Ok(handle); + }; const typedWorkspaceId = workspaceId as WorkspaceId; @@ -4565,8 +4549,7 @@ export class StreamManager { // If the stream was interrupted while we were waiting on async setup (mutex, // temp dir creation, etc), avoid starting the stream entirely. if (streamAbortController.signal.aborted) { - completionController.settle({ status: "aborted", abortReason: "startup" }); - return Ok(handle); + return settleStartupAbort(); } // Step 3: Create temp directory for this stream using runtime. @@ -4576,8 +4559,7 @@ export class StreamManager { providedRuntimeTempDir ?? (await this.createTempDirForStream(streamToken, runtime)); if (streamAbortController.signal.aborted) { - completionController.settle({ status: "aborted", abortReason: "startup" }); - return Ok(handle); + return settleStartupAbort(); } // Step 4: Atomic stream creation and registration @@ -4595,8 +4577,7 @@ export class StreamManager { // In that case, immediately drop the registered stream and rely on the caller to handle UI. if (streamAbortController.signal.aborted) { this.workspaceStreams.delete(typedWorkspaceId); - completionController.settle({ status: "aborted", abortReason: "startup" }); - return Ok(handle); + return settleStartupAbort(); } streamInfo.unlinkAbortSignal = unlinkAbortSignal; @@ -4621,8 +4602,7 @@ export class StreamManager { this.workspaceStreams.delete(typedWorkspaceId); } streamRegistered = false; - completionController.settle({ status: "aborted", abortReason: "startup" }); - return Ok(handle); + return settleStartupAbort(); } // Step 5: Track the processing promise for guaranteed cleanup From ef4d5116a12ebdb52deb7f0ef6ce8962bbd823f4 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:50:19 +0000 Subject: [PATCH 41/42] fix(services): skip handle-less startup-failure recovery after disposal Codex round: handleStreamWithHistoryFailure could persist retry state or error rows after dispose() when a startup await (commitPartial, history reads) settled with a failure post-teardown. Guard it like consumeTurnCompletion, settling collected pre-start recovery decisions in memory so waiters cannot hang. Red-green verified via the new dispose-race regression test. --- .../services/agentSession.disposeRace.test.ts | 35 +++++++++++++++++++ src/node/services/agentSession.ts | 11 ++++++ 2 files changed, 46 insertions(+) diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 3d00ff1d2b6..4aa91d58926 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -541,6 +541,41 @@ describe("AgentSession disposal race conditions", () => { } }); + test("skips handle-less startup-failure recovery when disposal begins mid-startup", async () => { + const commitDeferred = createDeferred>(); + const { session, historyService, cleanup } = await createAgentSessionHarness({ + workspaceId: "ws-dispose-startup-failure", + }); + try { + spyOn(historyService, "commitPartial").mockReturnValueOnce(commitDeferred.promise); + const errorSink = session as unknown as { + handleStreamError: (data: unknown) => Promise; + handleStreamFailureForAutoRetry: (failure: unknown) => Promise; + }; + const handleStreamErrorSpy = spyOn(errorSink, "handleStreamError"); + const autoRetrySpy = spyOn(errorSink, "handleStreamFailureForAutoRetry"); + + const resumePromise = session.resumeStream({ + model: "anthropic:claude-3-5-sonnet-latest", + agentId: "exec", + }); + // Let the resume park on the pending commitPartial before disposing. + await new Promise((resolve) => setTimeout(resolve, 10)); + session.dispose(); + commitDeferred.resolve(Err("workspace removed mid-startup")); + + const result = await resumePromise; + expect(result.success).toBe(false); + if (!result.success) { + expect(result.failureHandled).toBe(true); + } + expect(handleStreamErrorSpy).not.toHaveBeenCalled(); + expect(autoRetrySpy).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } + }); + test("preserves synthetic flag when flushing queued messages", () => { const aiService: AIService = { on(_eventName: string | symbol, _listener: (...args: unknown[]) => void) { diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 55492df977b..df8c15c59f5 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4836,6 +4836,17 @@ export class AgentSession { acpPromptId?: string, preStartErrors?: StreamErrorPayload[] | null ): Promise> { + // A disposed session must not persist retry/goal state or error rows + // post-teardown (mirrors consumeTurnCompletion). Settle collected recovery + // decisions in memory so waiters cannot hang, then skip all recovery + // bookkeeping; failureHandled keeps callers from running theirs. + if (this.disposed) { + for (const payload of preStartErrors ?? []) { + this.resolveStreamErrorRecoveryDecision(payload.messageId, "terminal"); + } + return { success: false, error, failureHandled: true }; + } + // Collected pre-start error events own this failure; the branches below // only cover failures that produced no error event. if (preStartErrors != null && preStartErrors.length > 0) { From 769f149f35de5a009375b16f13543c3aa337ec07 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Fri, 28 Aug 2026 20:30:37 +0000 Subject: [PATCH 42/42] fix(services): contain turn event sink rejections Codex round: TurnEngineEventSink may return a promise, but emitTurnEvent void'd it, so a rejecting async sink became an unhandled rejection and a slow terminal sink could outlast the settled completion unobserved. Contain rejections with a logged catch; also catch the two abort-side mirrors (abortDelivery.finally re-propagated rejections after settling, and stopStream's no-stream emit was raw void). Red-green verified via a new unhandled-rejection regression test. --- src/node/services/streamManager.test.ts | 32 +++++++++++++++++++++++++ src/node/services/streamManager.ts | 30 +++++++++++++++++++---- 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 43fc85f1302..b2105e4d8bb 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -257,6 +257,38 @@ function createStreamInfoForTests( }; } +describe("StreamManager - event sink rejection containment", () => { + test("a rejecting async event sink does not become an unhandled rejection", async () => { + const unhandled: unknown[] = []; + const onUnhandled = (reason: unknown) => { + unhandled.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + try { + const streamManager = new StreamManager(historyService, undefined, undefined, () => + Promise.reject(new Error("sink boom")) + ); + const emitTurnEvent = getPrivateMethodForTests<(event: TurnEngineEvent) => void>( + streamManager, + "emitTurnEvent" + ); + emitTurnEvent.call(streamManager, { + type: "workflow-run-attached", + workspaceId: "sink-rejection-workspace", + messageId: "sink-rejection-message", + toolCallId: "sink-rejection-call", + runId: "wfr_sink", + timestamp: Date.now(), + }); + // A macrotask so an uncontained rejection would surface before asserting. + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(unhandled).toHaveLength(0); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); +}); + describe("StreamManager - workflow run attachments", () => { test("persists attached workflow run metadata to partial immediately", async () => { const streamManager = new StreamManager(historyService); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index ba843b6caf5..dc168856af2 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -804,7 +804,13 @@ export class StreamManager { } private emitTurnEvent(event: TurnEngineEvent): void { - void this.eventSink(event); + // TurnEngineEventSink may return a promise; non-abort delivery stays + // fire-and-forget, so contain rejections here or a failing async sink + // becomes an unhandled rejection. Abort delivery is sequenced separately + // via emitStreamAbort. + void Promise.resolve(this.eventSink(event)).catch((error) => { + log.error("Turn event sink failed", { eventType: event.type, error: getErrorMessage(error) }); + }); } private getWorkspaceLogger( @@ -1685,9 +1691,15 @@ export class StreamManager { // Clean up immediately this.workspaceStreams.delete(workspaceId); - void abortDelivery.finally(() => { - streamInfo.completionController?.settle({ status: "aborted", abortReason }); - }); + void abortDelivery + .catch((error) => { + // Contain sink rejections: .finally alone would re-propagate them as + // an unhandled rejection after completion settles. + log.error("Stream-abort delivery failed", { error: getErrorMessage(error) }); + }) + .finally(() => { + streamInfo.completionController?.settle({ status: "aborted", abortReason }); + }); } /** @@ -4786,7 +4798,15 @@ export class StreamManager { // Emit abort event so frontend clears pending stream state. // This handles the case where user interrupts before stream-start arrives. // Use empty messageId - frontend handles gracefully (just clears pendingStreamStartTime). - void this.emitStreamAbort(typedWorkspaceId, "", {}, abortReason, options?.abandonPartial); + void this.emitStreamAbort( + typedWorkspaceId, + "", + {}, + abortReason, + options?.abandonPartial + ).catch((error) => { + log.error("Stream-abort delivery failed", { error: getErrorMessage(error) }); + }); return Ok(undefined); }