diff --git a/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl index ca15050c..b6f482e1 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/bash-background.jsonl @@ -13,7 +13,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"sleep 100","description":"Wait","run_in_background":true},"sessionUpdate":"tool_call_update","toolCallId":"toolu_bg"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"Q29tbWFuZCBydW5uaW5nIGluIGJhY2tncm91bmQgd2l0aCBJRDogYmFzaF8xLiBPdXRwdXQgaXMgYmVpbmcgd3JpdHRlbiB0bzogL3RtcC90YXNrcy9iYXNoXzEub3V0cHV0LiBZb3Ugd2lsbCBiZSBub3RpZmllZCB3aGVuIGl0IGNvbXBsZXRlcy4="},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl index eafc59f4..8c9e40af 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/permission-denied.jsonl @@ -12,7 +12,7 @@ {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"rawInput":{"command":"rm -rf /"},"sessionUpdate":"tool_call_update","title":"rm -rf /","toolCallId":"toolu_denied"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash","toolResponse":{"decisionReason":"Denied by rule Bash(rm:*)","decisionReasonType":"rule","message":"Permission to use Bash has been denied."}}},"content":[{"content":{"text":"Permission denied: Denied by rule Bash(rm:*)","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_denied"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"UGVybWlzc2lvbiB0byB1c2UgQmFzaCBoYXMgYmVlbiBkZW5pZWQu"},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_denied"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"sessionUpdate":"state_update","state":"idle","stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl index e1cf40fc..95af6a48 100644 --- a/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/v2/subagent-native-sessions.jsonl @@ -25,7 +25,7 @@ {"kind":"request","method":"session/request_permission","payload":{"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"","subject":{"toolCall":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"content":[{"terminalId":"toolu_native_bash","type":"terminal"}],"kind":"execute","name":"Bash","rawInput":{"command":"make","description":"Build"},"status":"pending","title":"make","toolCallId":"toolu_native_bash"},"type":"tool_call"},"title":"make"}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"sessionUpdate":"state_update","state":"running"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"output":{"data":"bWFrZTogKioqIEVycm9yIDI="},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} -{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} +{"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_native","toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native_bash"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Agent"}},"content":[{"content":{"text":"```\nThe build still fails.\n```","type":"text"},"type":"content"}],"rawOutput":[{"text":"The build still fails.","type":"text"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"toolu_native"}}} {"kind":"notification","method":"session/update","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}} diff --git a/src/tests/acp-scenarios/compare.ts b/src/tests/acp-scenarios/compare.ts index a9130de3..69f3eeea 100644 --- a/src/tests/acp-scenarios/compare.ts +++ b/src/tests/acp-scenarios/compare.ts @@ -19,6 +19,10 @@ * - An `available_commands_update` also lists the `mcp` command of the * adapter (see {@link ADAPTER_COMMANDS}), when origin/main did not list * `mcp`. The adapter replaces the text of `/mcp` for every client. + * - A `terminal_exit` has a `null` exit code where origin/main sent a code + * that the tool result does not say (see `CommandOutput.exitCode`): 1 for a + * failure that names no code or an interrupted command, 0 for a + * backgrounded command. */ import type { Recorded } from "./harness.js"; @@ -159,6 +163,32 @@ function withoutAdapterCommands(want: Json, got: Json): Json { }; } +/** + * `wanted` with the unknown exit code of `actual`, when origin/main sent a code + * in the `terminal_exit` of the same tool call. + */ +function withUnknownExitCode(wanted: Recorded, actual: Recorded | undefined): Recorded { + const want = updateOf(wanted); + const got = actual ? updateOf(actual) : undefined; + const wantExit = (want?._meta as Json | undefined)?.terminal_exit as Json | undefined; + const gotExit = (got?._meta as Json | undefined)?.terminal_exit as Json | undefined; + if ( + !want || + !wantExit || + !gotExit || + got?.toolCallId !== want.toolCallId || + typeof wantExit.exit_code !== "number" || + gotExit.exit_code !== null + ) { + return wanted; + } + const update = { + ...want, + _meta: { ...(want._meta as Json), terminal_exit: { ...wantExit, exit_code: null } }, + }; + return { ...wanted, payload: { ...(wanted.payload as Json), update } } as Recorded; +} + function isAppended(key: string): boolean { return APPENDED_META_KEYS.has(key.slice("_meta.".length)); } @@ -263,7 +293,8 @@ export function compareWithBaseline(baseline: Recorded[], current: Recorded[]): return true; }; - for (const wanted of expected) { + for (const recorded of expected) { + const wanted = withUnknownExitCode(recorded, current[next]); if (matches(wanted, current[next])) { remember(updateOf(wanted)); next++; diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 5da8fac8..17d6456e 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -1317,6 +1317,25 @@ describe("ACP v2 session translation", () => { expect(terminals.split("s", hook)).toEqual({ terminal: [], report: hook }); }); + it("leaves an exit code that the agent does not know out of the terminal's exit", () => { + const terminals = new V2Terminals(); + terminals.split("s", { + sessionUpdate: "tool_call", + toolCallId: "toolu_bash", + title: "Terminal", + _meta: { terminal_info: { terminal_id: "term" } }, + }); + const { terminal } = terminals.split("s", { + sessionUpdate: "tool_call_update", + toolCallId: "toolu_bash", + status: "failed", + _meta: { terminal_exit: { terminal_id: "term", exit_code: null, signal: null } }, + }); + expect(terminal).toEqual([ + { sessionUpdate: "terminal_update", terminalId: "term", exitStatus: {} }, + ]); + }); + it("forgets the terminals of a closed session", () => { const terminals = new V2Terminals(); const named = (sessionId: string) => diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 3ffbf1dd..b276dcd3 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -920,11 +920,12 @@ describe("Bash terminal output", () => { }); }); - it("should route failed commands through the terminal when supportsTerminalOutput is true", () => { + it("should route failed commands through the terminal with the code their text names", () => { + // Claude Code's text of a command that exited with a failing code. const toolResult: ToolResultBlockParam = { type: "tool_result", tool_use_id: "toolu_bash", - content: "some error output", + content: "Exit code 2\nsome error output", is_error: true, }; const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); @@ -932,8 +933,8 @@ describe("Bash terminal output", () => { expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); expect(update._meta).toEqual({ terminal_info: { terminal_id: "toolu_bash" }, - terminal_output: { terminal_id: "toolu_bash", data: "some error output" }, - terminal_exit: { terminal_id: "toolu_bash", exit_code: 1, signal: null }, + terminal_output: { terminal_id: "toolu_bash", data: "Exit code 2\nsome error output" }, + terminal_exit: { terminal_id: "toolu_bash", exit_code: 2, signal: null }, }); }); @@ -1050,13 +1051,13 @@ describe("Bash terminal output", () => { const update = toolUpdateFromToolResult(toolResult, bashToolUse, true); // Failed Bash commands skip the early error return and reach the Bash - // case so the client receives terminal output with a non-zero exit code - // instead of plain markdown details. + // case so the client receives terminal output instead of plain + // markdown details. A failure whose text names no exit code has none. expect(update.content).toEqual([{ type: "terminal", terminalId: "toolu_bash" }]); expect(update._meta).toEqual({ terminal_info: { terminal_id: "toolu_bash" }, terminal_output: { terminal_id: "toolu_bash", data: "command not found: bad_cmd" }, - terminal_exit: { terminal_id: "toolu_bash", exit_code: 1, signal: null }, + terminal_exit: { terminal_id: "toolu_bash", exit_code: null, signal: null }, }); }); @@ -2393,7 +2394,7 @@ describe("PowerShell terminal output", () => { _meta: { terminal_exit: { terminal_id: id, - exit_code: 1, + exit_code: null, signal: null, }, }, @@ -4076,7 +4077,7 @@ describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => { ]); }); - it("re-establishes the abort notice and a failing exit code for interrupted commands", () => { + it("re-establishes the abort notice and no exit code for interrupted commands", () => { const update = toolUpdateFromToolResult(rawWithHint, bashToolUse, true, { ...structured, stdout: "partial output", @@ -4089,7 +4090,7 @@ describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => { }); expect(update._meta?.terminal_exit).toEqual({ terminal_id: "toolu_bash", - exit_code: 1, + exit_code: null, signal: null, }); }); @@ -4107,6 +4108,67 @@ describe("structured tool_use_result rendering (Read/Bash/WebSearch)", () => { data: "clipped stdout\n[Output truncated (38100 bytes total): full output saved to /tmp/tool-results/abc.txt]", }); }); + + describe("exit codes", () => { + const text = (content: string, is_error = false): ToolResultBlockParam => ({ + type: "tool_result", + tool_use_id: "toolu_bash", + content, + is_error, + }); + const exitCode = ( + clientCapabilities: ClientCapabilities, + result: ToolResultBlockParam, + toolUseResult?: unknown, + ) => + new AcpToolCallRenderer(ToolCallCapabilities.from(clientCapabilities)).resultFields( + bashToolUse, + result as Parameters[1], + toolUseResult, + )._meta?.terminal_exit?.exit_code; + const zed: ClientCapabilities = { _meta: { terminal_output: true } }; + const air: ClientCapabilities = { + _meta: { ...AIR_CLIENT._meta, terminal_output_delta: true }, + }; + + // The command, its result, the code that Zed gets (null when the result + // does not say it), and the number that AIR always got. + it.each<[string, ToolResultBlockParam, unknown, number | null, number]>([ + ["a success", text("pushed ok"), structured, 0, 0], + ["a failure that names its code", text("Exit code 2\nboom", true), undefined, 2, 1], + [ + "a failure that names no code", + text("Permission to use Bash has been denied.", true), + undefined, + null, + 1, + ], + [ + "an interrupted command", + text("partial"), + { ...structured, stdout: "partial", interrupted: true }, + null, + 1, + ], + [ + "a backgrounded command", + text("Command running in background with ID: bash_1."), + { ...structured, stdout: "", backgroundTaskId: "bash_1" }, + null, + 0, + ], + [ + "a non-zero code that Claude Code accepted as a success", + text("No matches found"), + { ...structured, stdout: "", returnCodeInterpretation: "No matches found" }, + null, + 0, + ], + ])("%s", (_name, result, toolUseResult, zedCode, airCode) => { + expect(exitCode(zed, result, toolUseResult)).toBe(zedCode); + expect(exitCode(air, result, toolUseResult)).toBe(airCode); + }); + }); }); describe("WebSearch", () => { diff --git a/src/tool-calls/facts.ts b/src/tool-calls/facts.ts index 716fbc81..01d4765b 100644 --- a/src/tool-calls/facts.ts +++ b/src/tool-calls/facts.ts @@ -44,7 +44,14 @@ export interface ToolUseFacts { /** The output of a command, for the terminal channel. */ export interface CommandOutput { output: string; - exitCode: number; + /** + * The exit code of the command, when the tool result says it. Absent when it + * does not: a failure that names no code, an interrupted or backgrounded + * command, or a non-zero code that Claude Code accepted as a success. + */ + exitCode?: number; + /** The command was interrupted before it ended. */ + interrupted?: boolean; } /** diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index 73e9d350..15ba4d0e 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -82,7 +82,11 @@ export type ToolUpdateMeta = { }; terminal_exit?: { terminal_id: string; - exit_code: number; + /** + * Null when the agent does not know the code, as in `TerminalExitStatus`. + * AIR always gets a number. + */ + exit_code: number | null; signal: string | null; }; }; @@ -325,7 +329,17 @@ export class AcpToolCallRenderer { ...(this.capabilities.terminalOutputDelta ? { terminal_output_delta: output } : { terminal_output: output }), - terminal_exit: { terminal_id: terminalId, exit_code: command.exitCode, signal: null }, + terminal_exit: { + terminal_id: terminalId, + // AIR keeps the numbers it always got: 1 for a failed or + // interrupted command, else the exit code, or 0 when unknown. + exit_code: this.capabilities.air.client + ? result.is_error === true || command.interrupted + ? 1 + : (command.exitCode ?? 0) + : (command.exitCode ?? null), + signal: null, + }, }, }; } diff --git a/src/tool-calls/reporters/bash.ts b/src/tool-calls/reporters/bash.ts index b3433215..34076efe 100644 --- a/src/tool-calls/reporters/bash.ts +++ b/src/tool-calls/reporters/bash.ts @@ -36,7 +36,11 @@ export class BashReporter implements ToolReporter { // 4. Array content: text blocks for stdout, or image blocks when the // command produces an image. let output = ""; - let exitCode = isError ? 1 : 0; + // Claude Code accepts a non-zero code as a success only with a + // `returnCodeInterpretation` (grep's "No matches found"), so a success + // without one exited with 0. + let exitCode: number | undefined = isError ? undefined : 0; + let interrupted = false; const structuredBash = structuredResult(structured); if ( @@ -47,12 +51,14 @@ export class BashReporter implements ToolReporter { structuredBash.backgroundTaskId === undefined ) { output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n"); - // The CLI appends its abort marker only to the model-facing text, and an - // aborted command is not a success. + // The CLI appends its abort marker only to the model-facing text. The + // result does not carry the code of the aborted command. if (structuredBash.interrupted) { output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n"); - exitCode = 1; + exitCode = undefined; + interrupted = true; } + if (structuredBash.returnCodeInterpretation !== undefined) exitCode = undefined; // Structured stdout is clipped when the full output was persisted to // disk. Without this note the clip is silent. if (typeof structuredBash.persistedOutputPath === "string") { @@ -89,6 +95,25 @@ export class BashReporter implements ToolReporter { } output = content.map((c: any) => c.text).join("\n"); } - return { command: { output, exitCode } }; + if (isError && exitCode === undefined) exitCode = failureExitCode(output); + // A backgrounded command is still running: the result only announces it. + if (structuredBash?.backgroundTaskId !== undefined) exitCode = undefined; + return { + command: { + output, + ...(exitCode !== undefined ? { exitCode } : {}), + ...(interrupted ? { interrupted } : {}), + }, + }; } } + +/** + * The exit code that the text of a failed command names. Claude Code starts the + * text of a command that exited with a failing code with `Exit code N`. Other + * failures, such as a denial or a command that could not start, name none. + */ +function failureExitCode(text: string): number | undefined { + const match = /^Exit code (\d+)(?:\n|$)/.exec(text); + return match ? Number(match[1]) : undefined; +} diff --git a/src/v2/terminal.ts b/src/v2/terminal.ts index 8c46cd5a..2fba27c4 100644 --- a/src/v2/terminal.ts +++ b/src/v2/terminal.ts @@ -23,7 +23,7 @@ interface TerminalMeta { terminal_info?: { terminal_id: string }; terminal_output?: { terminal_id: string; data: string }; terminal_output_delta?: { terminal_id: string; data: string }; - terminal_exit?: { terminal_id: string; exit_code: number; signal: string | null }; + terminal_exit?: { terminal_id: string; exit_code: number | null; signal: string | null }; } const TERMINAL_KEYS = [ @@ -70,7 +70,7 @@ export class V2Terminals { ...(exit ? { exitStatus: { - exitCode: exit.exit_code, + ...(exit.exit_code != null ? { exitCode: exit.exit_code } : {}), ...(exit.signal != null ? { signal: exit.signal } : {}), }, }