diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 310a318d695..909ed92d32e 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,104 @@ 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": + // 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": + // 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); } - 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/features/Tools/Shared/getToolComponent.test.ts b/src/browser/features/Tools/Shared/getToolComponent.test.ts index 5233206e50f..2cc14ab1501 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.test.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.test.ts @@ -1,161 +1,53 @@ 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 { WorkspaceLifecycleToolCall } from "../WorkspaceLifecycleToolCall"; 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("renders historical workspace lifecycle actions", () => { + expect( + getToolComponent("task_workspace_lifecycle", { + action: "remove", + targets: [{ workspaceId: "workspace-id" }], + force: true, + }) + ).toBe(WorkspaceLifecycleToolCall); }); - 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 +57,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..cc0b8129bf4 100644 --- a/src/browser/features/Tools/Shared/getToolComponent.ts +++ b/src/browser/features/Tools/Shared/getToolComponent.ts @@ -10,6 +10,7 @@ import { TaskTerminateToolArgsSchema, TaskWorkspaceLifecycleToolArgsSchema, TOOL_DEFINITIONS, + type ToolName, } from "@/common/utils/tools/toolDefinitions"; import { AnalyticsQueryToolCall } from "../analyticsQuery/AnalyticsQueryToolCall"; @@ -69,20 +70,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 +142,21 @@ 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, + // 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. + "server:GOOGLE_SEARCH_WEB": z.object({ queries: z.array(z.string()).optional() }), }; /** @@ -279,9 +168,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/browser/utils/chatCommands.test.ts b/src/browser/utils/chatCommands.test.ts index ed157332b1f..b3bee5b49d4 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); +} + +function setHeartbeatExperiment(enabled: boolean): void { + localStorage.setItem( + getExperimentKey(EXPERIMENT_IDS.WORKSPACE_HEARTBEATS), + JSON.stringify(enabled) + ); } -describe("processSlashCommand - workflow", () => { - test("rejects workflow execution when dynamic workflows are disabled", async () => { +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, - }) - ); - 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: [], - }) + Promise.resolve({ runId: "wfr_123", status: "completed" as const, result: workflowResult }) ); - 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,534 +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("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 - ); - - 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, - }); - }); - - 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, + 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 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", - }) + } as unknown as SlashCommandEnv["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, + 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 SlashCommandContext["api"], - workspaceId: undefined, - }); - - setHeartbeatExperiment(true); - - 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: "No workspace selected", - }) - ); - }); - - test("enables workspace heartbeats with the requested interval without clearing the saved message", async () => { - const heartbeatGet = mock(() => - Promise.resolve({ - enabled: true as const, - intervalMs: 45 * 60 * 1000, - message: "Review the workspace status before taking action.", - }) + } as unknown as SlashCommandEnv["api"]) + ) ); - 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", + expectDisposition(resume.result, "restore"); + expect(resume.result.actions[0]).toMatchObject({ + type: "show-toast", + toast: { type: "error" }, }); - - 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" }); - 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", - }) - ); }); +}); - test("still updates the interval when reading current heartbeat settings fails", async () => { - const heartbeatGet = mock(() => Promise.reject(new Error("Corrupted heartbeat settings"))); - 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(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", - }) +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 }) ); + 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" } }); + + 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("preserves the configured interval and message when disabling workspace heartbeats", async () => { + test("preserves saved heartbeat fields and returns success", async () => { + setHeartbeatExperiment(true); const heartbeatGet = mock(() => Promise.resolve({ enabled: true as const, @@ -1440,106 +763,417 @@ 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"], + 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.", + }); + expectToast(complete.actions, { + type: "success", + message: "Heartbeat set to every 30 minutes", }); + }); + test("uses the default interval when disabling without saved settings", async () => { 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" }); + const heartbeatSet = mock(() => Promise.resolve({ success: true, data: undefined })); + 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: 45 * 60 * 1000, - message: "Review the workspace status before taking action.", + intervalMs: HEARTBEAT_DEFAULT_INTERVAL_MS, }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "success", - message: "Heartbeat disabled", - }) - ); }); - 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("returns backend update failures with restore disposition", async () => { + setHeartbeatExperiment(true); + 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", }); + }); +}); - setHeartbeatExperiment(true); +describe("detached command work", () => { + test("dream returns immediately and maps success and rejection to settle actions", async () => { + const consolidate = mock(() => + Promise.resolve({ + success: true as const, + data: { ops: [{ applied: true }, { applied: false }] }, + }) + ); + 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(result.actions).toEqual([{ type: "clear-input" }]); + expect(consolidate).not.toHaveBeenCalled(); + const successActions = await result.backgroundTask?.(); + expect(successActions).toBeDefined(); + expectToast(successActions ?? [], { + type: "success", + message: "Memory consolidated: 1 change(s)", + }); - const result = await processSlashCommand({ type: "heartbeat-set", minutes: null }, context); + 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(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: "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("surfaces backend heartbeat update failures", async () => { - const heartbeatGet = mock(() => + 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]).toEqual({ type: "clear-input" }); + expect(missingProposal.actions[1]).toMatchObject({ + type: "show-toast", + toast: { type: "error" }, + }); + + const run = mock(() => Promise.resolve({ - enabled: true as const, - intervalMs: 45 * 60 * 1000, - message: "Review the workspace status before taking action.", + success: true as const, + data: { applied: [], staged: [{ path: "src/a.ts" }], failed: [], noOp: false }, }) ); - const heartbeatSet = mock(() => - Promise.resolve({ success: false as const, error: "Heartbeat update failed" }) + const result = await processSlashCommand( + { type: "refine", apply: false }, + createEnv({ + api: { refinements: { run } } as unknown as SlashCommandEnv["api"], + }) ); - const context = createSlashCommandContext({ - api: { - workspace: { - heartbeat: { - get: heartbeatGet, - set: heartbeatSet, - }, - }, - } as unknown as SlashCommandContext["api"], - workspaceId: "test-ws", + 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", }); - setHeartbeatExperiment(true); + 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", + }); - const result = await processSlashCommand({ type: "heartbeat-set", minutes: 30 }, context); + 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", + }); + }); +}); - 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.", +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 }); }); - expect(context.setToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: "error", - message: "Heartbeat update failed", + 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" }, }) ); + 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 + ); + }); + + 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" } }); + }); + + 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(); + } + }); + + 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", + }); + }); + + 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); + } + } }); }); @@ -1820,279 +1454,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..383e6a69488 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,154 @@ 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", [{ type: "clear-input" }], 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", [ + { type: "clear-input" }, + 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", [{ type: "clear-input" }], 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 +891,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 +924,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 +951,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 +990,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 +1012,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 +1589,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. */ +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. */ +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, +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", +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 }; } // ============================================================================ 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; } 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/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..11a743f3eb7 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, @@ -37,6 +37,16 @@ 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"; export interface DevcontainerRuntimeOptions { srcBaseDir: string; @@ -186,13 +196,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). @@ -286,179 +289,7 @@ 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 ${this.quoteForContainer(filePath)}`, { - cwd: this.getContainerBasePath(), - 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 quotedPath = this.quoteForContainer(filePath); - const tempPath = getAtomicWriteTempPath(filePath); - const quotedTempPath = this.quoteForContainer(tempPath); - const writeCommand = `mkdir -p $(dirname ${quotedPath}) && cat > ${quotedTempPath} && mv ${quotedTempPath} ${quotedPath}`; - - 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(), - 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 ${this.quoteForContainer(dirPath)}`, { - 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" - ); - } - } - - 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)}`, { - cwd: this.getContainerBasePath(), - 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", - }; - } - 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 +443,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 +460,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, { @@ -723,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 { @@ -731,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 50e6033164b..4720ef97ef2 100644 --- a/src/node/runtime/LocalBaseRuntime.test.ts +++ b/src/node/runtime/LocalBaseRuntime.test.ts @@ -104,6 +104,33 @@ describe("LocalBaseRuntime.resolvePath", () => { }); describe("LocalBaseRuntime.exec PATH handling", () => { + 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\\n%s" "$XUM_TEST_PATH" "$XUM_TEST_HOME"', { + cwd: os.tmpdir(), + 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(os.homedir(), "runtime-path")}\n${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 7c62e15c637..a71d161879c 100644 --- a/src/node/runtime/LocalBaseRuntime.ts +++ b/src/node/runtime/LocalBaseRuntime.ts @@ -85,10 +85,20 @@ export abstract class LocalBaseRuntime implements Runtime { .map(([key, value]) => buildShellExport(key, value)) .join("\n"); - const spawnArgs = ["-c", `${nonInteractivePrelude}\n${command}`]; + // 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]) => buildShellExport(key, path.resolve(cwd, expandTilde(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 ?? {}), + }); const basePath = (options.env?.PATH && options.env.PATH.length > 0 ? mergedEnv.PATH @@ -213,9 +223,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 +293,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 +303,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 +361,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 +382,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..1e442f56bb0 100644 --- a/src/node/runtime/RemoteRuntime.test.ts +++ b/src/node/runtime/RemoteRuntime.test.ts @@ -1,64 +1,45 @@ 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" }); - } +function createStream(value: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(value)); + controller.close(); + }, + }); +} - initWorkspace() { - return Promise.resolve({ success: true }); - } +class CanonicalPathRemoteRuntime extends RecordingRemoteRuntime { + commands: string[] = []; - deleteWorkspace() { - return Promise.resolve({ success: true as const, deletedPath: "/workspace" }); + 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); } - renameWorkspace() { + override exec(command: string, _options: ExecOptions): Promise { + this.commands.push(command); return Promise.resolve({ - success: true as const, - oldPath: "/workspace", - newPath: "/workspace", + stdout: createStream(command.startsWith("stat ") ? "1 2 regular file\n" : "contents"), + stderr: createStream(""), + stdin: new WritableStream(), + exitCode: Promise.resolve(0), + duration: Promise.resolve(0), }); } - - forkWorkspace() { - return Promise.resolve({ success: false as const, error: "not implemented" }); - } - - ensureReady() { - return Promise.resolve({ ready: true as const }); - } } /** @@ -86,6 +67,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 @@ -116,6 +120,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 5b84e83a8fb..db4a1b235b2 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, @@ -35,10 +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 } from "./shellEnv"; +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 @@ -122,6 +130,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,74 +367,51 @@ export abstract class RemoteRuntime implements Runtime { return { stdout, stderr, stdin, exitCode, duration }; } + private async resolveFilePath(filePath: string, abortSignal?: AbortSignal): Promise { + if (filePath === "~" || filePath.startsWith("~/")) { + return this.resolveWithAbort(this.resolvePath(filePath), abortSignal); + } + if (path.posix.isAbsolute(filePath)) { + return path.posix.normalize(filePath); + } + const basePath = await this.resolveWithAbort(this.resolvePath(this.getBasePath()), abortSignal); + return path.posix.resolve(basePath, filePath); + } + /** - * Read file contents as a stream via exec. + * 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. */ - 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 }); + 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"); } - const cleanupAbortForwarder = () => { - abortSignal?.removeEventListener("abort", forwardAbort); - }; - - return new ReadableStream({ - cancel: () => { - readAbort.abort(); - cleanupAbortForwarder(); - }, - start: async (controller: ReadableStreamDefaultController) => { - try { - const stream = await this.exec(`cat ${this.quoteForRemote(filePath)}`, { - 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"); - } + return result.value; + } - 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(); - } + /** + * Read file contents as a stream via exec. + */ + readFile(filePath: string, abortSignal?: AbortSignal): ReadableStream { + 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 + ); } /** @@ -431,74 +419,20 @@ 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(); - 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.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 + ); } /** @@ -512,65 +446,29 @@ 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)}`, { - 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 stream = await this.exec(`stat -L -c '%s %Y %F' ${this.quoteForRemote(filePath)}`, { - 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/Runtime.ts b/src/node/runtime/Runtime.ts index 29406cf2eb4..96a1aeae807 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 @@ -397,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 @@ -406,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 @@ -415,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 aec25a495ee..d8cd7c951de 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; takes precedence over 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/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/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 51cd6c0cfb4..6e1809e962e 100644 --- a/src/node/runtime/shellEnv.ts +++ b/src/node/runtime/shellEnv.ts @@ -16,3 +16,18 @@ 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); + // 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}" ;; /* | [A-Za-z]:* | '\\\\'*) ;; *) ${key}="$PWD/$${key}" ;; esac`, + `export ${key}`, + ].join(" && "); +} 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/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 3ef773f3396..cf7546048a4 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -19,7 +19,7 @@ import type { BackgroundProcessManager } from "@/node/services/backgroundProcess 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"; describe("AgentSession on-send auto-compaction snapshot deferral", () => { @@ -48,7 +48,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,11 +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(undefined))); - 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; @@ -234,10 +232,8 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { workspaceId: string; experiments?: SendMessageOptions["experiments"]; }) => { - const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); 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 @@ -342,7 +338,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 +382,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 +436,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 +499,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,11 +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(undefined))); - 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 = { @@ -611,11 +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(undefined))); - 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 @@ -654,7 +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(undefined))); const compactionModel = "openai:gpt-5.5"; const config = { srcDir: "/tmp", @@ -663,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", @@ -711,7 +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(undefined))); const config = { srcDir: "/tmp", getSessionDir: (_workspaceId: string) => "/tmp", @@ -721,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", @@ -764,11 +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(undefined))); - 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", @@ -846,7 +820,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,7 +940,9 @@ 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))), @@ -1062,7 +1038,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 +1186,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 +1362,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.budgetGate.test.ts b/src/node/services/agentSession.budgetGate.test.ts index 5481647bb8b..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 @@ -48,7 +49,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.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index ab65098f998..4aa91d58926 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,75 @@ describe("AgentSession disposal race conditions", () => { expect(setEnabled).toHaveBeenCalledTimes(0); }); + test("drops failed turn completions delivered after disposal", async () => { + const completion = createDeferred(); + const streamMessage = mock((_opts: StreamMessageOptions) => + Promise.resolve(Ok({ messageId: "assistant-post-dispose", completion: completion.promise })) + ); + 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(); + completion.resolve({ + status: "failed", + streamError: { messageId: "assistant-post-dispose", error: "boom", errorType: "api" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(handleStreamErrorSpy).not.toHaveBeenCalled(); + } finally { + await cleanup(); + } + }); + + 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.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index 146f494a541..09644eb2813 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -5,15 +5,15 @@ 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 { createTestHistoryService } from "./testHistoryService"; +import { createStartedTurnHandle } from "./agentSession.testHarness"; type StreamMessageHandler = AIService["streamMessage"]; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; + const config = { srcDir: "/tmp", getSessionDir: (_workspaceId: string) => "/tmp", @@ -35,7 +35,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 +282,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.fileChangeNotification.test.ts b/src/node/services/agentSession.fileChangeNotification.test.ts index f0af096130d..eee36a733dd 100644 --- a/src/node/services/agentSession.fileChangeNotification.test.ts +++ b/src/node/services/agentSession.fileChangeNotification.test.ts @@ -12,6 +12,7 @@ import type { AIService, StreamMessageOptions } from "./aiService"; import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { InitStateManager } from "./initStateManager"; import { createTestHistoryService } from "./testHistoryService"; +import { createStartedTurnHandle } from "./agentSession.testHarness"; /** * Log purity: externally-edited files must produce a durable @@ -47,7 +48,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.goalAutoPause.test.ts b/src/node/services/agentSession.goalAutoPause.test.ts index 7ba6a05ecfa..8fcd623a236 100644 --- a/src/node/services/agentSession.goalAutoPause.test.ts +++ b/src/node/services/agentSession.goalAutoPause.test.ts @@ -19,6 +19,7 @@ import { } from "@/constants/goals"; import { waitForCondition } from "./testDispatchHelpers"; import { IdleDispatcher } from "./idleDispatcher"; +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" }; @@ -50,7 +51,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 +1002,11 @@ describe("AgentSession goal safety hooks", () => { error: "boom", errorType: "unknown", }); - return Promise.resolve(Ok(undefined)); + return Promise.resolve( + Ok( + createFailedTurnHandle("assistant-stream-error", { error: "boom", errorType: "unknown" }) + ) + ); }) as unknown as AIService["streamMessage"]; const eventTypes: string[] = []; session.onChatEvent((event) => { @@ -1181,7 +1186,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 +1233,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 +1258,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 +1296,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 +1328,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.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 d4c39fc3b94..f861e345d33 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -13,6 +13,17 @@ import type { BackgroundProcessManager } from "./backgroundProcessManager"; import type { MuxMessage } from "@/common/types/message"; 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; @@ -91,11 +102,14 @@ describe("AgentSession post-compaction context retry", () => { errorType: "context_exceeded", }); - return Promise.resolve({ success: true as const, data: undefined }); + return Promise.resolve(contextExceededResult("assistant-ctx-exceeded")); } resolveSecondCall?.(); - return Promise.resolve({ success: true as const, data: undefined }); + return Promise.resolve({ + success: true as const, + data: createStartedTurnHandle("assistant-retry"), + }); }); const aiService: AIService = { @@ -237,7 +251,7 @@ describe("AgentSession post-compaction context retry", () => { error: "Context length exceeded", errorType: "context_exceeded", }); - return { success: true as const, data: undefined }; + 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). @@ -372,7 +386,7 @@ 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(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. @@ -382,7 +396,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: createFailedTurnHandle("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..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"; @@ -443,80 +443,6 @@ describe("AgentSession pre-stream errors", () => { session.dispose(); }); - it("does not double-schedule auto-retry when runtime startup failure already emitted", async () => { - const workspaceId = "ws-runtime-start-failed-pre-emitted-error"; - - const { historyService, config, cleanup } = await createTestHistoryService(); - historyCleanup = cleanup; - - const aiEmitter = new EventEmitter(); - const streamMessage = mock((_history: MuxMessage[]) => { - aiEmitter.emit("error", { - workspaceId, - messageId: "assistant-stream-startup-failed", - error: "Runtime is still starting", - errorType: "runtime_start_failed", - }); - - return Promise.resolve( - Err({ - type: "runtime_start_failed", - message: "Runtime is still starting", - }) - ); - }); - - 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>, - }) 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(false); - - 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); @@ -1192,4 +1118,53 @@ 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 (and the caller's onPreStartError collector), + // then streamMessage returns Err with no handle. + const aiEmitter = new EventEmitter(); + const streamMessage = mock((opts: StreamMessageOptions) => { + const errorEvent = { + type: "error" as const, + workspaceId, + messageId: preStartMessageId, + error: "Runtime unavailable.", + errorType: "runtime_not_ready" as const, + }; + aiEmitter.emit("error", errorEvent); + opts.onPreStartError?.(errorEvent); + 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.preTurnMessages.test.ts b/src/node/services/agentSession.preTurnMessages.test.ts index 42bcecfa7c1..b57038de012 100644 --- a/src/node/services/agentSession.preTurnMessages.test.ts +++ b/src/node/services/agentSession.preTurnMessages.test.ts @@ -8,6 +8,7 @@ import { createMuxMessage } from "@/common/types/message"; import { Err, Ok } from "@/common/types/result"; import { AgentSession } from "./agentSession"; import { createTestHistoryService } from "./testHistoryService"; +import { createStartedTurnHandle } from "./agentSession.testHarness"; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { @@ -26,7 +27,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))), 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 248933ab3d2..a72fb1a03d8 100644 --- a/src/node/services/agentSession.startupAutoRetry.test.ts +++ b/src/node/services/agentSession.startupAutoRetry.test.ts @@ -1,7 +1,7 @@ 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"; @@ -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; @@ -258,7 +271,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 +315,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 { @@ -1177,17 +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; - activeStreamFailureHandled: boolean; - resumeStream: ( - options: SendMessageOptions - ) => Promise< - | { success: true; data: { started: boolean } } - | { success: false; error: { type: "runtime_start_failed"; message: string } } - >; - }; + const privateSession = session as unknown as RetryableSessionForTests; privateSession.lastAutoRetryResumeRequest = { options: { @@ -1196,7 +1199,6 @@ describe("AgentSession startup auto-retry recovery", () => { }, }; - privateSession.activeStreamFailureHandled = true; const resumeStreamMock = mock((_options: SendMessageOptions) => Promise.resolve({ success: false as const, @@ -1204,6 +1206,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; @@ -1224,17 +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; - activeStreamFailureHandled: boolean; - resumeStream: ( - options: SendMessageOptions - ) => Promise< - | { success: true; data: { started: boolean } } - | { success: false; error: { type: "runtime_start_failed"; message: string } } - >; - }; + const privateSession = session as unknown as RetryableSessionForTests; privateSession.lastAutoRetryResumeRequest = { options: { @@ -1243,7 +1236,6 @@ describe("AgentSession startup auto-retry recovery", () => { }, }; - privateSession.activeStreamFailureHandled = false; const resumeStreamMock = mock((_options: SendMessageOptions) => Promise.resolve({ success: false as const, @@ -1382,7 +1374,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 +1468,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 +1551,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..6bdff8ef9a7 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -2,10 +2,10 @@ 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"; +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"; @@ -14,6 +14,24 @@ 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 { messageId, completion: new Promise(() => undefined) }; +} + +export function createFailedTurnHandle( + messageId: string, + failure: { error: string; errorType: StreamErrorType } +): TurnStreamHandle { + return { + messageId, + completion: Promise.resolve({ + status: "failed" as const, + streamError: { messageId, ...failure }, + }), + }; +} function createAgentSessionTestConfig(sessionDir = "/tmp"): Config { return { @@ -48,8 +66,8 @@ 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: mock((_history: MuxMessage[]) => - Promise.resolve(Ok(undefined)) + streamMessage: mock(() => + Promise.resolve(Ok(createStartedTurnHandle("test-assistant-message"))) ) as unknown as AIService["streamMessage"], ...args?.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.ts b/src/node/services/agentSession.ts index efcf5d7f199..df8c15c59f5 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 { 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"; @@ -192,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. @@ -747,18 +757,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 +1268,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 +3014,7 @@ export class AgentSession { */ admissionStale?: () => boolean; } - ): Promise> { + ): Promise> { this.assertNotDisposed("sendMessage"); assert(typeof message === "string", "sendMessage requires a string message"); @@ -4026,7 +4021,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 +4122,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 +4752,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 +4831,76 @@ export class AgentSession { return Ok(undefined); } + private async handleStreamWithHistoryFailure( + error: SendMessageError, + 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) { + 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") { + 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(handle: TurnStreamHandle): void { + void handle.completion + .then(async (outcome) => { + // A disposed session must not persist retry/goal state post-teardown. + if (outcome.status !== "failed" || this.disposed) return; + + try { + await this.handleStreamError(outcome.streamError); + } finally { + this.resolveStreamErrorRecoveryDecision(outcome.streamError.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 +4914,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 +4926,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 +4941,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 +4968,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 +4981,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 +4991,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." ) @@ -5042,6 +5111,10 @@ export class AgentSession { normalizeDelegatedToolNames(options?.delegatedToolNames) ?? extractAcpDelegatedTools(optionsMuxMetadata); + // 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, @@ -5079,41 +5152,19 @@ export class AgentSession { // send-time level above (single source of truth for the floor). minThinkingLevel, activeTurnThinkingOverride, + onPreStartError: ({ workspaceId: _workspaceId, ...payload }) => preStartErrors.push(payload), }); 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, + preStartErrors + ); } - return streamResult; + this.consumeTurnCompletion(streamResult.data); + return Ok(undefined); } private resolveCompactionRequest( @@ -6108,21 +6159,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/agentSession.workspaceTurnInheritance.test.ts b/src/node/services/agentSession.workspaceTurnInheritance.test.ts index c19efecd029..707a2a3242b 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,12 +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(undefined))); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, - aiServiceOverrides: { - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }, }); try { const result = await session.sendMessage( @@ -226,12 +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(undefined))); const { session, cleanup, historyService } = await createAgentSessionHarness({ workspaceId, - aiServiceOverrides: { - streamMessage: streamMessage as unknown as AIService["streamMessage"], - }, }); try { await historyService.appendToHistory(workspaceId, turnPrompt("delegated-prompt")); diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 2991b7820a3..9a5ffe4cb04 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -52,11 +52,16 @@ import type { RuntimeStatusEvent, StreamAbortEvent, StreamEndEvent, - WorkflowRunAttachedEvent, } 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 +298,7 @@ function stubCommonStreamMessageDependencies(args: { historyService: HistoryService; initStateManager: InitStateManager; metadata: WorkspaceMetadata; - startStreamCalls?: unknown[][]; + startStreamCalls?: TurnExecutionOptions[]; routeProvider?: ProviderName; allTools?: Record; workspacePathOverride?: string; @@ -399,13 +404,17 @@ 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: { + messageId: options.messageId, + completion: new Promise(() => undefined), + }, + }; }; spyOn(streamManager, "startStream").mockImplementation(stubStartStream); @@ -618,9 +627,9 @@ describe("resolveMuxProjectRootForHostFs", () => { }); }); -describe("AIService.setupStreamEventForwarding", () => { +describe("AIService turn engine events", () => { interface ForwardingInternals { - streamManager: StreamManager; + emitEngineEvent: (event: TurnEngineEvent) => void | Promise; pendingDevToolsRunMetadataByMessageId: Map; } @@ -674,7 +683,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); @@ -682,55 +691,9 @@ describe("AIService.setupStreamEventForwarding", () => { 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)); - }); - internals.streamManager.emit("stream-abort", abortEvent); - - expect(await forwardedAbortPromise).toEqual(abortEvent); - expect(clearPendingRunMetadataSpy).not.toHaveBeenCalled(); - 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) - ); - }); - internals.streamManager.emit("workflow-run-attached", event); - - expect(await forwardedPromise).toEqual(event); - }); - it.each([ { - name: "stream error", + name: "stream error clears tracked metadata", eventName: "error" as const, event: { type: "error" as const, @@ -739,9 +702,10 @@ describe("AIService.setupStreamEventForwarding", () => { 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, @@ -750,11 +714,24 @@ describe("AIService.setupStreamEventForwarding", () => { metadata: { model: "anthropic:claude-opus-4-1" }, parts: [], } satisfies StreamEndEvent, + expectCleared: true, + }, + { + 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, }, - ])("clears tracked devtools run metadata on $name", async ({ eventName, event }) => { + ])("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", }); @@ -762,11 +739,17 @@ 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"); - 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 + ); }); }); @@ -1205,7 +1188,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { streamSystemContextAdvisorFlags: Array; streamSystemContextMemoryToolFlags: Array; streamSystemContextHotMemoriesBlocks: Array; - startStreamCalls: unknown[][]; + startStreamCalls: TurnExecutionOptions[]; getToolsForModelSpy: ReturnType>; } @@ -1228,10 +1211,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 +1227,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 +1267,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 +1320,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 +1405,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 +1534,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 +1631,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 +1723,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 +1878,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 +2390,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 +2771,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 +2964,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 +3448,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 +3457,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 +3785,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 +3820,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 +3841,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 +4073,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 +4253,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 +4319,7 @@ describe("AIService.streamMessage turn envelope", () => { interface TurnEnvelopeHarness { service: AIService; config: Config; - startStreamCalls: unknown[][]; + startStreamCalls: TurnExecutionOptions[]; } function createHarness( @@ -4356,7 +4328,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..e287a0f98fc 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, @@ -156,12 +164,7 @@ import type { RebuildProviderOptionsForThinkingLevel, } from "@/node/services/thinkingOverride"; -import type { - ErrorEvent, - StreamAbortEvent, - StreamAbortReason, - StreamEndEvent, -} from "@/common/types/stream"; +import type { ErrorEvent, StreamAbortEvent, StreamAbortReason } from "@/common/types/stream"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import { computeActiveToolNames, @@ -293,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; @@ -620,8 +625,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 +640,6 @@ export class AIService extends EventEmitter { devToolsService ); void this.ensureSessionsDir(); - this.setupStreamEventForwarding(); this.mockModeEnabled = false; if (resolveXumEnvironmentValue("MOCK_AI", process.env) === "1") { @@ -780,57 +787,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 +819,43 @@ 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 { messageId, completion: Promise.resolve(completion) }; + } + + private createAbortedTurnHandle(messageId: string): TurnStreamHandle { + return this.createSettledTurnHandle(messageId, { status: "aborted", abortReason: "startup" }); } private trackPendingDevToolsRunMetadata( @@ -1317,7 +1300,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, @@ -1334,6 +1319,7 @@ export class AIService extends EventEmitter { agentId, strictAgentResolution, acpPromptId, + onPreStartError, delegatedToolNames, recordFileState, postCompactionAttachments, @@ -1388,15 +1374,22 @@ 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.createAbortedTurnHandle(syntheticMessageId)); } - return await this.mockAiStreamPlayer.play(messages, workspaceId, { + // 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, thinkingLevel, muxMetadata, abortSignal: combinedAbortSignal, }); + if (!result.success) { + return result; + } + return Ok(result.data ?? this.createAbortedTurnHandle(syntheticMessageId)); } // DEBUG: Log streamMessage call @@ -1759,7 +1752,7 @@ export class AIService extends EventEmitter { await this.initStateManager.waitForInit(workspaceId, combinedAbortSignal); recordStartupPhaseTiming("waitForInitMs", waitForInitStartedAt); if (combinedAbortSignal.aborted) { - return Ok(undefined); + return Ok(this.createAbortedTurnHandle(syntheticMessageId)); } // Verify runtime is actually reachable after init completes. @@ -1797,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", @@ -1880,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, }); @@ -3006,7 +3001,7 @@ export class AIService extends EventEmitter { }); if (combinedAbortSignal.aborted) { - return Ok(undefined); + return Ok(this.createAbortedTurnHandle(assistantMessageId)); } const requestHistorySequence = providerRequestMessages.reduce( @@ -3056,12 +3051,16 @@ export class AIService extends EventEmitter { emit: (event, data) => this.emit(event, data), }; + // Simulations emit their synthetic events before returning, so the + // handle settles immediately with the matching terminal outcome. if (forceContextLimitError) { - await simulateContextLimitError(simulationCtx, this.historyService); - } else { - await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); + const streamError = await simulateContextLimitError(simulationCtx, this.historyService); + return Ok( + this.createSettledTurnHandle(assistantMessageId, { status: "failed", streamError }) + ); } - return Ok(undefined); + await simulateToolPolicyNoop(simulationCtx, effectiveToolPolicy, this.historyService); + return Ok(this.createSettledTurnHandle(assistantMessageId, { status: "completed" })); } // Build provider options based on thinking level and request-sliced message history. @@ -3336,7 +3335,7 @@ export class AIService extends EventEmitter { if (combinedAbortSignal.aborted) { await deleteAbortedPlaceholder(assistantMessageId); - return Ok(undefined); + return Ok(this.createAbortedTurnHandle(assistantMessageId)); } // Capture request payload for the debug modal, then delegate to StreamManager. @@ -4097,43 +4096,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 +4138,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 +4202,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/backgroundProcessExecutor.test.ts b/src/node/services/backgroundProcessExecutor.test.ts index 7f0e81900ec..949d68f3405 100644 --- a/src/node/services/backgroundProcessExecutor.test.ts +++ b/src/node/services/backgroundProcessExecutor.test.ts @@ -5,24 +5,9 @@ import * as path from "path"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; 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); - } - - mapPathForExec(filePath: string): string { - return filePath.startsWith(this.hostPrefix) - ? this.execPrefix + filePath.slice(this.hostPrefix.length) - : filePath; - } -} - /** * 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 @@ -112,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 9e7e2043f8a..ffcccc0d4ce 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,12 +230,19 @@ export async function spawnProcess( }; } - // Build wrapper script (same for all runtimes now that paths are absolute) - // Note: buildWrapperScript handles quoting internally via shellQuote + // 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: execCwd, - env: { ...options.env, ...NON_INTERACTIVE_ENV_VARS }, + cwd: options.cwd, + cwdEnvVar: BACKGROUND_CWD_ENV, + env: wrapperEnv, script, }); @@ -241,6 +256,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 { 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/hooks.test.ts b/src/node/services/hooks.test.ts index 4f9463c6176..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"; @@ -12,22 +12,7 @@ import { runPostHook, } from "./hooks"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; - -class ExecPathMappingRuntime extends LocalRuntime { - constructor( - projectPath: string, - private readonly hostPrefix: string, - private readonly execPrefix: string - ) { - super(projectPath); - } - - mapPathForExec(filePath: string): string { - return filePath.startsWith(this.hostPrefix) - ? this.execPrefix + filePath.slice(this.hostPrefix.length) - : filePath; - } -} +import { ExecPathMappingRuntime } from "./testExecPathMappingRuntime"; describe("hooks", () => { let tempDir: string; @@ -43,38 +28,39 @@ 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"); - - 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") - ); - const statPaths = statSpy.mock.calls.map(([filePath]) => filePath); - expect(statPaths).toContain(hookPath); - expect(statPaths).toContain(toolEnvPath); + const mappingRuntime = new ExecPathMappingRuntime(tempDir, tempDir, "/workspaces/project"); + expect(await getHookPath(mappingRuntime, tempDir)).toBe(hookPath); + expect(await getToolEnvPath(mappingRuntime, tempDir)).toBe(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..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"; @@ -25,19 +26,18 @@ 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 { - // 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"`; } -/** - * 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 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 +85,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 +275,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: resolveExecProjectDir(runtime, context.projectDir), XUM_EXEC: execMarker, }; if (toolInputPath) { @@ -315,11 +313,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) { @@ -330,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, }); @@ -510,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, }); @@ -623,7 +620,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: resolveExecProjectDir(runtime, context.projectDir), }; if (toolInputPath) { canonicalHookEnv.XUM_TOOL_INPUT_PATH = toolInputPath; @@ -631,9 +627,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 +716,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: resolveExecProjectDir(runtime, context.projectDir), XUM_TOOL_RESULT: resultEnv, }; if (toolInputPath) { @@ -735,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, }); @@ -745,9 +741,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, }); @@ -803,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/mock/mockAiStreamPlayer.test.ts b/src/node/services/mock/mockAiStreamPlayer.test.ts index c6c3c6a1fd6..8417f0b1eb2 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,8 @@ describe("MockAiStreamPlayer", () => { workspaceId ); expect(secondResult.success).toBe(true); + if (!secondResult.success || !secondResult.data) throw new Error("expected a stream handle"); + expect(await secondResult.data.completion).toMatchObject({ status: "failed" }); // Read back all messages and check the assistant placeholders const allResult = await historyService.getLastMessages(workspaceId, 100); @@ -684,6 +688,8 @@ 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"); + expect(await playResult.data.completion).toMatchObject({ status: "completed" }); const partial = await historyService.readPartial(workspaceId); expect(partial).toBeNull(); @@ -800,5 +806,7 @@ describe("MockAiStreamPlayer", () => { expect(deltaCount).toBe(deltasAtStop); expect(abortCount).toBe(1); + if (!playResult.success || !playResult.data) throw new Error("expected a stream handle"); + expect(await playResult.data.completion).toMatchObject({ status: "aborted" }); }); }); diff --git a/src/node/services/mock/mockAiStreamPlayer.ts b/src/node/services/mock/mockAiStreamPlayer.ts index 62193d7cfa8..59bb7d403b9 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,10 @@ export class MockAiStreamPlayer { errorType: payload.errorType, }) ); + active.settleCompletion({ + status: "failed", + streamError: { messageId, error: payload.error, errorType: payload.errorType }, + }); this.cleanup(workspaceId); break; } @@ -868,6 +883,7 @@ export class MockAiStreamPlayer { if (!this.isCurrentActiveStream(workspaceId, active)) return; this.deps.aiService.emit("stream-end", payload); + active.settleCompletion({ status: "completed" }); this.cleanup(workspaceId); break; } @@ -879,6 +895,8 @@ export class MockAiStreamPlayer { if (!active) return; active.cancelled = true; + // Settle-once backstop: terminal events settled above; cancels settle here. + active.settleCompletion({ status: "aborted", abortReason: "user" }); if (active.partialWriteTimer) { clearTimeout(active.partialWriteTimer); 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); diff --git a/src/node/services/streamManager.modelOnlyNotifications.test.ts b/src/node/services/streamManager.modelOnlyNotifications.test.ts index 6269c177df3..93369e4db45 100644 --- a/src/node/services/streamManager.modelOnlyNotifications.test.ts +++ b/src/node/services/streamManager.modelOnlyNotifications.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { StreamManager } from "./streamManager"; +import { onTurnEngineEvent } from "./streamManager.testHarness"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; @@ -29,9 +30,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 +120,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..b2105e4d8bb 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 TurnExecutionOptions, +} from "./streamManager"; import type { ActiveTurnThinkingOverride, RebuildFirstStepForThinkingLevel, @@ -45,6 +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"; +import { onTurnEngineEvent } from "./streamManager.testHarness"; function createTestLanguageModel(modelId = "cleanup-model"): LanguageModel { return { @@ -58,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()) { @@ -106,6 +111,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, @@ -237,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); @@ -317,7 +369,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 +431,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 +481,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 +517,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< @@ -1015,7 +1075,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); @@ -1029,8 +1089,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; @@ -1054,21 +1114,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); @@ -1127,7 +1181,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 }, @@ -1150,14 +1204,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", () => { @@ -1210,7 +1263,6 @@ describe("StreamManager - OpenAI GPT-5.6 cached system instructions", () => { undefined, () => eligibleProvidersConfig ); - streamManager.on("error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), countTokens: () => Promise.resolve(0), @@ -1355,7 +1407,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 @@ -1397,7 +1449,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), }; @@ -1453,22 +1505,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); @@ -1515,7 +1559,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 @@ -1546,7 +1590,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), }; @@ -1563,21 +1607,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() { @@ -1665,23 +1702,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()); @@ -1726,7 +1747,6 @@ describe("StreamManager - language model cleanup", () => { streamInfoOverrides?: Record; }): Promise { const streamManager = new StreamManager(historyService); - streamManager.on("error", () => undefined); const historySequence = 1; await appendPartialAssistantForTests(params.workspaceId, params.messageId, historySequence); @@ -1863,15 +1883,12 @@ describe("StreamManager - language model cleanup", () => { abortController.abort(new Error("pre-abort")); const result = await streamManager.startStream( - "cleanup-preabort-workspace", - [{ role: "user", content: "hello" }], - model, - "openai:gpt-4.1-mini", - 1, - "system", - runtime, - "cleanup-preabort-message", - abortController.signal + testStartOptions({ + workspaceId: "cleanup-preabort-workspace", + messageId: "cleanup-preabort-message", + model, + abortSignal: abortController.signal, + }) ); expect(result.success).toBe(true); @@ -1880,10 +1897,9 @@ 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); 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 }; @@ -1898,37 +1914,12 @@ describe("StreamManager - language model cleanup", () => { }; const result = await streamManager.startStream( - workspaceId, - [{ role: "user", content: "hello" }], - model, - "openai:gpt-4.1-mini", - 1, - "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 + testStartOptions({ + workspaceId, + messageId: "constructed-abort-message", + model, + onStreamConstructed, + }) ); expect(result.success).toBe(true); @@ -1952,20 +1943,189 @@ describe("StreamManager - language model cleanup", () => { expect(replaceCreateStreamResult).toBe(true); const result = await streamManager.startStream( - "cleanup-create-throw-workspace", - [{ role: "user", content: "hello" }], - model, - "openai:gpt-4.1-mini", - 1, - "system", - runtime, - "cleanup-create-throw-message" + testStartOptions({ + workspaceId: "cleanup-create-throw-workspace", + messageId: "cleanup-create-throw-message", + model, + }) ); expect(result.success).toBe(false); expect(getCleanupCalls()).toBe(1); }); }); + +describe("StreamManager - turn completion", () => { + function stubTokenTracker(streamManager: StreamManager): void { + Reflect.set(streamManager, "tokenTracker", { + setModel: () => Promise.resolve(), + countTokens: () => Promise.resolve(0), + }); + } + + /** 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; + createStreamResult?: (request: unknown, abortController: AbortController) => unknown; + sink?: (event: TurnEngineEvent) => void | Promise; + events?: TurnEngineEvent[]; + }) { + const streamManager = new StreamManager( + historyService, + undefined, + undefined, + input.sink ?? + ((event) => { + input.events?.push(event); + }) + ); + stubTokenTracker(streamManager); + Reflect.set( + streamManager, + "createStreamResult", + input.createStreamResult ?? (() => createStreamResultForTests(input.fullStream!)) + ); + await appendPartialAssistantForTests(input.workspaceId, input.messageId, 1); + + 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 }; + } + + 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( + 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( + 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({ status: "aborted", abortReason: "startup" }); + }); + + 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", + 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" }); + 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); + + // 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 () => { + let releaseAbortDelivery!: () => void; + const abortDelivery = new Promise((resolve) => { + releaseAbortDelivery = resolve; + }); + 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 handle.completion.then(() => { + settled = true; + }); + await streamManager.stopStream("completion-abort-workspace", { abortReason: "user" }); + await Promise.resolve(); + expect(settled).toBe(false); + + releaseAbortDelivery(); + expect(await handle.completion).toEqual({ status: "aborted", abortReason: "user" }); + }); +}); + describe("StreamManager - stripEncryptedContent", () => { test("strips encryptedContent from array output shape", () => { const output = [ @@ -2039,87 +2199,6 @@ 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); - }); - - // 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; - - streamManager.on("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 }) => { - if (streamStates[data.messageId]) { - streamStates[data.messageId].finished = true; - } - }); - - streamManager.on("stream-abort", (data: { messageId: string }) => { - if (streamStates[data.messageId]) { - streamStates[data.messageId].finished = true; - } - }); - - // Start first stream - const result1 = await streamManager.startStream( - workspaceId, - [{ role: "user", content: "Say hello and nothing else" }], - model, - KNOWN_MODELS.SONNET.id, - 1, - "You are a helpful assistant", - runtime, - "test-msg-1", - undefined, - {} - ); - - 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, - [{ role: "user", content: "Say goodbye and nothing else" }], - model, - KNOWN_MODELS.SONNET.id, - 2, - "You are a helpful assistant", - runtime, - "test-msg-2", - undefined, - {} - ); - - 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 @@ -2192,22 +2271,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"); @@ -2220,13 +2285,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, @@ -2234,7 +2299,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => { processingPromise: Promise.resolve(), }; - workspaceStreams.set(wsId, streamInfo); + workspaceStreams.set(options.workspaceId, streamInfo); return streamInfo; } ); @@ -2265,18 +2330,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 +2362,7 @@ describe("StreamManager - Concurrent Stream Prevention", () => { let processCalled = false; let streamStartEmitted = false; - streamManager.on("stream-start", () => { + onTurnEngineEvent(streamManager, "stream-start", () => { streamStartEmitted = true; }); @@ -2309,76 +2373,36 @@ 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"); - } - - const anthropic = createAnthropic({ apiKey: "dummy-key" }); - const model = anthropic("claude-sonnet-4-5"); + 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 startPromise = streamManager.startStream( - workspaceId, - [{ role: "user", content: "test" }], - model, - KNOWN_MODELS.SONNET.id, - 1, - "system", - runtime, - "test-msg-abort", - abortController.signal, - {} + testStartOptions({ + workspaceId, + messageId: "test-msg-abort", + model: createTestLanguageModel(), + runtime, + abortSignal: abortController.signal, + tools: {}, + }) ); await tempDirStarted; @@ -2386,6 +2410,8 @@ describe("StreamManager - Concurrent Stream Prevention", () => { 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); @@ -2402,10 +2428,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 +2514,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 +2587,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 +2653,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 +2710,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 +2793,6 @@ 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); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -2839,7 +2864,6 @@ describe("StreamManager - empty stream completions", () => { recordHeadlessUsage, } as unknown as SessionUsageService; const streamManager = new StreamManager(historyService, sessionUsageService); - streamManager.on("error", () => undefined); Reflect.set(streamManager, "tokenTracker", { setModel: () => Promise.resolve(undefined), @@ -2911,10 +2935,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 +3034,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 +3187,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 +3324,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 +3417,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 +3501,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 +3641,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 +3801,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 +3918,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 +3995,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 +4157,12 @@ 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); 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 +4876,6 @@ 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); return streamManager; } @@ -4881,17 +4903,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 +4960,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 +5017,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 +5079,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 +5127,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); }); @@ -5264,38 +5277,19 @@ 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); // 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 +5323,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); - streamManager.on("stream-abort", () => undefined); const workspaceId = "abort-usage-workspace"; const messageId = "abort-usage-message"; await appendPartialAssistantForTests(workspaceId, messageId, 1); @@ -5365,7 +5358,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("stream-abort", () => undefined); const workspaceId = "abort-tool-only-workspace"; const usage = { inputTokens: 500, outputTokens: 0, totalTokens: 500 }; @@ -5424,7 +5416,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("stream-abort", () => undefined); const workspaceId = "abort-commit-worthy-workspace"; await appendPartialAssistantForTests(workspaceId, "abort-commit-worthy-message", 1); @@ -5457,7 +5448,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("error", () => undefined); const workspaceId = "error-nondurable-workspace"; const usage = { inputTokens: 900, outputTokens: 0, totalTokens: 900 }; @@ -5504,7 +5494,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("error", () => undefined); const workspaceId = "error-commit-worthy-workspace"; const usage = { inputTokens: 900, outputTokens: 40, totalTokens: 940 }; @@ -5554,7 +5543,6 @@ describe("StreamManager - aborted stream usage persistence", () => { try { const sessionUsageService = new SessionUsageService(config, hs); const streamManager = new StreamManager(hs, sessionUsageService); - streamManager.on("stream-abort", () => undefined); const workspaceId = "abort-abandon-workspace"; const messageId = "abort-abandon-message"; @@ -5600,7 +5588,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 @@ -5636,7 +5624,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), }; @@ -5729,24 +5717,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. @@ -5766,7 +5743,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 @@ -5791,7 +5768,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 @@ -5802,7 +5779,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), }; @@ -5982,50 +5959,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.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; + }; +} diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index a25265be36b..dc168856af2 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,7 +187,102 @@ type ToolCallMap = Map; type WorkspaceId = string & { __brand: "WorkspaceId" }; type StreamToken = string & { __brand: "StreamToken" }; -// Stream request config for start/retry +export type TurnEngineEvent = + | StreamStartEvent + | StreamDeltaEvent + | StreamEndEvent + | StreamAbortEvent + | ErrorEvent + | UsageDeltaEvent + | ToolCallStartEvent + | ToolCallExecutionStartEvent + | ToolCallDeltaEvent + | ToolCallEndEvent + | ReasoningDeltaEvent + | ReasoningEndEvent + | WorkflowRunAttachedEvent; + +export type TurnEngineEventSink = (event: TurnEngineEvent) => void | Promise; + +// Turn identity lives on TurnStreamHandle.messageId; completions cannot diverge from it. +export type TurnCompletion = + | { status: "completed" } + | { status: "aborted"; abortReason: StreamAbortReason } + | { status: "failed"; streamError: StreamErrorPayload & { errorType: StreamErrorType } }; + +export interface TurnStreamHandle { + messageId: string; + completion: Promise; +} + +export interface TurnCompletionController { + promise: Promise; + settle: (completion: TurnCompletion) => void; +} + +export 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); + }, + }; +} + +// 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; + messages: ModelMessage[]; + system: 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; + thinkingOverrideState?: ActiveTurnThinkingOverride; + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel; + forcedFirstStepToolNames?: string[]; + providersConfigSnapshot?: ProvidersConfigMap; + rebuildFirstStepForThinkingLevel?: 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; +}; interface StepMessageTracker { latestMessages?: ModelMessage[]; @@ -651,6 +752,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. @@ -673,7 +776,7 @@ function nextPartTimestamp(streamInfo: WorkspaceStreamInfo): number { * - Atomic stream creation/cancellation operations * - Guaranteed resource cleanup in all code paths */ -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 +784,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 +794,23 @@ 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 { + // 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( @@ -769,7 +884,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 +950,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, @@ -982,11 +1097,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, 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), @@ -1057,7 +1170,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, @@ -1272,7 +1385,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, @@ -1283,7 +1396,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, @@ -1296,7 +1409,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, @@ -1314,7 +1427,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, @@ -1328,7 +1441,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, @@ -1380,7 +1493,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, @@ -1565,8 +1678,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 }, @@ -1577,6 +1691,15 @@ export class StreamManager extends EventEmitter { // Clean up immediately this.workspaceStreams.delete(workspaceId); + 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 }); + }); } /** @@ -1686,33 +1809,31 @@ export class StreamManager extends EventEmitter { : 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 @@ -2046,74 +2167,43 @@ export class StreamManager extends EventEmitter { * 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 + options: TurnExecutionOptions, + ctx: { + streamToken: StreamToken; + runtimeTempDir: string; + abortController: AbortController; + completionController: TurnCompletionController; + } ): WorkspaceStreamInfo { - // abortController is created and linked to the caller-provided abortSignal in startStream(). - - const stepTracker: StepMessageTracker = {}; - const metadataModel = this.resolveMetadataModel(modelString, providersConfigSnapshot); - const request = this.buildStreamRequestConfig( - model, + // ctx.abortController is created and linked to the caller-provided abortSignal in startStream(). + const workspaceId = options.workspaceId as WorkspaceId; + const { + messageId, modelString, - messages, - system, - initialMetadata?.routeProvider, - tools, - providerOptions, + historySequence, + workspaceName, + thinkingLevel, + initialMetadata, + modelFallback, maxOutputTokens, - callSettingsOverrides, - toolPolicy, - hasQueuedMessages, - headers, - anthropicCacheTtlOverride, - onChunk, - onStepMessages, - toolSearchState, - (toolCallId) => this.handleToolExecutionStart(workspaceId, messageId, toolCallId), - thinkingOverrideState, - rebuildProviderOptionsForThinkingLevel, - forcedFirstStepToolNames, - providersConfigSnapshot, - rebuildFirstStepForThinkingLevel - ); + runtime, + } = options; + const stepTracker: StepMessageTracker = {}; + const metadataModel = this.resolveMetadataModel(modelString, options.providersConfigSnapshot); + 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; 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; } @@ -2123,9 +2213,9 @@ export class StreamManager extends EventEmitter { state: StreamState.STARTING, streamResult, workspaceName, - abortController, + abortController: ctx.abortController, messageId, - token: streamToken, + token: ctx.streamToken, startTime, lastPartTimestamp: startTime, toolCompletionTimestamps: new Map(), @@ -2160,7 +2250,8 @@ export class StreamManager extends EventEmitter { partialWritePromise: undefined, // No write in flight initially processingPromise: Promise.resolve(), // Placeholder, overwritten in startStream softInterrupt: { pending: false }, - 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 }, @@ -2250,7 +2341,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, @@ -2390,7 +2481,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, @@ -2402,7 +2493,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, @@ -2436,7 +2527,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, @@ -2476,16 +2567,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( @@ -2833,38 +2926,39 @@ export class StreamManager extends EventEmitter { // 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. @@ -3166,7 +3260,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, @@ -3220,7 +3314,7 @@ export class StreamManager extends EventEmitter { } } - this.emit("reasoning-end", { + this.emitTurnEvent({ type: "reasoning-end", workspaceId: workspaceId as string, messageId: streamInfo.messageId, @@ -3501,7 +3595,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; } @@ -3697,7 +3791,8 @@ 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" }; } break; } catch (error) { @@ -3757,6 +3852,11 @@ export class StreamManager extends EventEmitter { workspaceId: workspaceId as string, messageId: streamInfo.messageId, }); + + if (streamInfo.terminalCompletion != null) { + // Optional-chained: whitebox test fixtures register stream infos without a controller. + streamInfo.completionController?.settle(streamInfo.terminalCompletion); + } } } @@ -3779,7 +3879,8 @@ 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", streamError: persistedPayload }; } private buildStreamErrorPayload( @@ -3914,7 +4015,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 @@ -4008,8 +4109,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( @@ -4395,48 +4497,28 @@ 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, + runtime, + messageId, + abortSignal, + providedStreamToken, + providedRuntimeTempDir, + onStreamConstructed, + } = 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; if (messages.length === 0) { @@ -4479,7 +4561,7 @@ 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); + return settleStartupAbort(); } // Step 3: Create temp directory for this stream using runtime. @@ -4489,43 +4571,16 @@ export class StreamManager extends EventEmitter { providedRuntimeTempDir ?? (await this.createTempDirForStream(streamToken, runtime)); if (streamAbortController.signal.aborted) { - return Ok(streamToken); + return settleStartupAbort(); } // 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 - ); + abortController: streamAbortController, + completionController, + }); // Guard against a narrow race: // - stopStream() may abort while we're between the last aborted-check and stream registration. @@ -4534,7 +4589,7 @@ 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); + return settleStartupAbort(); } streamInfo.unlinkAbortSignal = unlinkAbortSignal; @@ -4559,7 +4614,7 @@ export class StreamManager extends EventEmitter { this.workspaceStreams.delete(typedWorkspaceId); } streamRegistered = false; - return Ok(streamToken); + return settleStartupAbort(); } // Step 5: Track the processing promise for guaranteed cleanup @@ -4572,7 +4627,7 @@ export class StreamManager extends EventEmitter { log.error("Unexpected error in stream processing:", error); }); - return Ok(streamToken); + return Ok(handle); } finally { if (!streamRegistered) { runLanguageModelCleanup(model); @@ -4743,7 +4798,15 @@ 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 + ).catch((error) => { + log.error("Stream-abort delivery failed", { error: getErrorMessage(error) }); + }); return Ok(undefined); } @@ -4954,7 +5017,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); } } @@ -4992,11 +5055,14 @@ export class StreamManager extends EventEmitter { }; // 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", streamError: persistedPayload }; // Wait for the stream processing to complete (cleanup) await streamInfo.processingPromise; 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; } // --------------------------------------------------------------------------- diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index a0e7c5392d9..68e80c83d84 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"; @@ -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: WorkspaceService; - 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 ?? @@ -706,14 +662,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,62 +678,71 @@ 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, 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: 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 Ok({ metadata: createWorkspaceTurnMetadata(projectPath) }); + } + ); +} + +function makeCreateMockReturning(result: Result<{ metadata: WorkspaceMetadata }>) { + return mock((): Promise> => Promise.resolve(result)); +} + function createTaskServiceHarness( config: Config, overrides?: { aiService?: AIService; - workspaceService?: WorkspaceService; + workspaceService?: WorkspaceHost; initStateManager?: InitStateManager; sessionUsageService?: SessionUsageService; workspaceGoalService?: WorkspaceGoalService; @@ -789,7 +752,7 @@ function createTaskServiceHarness( partialService: HistoryService; taskService: TaskService; aiService: AIService; - workspaceService: WorkspaceService; + workspaceService: WorkspaceHost; initStateManager: InitStateManager; } { const historyService = new HistoryService(config); @@ -954,57 +917,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 } : {}), }); @@ -1073,27 +987,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, }); @@ -2602,26 +2496,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, { @@ -2667,9 +2542,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 }); @@ -2730,10 +2604,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, { @@ -2760,9 +2631,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 }); @@ -2795,9 +2665,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 }); @@ -2850,9 +2719,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 }); @@ -2974,9 +2842,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 }); @@ -3029,10 +2896,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)) ); @@ -3066,10 +2930,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, { @@ -3166,10 +3027,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, { @@ -3228,10 +3086,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, { @@ -3570,26 +3425,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, { @@ -3626,26 +3462,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?.(); @@ -3747,26 +3564,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?.(); @@ -3842,10 +3640,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, @@ -3868,10 +3663,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, @@ -3918,26 +3710,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?.(); @@ -4881,26 +4654,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)) @@ -5104,26 +4858,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, { @@ -5414,26 +5149,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, { @@ -10879,26 +10595,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, { @@ -10943,26 +10640,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, { @@ -27668,8 +27346,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, { @@ -30268,7 +29947,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, @@ -30288,7 +29967,6 @@ describe("TaskService", () => { sendMessage, replaceHistory, createModel, - updateAgentStatus, taskService, internal, }; diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 4edddac2c72..0da071a7188 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,8 +230,6 @@ export class AgentReportWaitTimeoutError extends Error { } } -export type AgentTaskStatus = NonNullable; - /** * Resolved per-agent AI settings (canonical model + optional thinking level). * @@ -1601,7 +1603,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 +1708,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 +2241,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 +3958,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 +3990,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 +4338,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 +5552,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 +5587,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 +6470,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,8 +6677,7 @@ export class TaskService { }; } - // Optional chaining: test harnesses mock WorkspaceService 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", @@ -7129,7 +7130,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..b986d923f40 --- /dev/null +++ b/src/node/services/taskWorkspaceSeam.testUtils.ts @@ -0,0 +1,22 @@ +import type { AgentTaskIntegration } from "@/node/services/taskWorkspaceSeam"; + +export function makeAgentTaskIntegrationFake( + overrides: Partial = {} +): 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..f3cb1e84f7b --- /dev/null +++ b/src/node/services/taskWorkspaceSeam.ts @@ -0,0 +1,308 @@ +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 { 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"; +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; + +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; +} + +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; +} + +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; +} + +export interface WorkspaceHost { + acquirePreInterruptionArchiveHold( + workspaceId: string, + options: { + queuedDelegatedTurnCount: number; + expectedDelegatedTurnCorrelations: readonly WorkspaceTurnTaskCorrelation[]; + } + ): Result; + archive( + workspaceId: string, + acknowledgedUntrackedPaths?: string[], + options?: ArchiveWorkspaceOptions + ): Promise>; + archiveWhileTaskTreeLocked( + workspaceId: string, + acknowledgedUntrackedPaths?: string[], + options?: ArchiveWorkspaceOptions + ): 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): WorkspaceLiveActivity; + 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?: SendMessageInternalOptions + ): 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/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)]) + ), + }); + } +} 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/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/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/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 73f3efc9b3f..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"; @@ -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"; @@ -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, @@ -11886,66 +11888,43 @@ 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()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + }) + ); const result = await workspaceService.sendMessage("test-workspace", "hello", { model: "openai:gpt-4o-mini", 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 () => { @@ -11953,11 +11932,12 @@ 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, + }) + ); const startupFailureHandled = createDeferred(); fakeSession.sendMessage.mockImplementation( @@ -11992,85 +11972,62 @@ 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.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); - - 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()); - workspaceService.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + makeAgentTaskIntegrationFake({ + markInterruptedTaskRunning, + restoreInterruptedTaskAfterResumeFailure, + }) + ); const result = await workspaceService.resumeStream("test-workspace", { model: "openai:gpt-4o-mini", 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.setTaskService({ - getAgentTaskStatus, - markInterruptedTaskRunning, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); - - 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.setTaskService({ - getAgentTaskStatus, - markInterruptedTaskRunning, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + 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") { @@ -12078,50 +12035,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.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - resetAutoResumeCount, - } as unknown as TaskService); - - 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.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - resetAutoResumeCount, - } as unknown as TaskService); + workspaceService.setAgentTaskIntegration( + 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 () => { @@ -12328,206 +12276,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.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); - - 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.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); - - 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.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); - - 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.setTaskService({ - getAgentTaskStatus: mock(() => "running" as const), - backgroundForegroundWaitsForWorkspace, - } as unknown as TaskService); - - 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.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); - - 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.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); - - 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.setTaskService({ - markInterruptedTaskRunning, - restoreInterruptedTaskAfterResumeFailure, - resetAutoResumeCount: mock(() => undefined), - } as unknown as TaskService); - - 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( @@ -15192,23 +14996,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( @@ -16517,24 +16328,13 @@ describe("WorkspaceService archive lifecycle hooks", () => { await cleanupHistory(); }); - test("archive coordinates through the task-tree lifecycle lock", async () => { - const withTaskTreeLifecycleLock = mock( - (_: string, operation: () => Promise): Promise => operation() - ); - 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)); - }); - 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); @@ -16728,21 +16528,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.setTaskService({ - cleanupReportedDescendantsAfterArchive, - hasActiveDescendantAgentTasksForWorkspace: () => false, - } as unknown as TaskService); - - 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( @@ -16975,13 +16760,11 @@ 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({ + hasActiveTopLevelWorkflowRunsForWorkspace: mock(() => Promise.resolve(true)), + }) + ); const result = await workspaceService.archive(workspaceId, undefined, { refuseLiveUserActivity: true, @@ -20649,11 +20432,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..492412ac31b 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 { @@ -305,7 +304,15 @@ 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 ArchiveWorkspaceOptions, + type SendMessageInternalOptions, + type WorkspaceHost, + type WorkspaceLiveActivity, +} from "@/node/services/taskWorkspaceSeam"; import { findWorkspaceEntry } from "@/node/services/taskUtils"; import type { WorktreeArchiveSnapshotService } from "@/node/services/worktreeArchiveSnapshotService"; @@ -607,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"; @@ -667,18 +627,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 +641,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 +1817,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 +2314,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 +3341,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 +3405,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 +3687,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,9 +6142,10 @@ export class WorkspaceService extends EventEmitter { workspaceId: string, operation: () => Promise ): Promise { - const taskService = this.taskService; - const withLock = taskService?.withTaskTreeLifecycleLock?.bind(taskService); - return withLock == null ? await operation() : await withLock(workspaceId, operation); + const integration = this.agentTaskIntegration; + return integration == null + ? await operation() + : await integration.withTaskTreeLifecycleLock(workspaceId, operation); } async remove(workspaceId: string, force = false): Promise> { @@ -6227,9 +6155,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 +6193,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 +8340,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); } @@ -8888,19 +8818,7 @@ export class WorkspaceService extends EventEmitter { * 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: @@ -9061,7 +8979,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 +9072,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 +9099,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 +9449,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). */ @@ -11200,60 +11122,7 @@ export class WorkspaceService extends EventEmitter { 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, @@ -11367,7 +11236,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 +11411,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 +11515,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 +11537,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 +11548,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 +11561,7 @@ 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 +11630,7 @@ 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 +11665,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 +11775,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 +11805,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 +11829,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 +11860,7 @@ 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 +11876,7 @@ 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 +11891,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 +11966,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 +11978,7 @@ 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); + releaseHardStopLatch = this.agentTaskIntegration?.latchHardInterruptCascade(workspaceId); } const session = this.getOrCreateSession(workspaceId); @@ -12118,7 +11986,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 +12004,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 +12023,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 +12036,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); 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..985fc71811a 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,56 +225,19 @@ 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 } } -/** - * 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