diff --git a/src/browser/features/ChatInput/index.tsx b/src/browser/features/ChatInput/index.tsx index 310a318d69..909ed92d32 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 80d9e47583..05c198db90 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 5233206e50..2cc14ab150 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 ac38aef038..cc0b8129bf 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 ed157332b1..b3bee5b49d 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 152d9739f8..383e6a6948 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 2c3606d921..721d5c1448 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 f4f5cb52bc..6a6f89ff5f 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 fc286a097c..d8d0a95dbe 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 3f0faa359e..21675ce793 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 b96568ffbc..11a743f3eb 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 50e6033164..4720ef97ef 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 7c62e15c63..a71d161879 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 54b2e53033..1e442f56bb 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 5b84e83a8f..db4a1b235b 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 29406cf2eb..96a1aeae80 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 aec25a495e..d8cd7c951d 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 0000000000..9a7d5afaa8 --- /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 b58261eddd..8877797b0e 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 310ce65db4..aa0aba3561 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 51cd6c0cfb..6e1809e962 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 0000000000..edaa4b9da6 --- /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/backgroundProcessExecutor.test.ts b/src/node/services/backgroundProcessExecutor.test.ts index 7f0e81900e..949d68f340 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 9e7e2043f8..ffcccc0d4c 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 38bbb272c9..4f7a10c769 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 { 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 4fc1e7854d..cf733e8246 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/ptc/toolBridge.ts b/src/node/services/ptc/toolBridge.ts index 2ab16c14d6..c897be51cc 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 7f305ae736..3dd4b98e03 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 f48c9fb101..8db94e3968 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.ts b/src/node/services/streamManager.ts index a25265be36..5ee4e80a33 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -982,11 +982,9 @@ export class StreamManager extends EventEmitter { public async createTempDirForStream(streamToken: StreamToken, runtime: Runtime): Promise { const tempDir = `~/.xum-tmp/${streamToken}`; - // Resolve ~ in the runtime's context. - // - // IMPORTANT: On Windows local runtime, Git Bash may use a customized $HOME, - // while runtime.resolvePath expands ~ via Node (USERPROFILE). To avoid drift, - // create the directory using the resolved absolute path. + // Resolve ~ in the runtime's context: callers need the canonical absolute + // path as a value, 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), diff --git a/src/node/services/testExecPathMappingRuntime.ts b/src/node/services/testExecPathMappingRuntime.ts new file mode 100644 index 0000000000..11def556ec --- /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 1a59ade9a1..e92c57d516 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/testHelpers.ts b/src/node/services/tools/testHelpers.ts index 8687f74cad..49ebf0970e 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 e741cbfaf1..6a55c70e18 100644 --- a/src/node/services/tools/xum_agents.test.ts +++ b/src/node/services/tools/xum_agents.test.ts @@ -4,7 +4,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import type { ToolExecutionOptions } from "ai"; -import { LocalRuntime } from "@/node/runtime/LocalRuntime"; +import type { LocalRuntime } from "@/node/runtime/LocalRuntime"; const GLOBAL_WORKSPACE_ID = "workspace-global"; const GLOBAL_WORKSPACE_NAME = "global-scope"; const GLOBAL_WORKSPACE_TITLE = "Global Scope"; @@ -14,7 +14,7 @@ import { FILE_EDIT_DIFF_OMITTED_MESSAGE } from "@/common/types/tools"; import { resolveAgentsPathOnRuntime } from "./xum_agents_path"; import { createXumAgentsReadTool } from "./xum_agents_read"; import { createXumAgentsWriteTool } from "./xum_agents_write"; -import { TestTempDir, createTestToolConfig } from "./testHelpers"; +import { TestTempDir, createTestToolConfig, RemotePathMappedRuntime } from "./testHelpers"; const mockToolCallOptions: ToolExecutionOptions = { toolCallId: "test-call-id", @@ -108,143 +108,6 @@ function mockAgentsPathProbe( }); } -class RemotePathMappedRuntime extends LocalRuntime { - private readonly localWorkspaceRoot: string; - private readonly remoteWorkspaceRoot: string; - private readonly localHomeForTildeRoot: string | null; - - constructor(localWorkspaceRoot: string, remoteWorkspaceRoot: string) { - super(localWorkspaceRoot); - this.localWorkspaceRoot = path.resolve(localWorkspaceRoot); - this.remoteWorkspaceRoot = - remoteWorkspaceRoot === "/" ? remoteWorkspaceRoot : remoteWorkspaceRoot.replace(/\/+$/u, ""); - - if (this.remoteWorkspaceRoot === "~") { - this.localHomeForTildeRoot = this.localWorkspaceRoot; - } else if (this.remoteWorkspaceRoot.startsWith("~/")) { - const homeRelativeSuffix = this.remoteWorkspaceRoot.slice(1); - const normalizedLocalRoot = this.localWorkspaceRoot.replaceAll("\\", "/"); - if (normalizedLocalRoot.endsWith(homeRelativeSuffix)) { - const derivedHome = normalizedLocalRoot.slice( - 0, - normalizedLocalRoot.length - homeRelativeSuffix.length - ); - this.localHomeForTildeRoot = derivedHome.length > 0 ? derivedHome : "/"; - } else { - this.localHomeForTildeRoot = null; - } - } else { - this.localHomeForTildeRoot = null; - } - } - - private usesTildeWorkspaceRoot(): boolean { - return this.remoteWorkspaceRoot === "~" || this.remoteWorkspaceRoot.startsWith("~/"); - } - - private toLocalPath(runtimePath: string): string { - const normalizedRuntimePath = runtimePath.replaceAll("\\", "/"); - - if (normalizedRuntimePath === this.remoteWorkspaceRoot) { - return this.localWorkspaceRoot; - } - - if (normalizedRuntimePath.startsWith(`${this.remoteWorkspaceRoot}/`)) { - const suffix = normalizedRuntimePath.slice(this.remoteWorkspaceRoot.length + 1); - return path.join(this.localWorkspaceRoot, ...suffix.split("/")); - } - - return runtimePath; - } - - private toRemotePath(localPath: string): string { - const resolvedLocalPath = path.resolve(localPath); - - if (resolvedLocalPath === this.localWorkspaceRoot) { - return this.remoteWorkspaceRoot; - } - - const localPrefix = `${this.localWorkspaceRoot}${path.sep}`; - if (resolvedLocalPath.startsWith(localPrefix)) { - const suffix = resolvedLocalPath.slice(localPrefix.length).split(path.sep).join("/"); - return `${this.remoteWorkspaceRoot}/${suffix}`; - } - - return localPath.replaceAll("\\", "/"); - } - - private translateCommandToLocal(command: string): string { - return command - .split(this.remoteWorkspaceRoot) - .join(this.localWorkspaceRoot.replaceAll("\\", "/")); - } - - override normalizePath(targetPath: string, basePath: string): string { - const normalizedBasePath = this.toRemotePath(basePath); - const normalizedTargetPath = targetPath.replaceAll("\\", "/"); - - if (normalizedBasePath === "~" || normalizedBasePath.startsWith("~/")) { - if ( - normalizedTargetPath === "~" || - normalizedTargetPath.startsWith("~/") || - normalizedTargetPath.startsWith("/") - ) { - return normalizedTargetPath; - } - return path.posix.normalize(path.posix.join(normalizedBasePath, normalizedTargetPath)); - } - - return path.posix.resolve(normalizedBasePath, normalizedTargetPath); - } - - override async resolvePath(filePath: string): Promise { - const resolvedLocalPath = await super.resolvePath(this.toLocalPath(filePath)); - return this.toRemotePath(resolvedLocalPath); - } - - override exec( - command: string, - options: Parameters[1] - ): ReturnType { - const usesTildeRoot = this.usesTildeWorkspaceRoot(); - const localHomeForTildeRoot = - this.localHomeForTildeRoot ?? process.env.HOME ?? this.localWorkspaceRoot; - - return super.exec(usesTildeRoot ? command : this.translateCommandToLocal(command), { - ...options, - cwd: this.toLocalPath(options.cwd), - env: usesTildeRoot - ? { - ...(options.env ?? {}), - HOME: localHomeForTildeRoot, - } - : options.env, - }); - } - - override stat(filePath: string, abortSignal?: AbortSignal): ReturnType { - return super.stat(this.toLocalPath(filePath), abortSignal); - } - - override readFile( - filePath: string, - abortSignal?: AbortSignal - ): ReturnType { - return super.readFile(this.toLocalPath(filePath), abortSignal); - } - - override writeFile( - filePath: string, - abortSignal?: AbortSignal - ): ReturnType { - return super.writeFile(this.toLocalPath(filePath), abortSignal); - } - - override ensureDir(dirPath: string): ReturnType { - return super.ensureDir(this.toLocalPath(dirPath)); - } -} - /** Simulates BSD/macOS where readlink doesn't support -f */ class NoReadlinkFRemoteRuntime extends RemotePathMappedRuntime { override exec( diff --git a/src/node/utils/runtime/helpers.test.ts b/src/node/utils/runtime/helpers.test.ts index 9c9a5653b7..63417d607c 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 7b1fcb20ee..985fc71811 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