diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 2a8581db..bb2df6b8 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -1279,6 +1279,69 @@ describe("ACP v2 prompts", () => { expect(statuses.at(-1)).toBe("cancelled"); }); + it("reports a command whose result says interrupted as cancelled", async () => { + scriptTurns(async function* (options) { + yield { + type: "assistant", + message: { + id: "msg_bash", + type: "message", + role: "assistant", + model: "claude-sonnet-4-6", + content: [ + { type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "make" } }, + ], + stop_reason: "tool_use", + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: options.sessionId, + }; + // Claude Code aborted the command for a steering message: a successful + // result whose structured output says `interrupted`. + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_bash", + content: + "Command aborted before executionCommand was aborted before completion", + }, + ], + }, + tool_use_result: { + stdout: "", + stderr: "Command aborted before execution", + interrupted: true, + isImage: false, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: options.sessionId, + }; + yield result(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + + const statuses = client + .updates("tool_call_update") + .filter((update) => update.toolCallId === "toolu_bash" && update.status) + .map((update) => update.status); + expect(statuses.at(-1)).toBe("cancelled"); + }); + it("reports a tool call that Claude dropped before it ran as cancelled", async () => { scriptTurns(async function* (options) { // Claude starts a tool_use, but no complete assistant message holds it, diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 0eb6a9a5..2231bbc2 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -3820,6 +3820,31 @@ describe("tool_result_meta non-execution stamping", () => { }); }); + it("reports an interrupted command as cancelled to a v2 client, and as completed to every other client", () => { + const status = (interrupted: boolean, toolCallCapabilities?: ToolCallCapabilities) => + toAcpNotifications( + [{ type: "tool_result", tool_use_id: "toolu_bash", content: "partial" }] as any, + "user", + "test-session", + { toolu_bash: bashToolUse }, + mockClient, + mockLogger, + { + toolUseResult: { stdout: "partial", stderr: "", interrupted, isImage: false }, + ...(toolCallCapabilities ? { toolCallCapabilities } : {}), + }, + ).at(-1)?.update; + const v2 = ToolCallCapabilities.from({}, { v2: true }); + + expect(status(true, v2)).toMatchObject({ status: "cancelled" }); + expect(status(false, v2)).toMatchObject({ status: "completed" }); + // v1 has no cancelled status. + expect(status(true)).toMatchObject({ status: "completed" }); + expect(status(true, ToolCallCapabilities.from(AIR_CLIENT))).toMatchObject({ + status: "completed", + }); + }); + it("closes a tool call that Claude dropped before it ran as cancelled on v2, and as failed elsewhere", () => { const unfinished = (ran: boolean, capabilities?: ToolCallCapabilities) => new AcpToolCallRenderer(capabilities).unfinished({ toolCallId: "toolu_bash", ran }); diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index 24eee410..14ec773b 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -151,14 +151,22 @@ export class AcpToolCallRenderer { } /** - * The status of a tool result. `nonExecution` is why Claude Code stamped an - * error result instead of the tool's output. A run that was aborted is - * `cancelled` for a v2 client, which has that status; every other client - * gets `failed`, with the kind in `_meta.claudeCode.nonExecutionKind`. + * The status of a tool result. Claude Code reports a run that it aborted in + * one of two ways: an error result whose `nonExecution` kind says so (a + * cancelled turn), or a command result that says `interrupted` (for example, + * a steering message landed just before the command started). Such a run is + * `cancelled` for a v2 client, which has that status. Every other client gets + * `failed` for the error, with the kind in `_meta.claudeCode.nonExecutionKind`, + * and `completed` for the command. */ - resultStatus(isError: boolean, nonExecution: ToolResultMeta | undefined): ToolCallStatus { - if (!isError) return "completed"; - return this.capabilities.v2 && isAbortedToolResult(nonExecution) ? V2_CANCELLED : "failed"; + resultStatus( + isError: boolean, + nonExecution: ToolResultMeta | undefined, + interrupted = false, + ): ToolCallStatus { + const aborted = interrupted || (isError && isAbortedToolResult(nonExecution)); + if (this.capabilities.v2 && aborted) return V2_CANCELLED; + return isError ? "failed" : "completed"; } /** The facts of a tool use. */ @@ -332,7 +340,14 @@ export class AcpToolCallRenderer { /** The result fields of a tool result, before the status and the tool name. */ resultFields(toolUse: ToolUse, result: ResultBlock, structured?: unknown): RenderedResult { - const facts = this.resultFacts(toolUse, result, structured); + return this.fieldsOfResult(toolUse, result, this.resultFacts(toolUse, result, structured)); + } + + private fieldsOfResult( + toolUse: ToolUse, + result: ResultBlock, + facts: ToolResultFacts, + ): RenderedResult { const { command, ...fields } = facts; if (!command) return fields; // The terminal was announced under the tool_use id (see `toolCall`), so the @@ -397,7 +412,8 @@ export class AcpToolCallRenderer { result: ResultBlock, options: { structured?: unknown; nonExecution?: ToolResultMeta } = {}, ): ToolCallUpdate[] { - const { _meta: resultMeta, ...fields } = this.resultFields(toolUse, result, options.structured); + const facts = this.resultFacts(toolUse, result, options.structured); + const { _meta: resultMeta, ...fields } = this.fieldsOfResult(toolUse, result, facts); // AIR shows a read or a search that names a path as the list of viewed // files. That view does not show the text of the result, so it stays out. const viewedFiles = @@ -445,7 +461,11 @@ export class AcpToolCallRenderer { } satisfies ToolUpdateMeta, toolCallId: toolUse.id, sessionUpdate: "tool_call_update", - status: this.resultStatus(result.is_error === true, options.nonExecution), + status: this.resultStatus( + result.is_error === true, + options.nonExecution, + facts.command?.interrupted === true, + ), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(planFilePath ? { rawInput: planFileInput(toolUse.input, planFilePath) } : {}), ...fields,