fix: report a command's exit code only when the tool result says it (#1252)

Found while mapping commands to ACP v2 display terminals (#1240). It
affects Zed and plain ACP v1 terminal clients and the v2 surface. **AIR
is unchanged.**

## Problem

The agent reported every command's exit with a number, even when it
didn't know the code:
- **any failure as 1.** A command that exited with 2, or a denied
command that never ran, showed "Exited with code 1".
- **a backgrounded command as 0 when it launched**, though it's still
running.
- **an interrupted command as 1**, and a non-zero code that Claude Code
accepted as a success (grep with no matches) as 0.

## Change

`CommandOutput.exitCode` is now optional. `BashReporter` sets it only
when the tool result says it. I checked each source against the Claude
Code 2.1.287 binary:
- **a failure:** the `Exit code N` line that starts Claude Code's text
of a command that exited with a failing code (`rE`/`JPr` for a
`ShellError`). A failure that names none, such as a denial or a spawn
error, has no code.
- **a success:** 0, unless the structured `BashOutput` says otherwise.
Claude Code accepts a non-zero code as a success only with a
`returnCodeInterpretation`, such as grep's "No matches found" or diff's
"Files differ". An interrupted command (an Esc interrupt returns a
success with `interrupted: true`) and a backgrounded one have no code
either.
- **the code-execution block:** its `return_code`, as before.

An unknown code goes out as:
- **v1, Zed and plain clients:** `_meta.terminal_exit.exit_code: null`.
The v1 `TerminalExitStatus` allows null. Zed (`as_u64()` → `None`) and
Delta's v1 adapter (`Option<u32>`) read it as an optional code. Zed then
shows a failed command as failed, without the "Exited with code N"
tooltip, and every other exit as before. `compare.ts` allows the null
code against the origin/main baseline, a documented difference like the
`mcp` command in #1218.
- **v2:** `exitStatus` without `exitCode`, which the v2 schema treats
the same as null.
- **AIR:** the numbers it always got (1 for a failed or interrupted
command, else the code, or 0). Whether AIR accepts a null code is
unchecked, so this stays out of AIR's contract. The command fact carries
`interrupted` for that. The AIR golden files and
`docs/air-extensions.md` don't change.

## For review

- **Interrupted commands** no longer show as failed in Zed, since the
tool call completes and no code is known. Their output still ends with
"[Command was aborted before completion]".

## Not changed

- **A backgrounded command's terminal still exits at launch**, now
without a code. It could stay running until the task's
`task_notification`. That needs the task's end routed to the terminal,
also on replay, so it's a separate change.
- **Replay has no `tool_use_result`** (the same gap as replayed diffs),
so a replayed success still reports 0 when Claude Code interpreted the
code or backgrounded the command.

## Verification

A table test pins, for each kind of exit, the code Zed gets and the
number AIR gets: a success, a failure that names its code, one that
names none, an interrupted, a backgrounded, and an interpreted command.
A unit test covers the v2 `exitStatus` without a code. Removing any one
of the rules, including AIR's, fails tests. Golden files change for 3 v2
scenarios, each on its exit line only. Build, lint and all 2399 tests
pass.
This commit is contained in:
Ben Brandt
2026-10-05 03:07:37 -07:00
committed by GitHub
parent 8589a9cf15
commit 52a92be959
10 changed files with 182 additions and 24 deletions

View File

@@ -13,7 +13,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","update":{"output":{"data":"Q29tbWFuZCBydW5uaW5nIGluIGJhY2tncm91bmQgd2l0aCBJRDogYmFzaF8xLiBPdXRwdXQgaXMgYmVpbmcgd3JpdHRlbiB0bzogL3RtcC90YXNrcy9iYXNoXzEub3V0cHV0LiBZb3Ugd2lsbCBiZSBub3RpZmllZCB3aGVuIGl0IGNvbXBsZXRlcy4="},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":0},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Bash"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_bg"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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}}}}

View File

@@ -12,7 +12,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","update":{"output":{"data":"UGVybWlzc2lvbiB0byB1c2UgQmFzaCBoYXMgYmVlbiBkZW5pZWQu"},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_denied"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","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}}}}

View File

@@ -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":"<session>","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":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"output":{"data":"bWFrZTogKioqIEVycm9yIDI="},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{"exitCode":1},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"exitStatus":{},"sessionUpdate":"terminal_update","terminalId":"toolu_native_bash"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","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":"<session>","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":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}

View File

@@ -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++;

View File

@@ -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) =>

View File

@@ -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<AcpToolCallRenderer["resultFields"]>[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", () => {

View File

@@ -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;
}
/**

View File

@@ -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,
},
},
};
}

View File

@@ -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<BashOutput>(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;
}

View File

@@ -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 } : {}),
},
}