Files
claude-agent-acp/src/v2/terminal.ts
Ben Brandt 52a92be959 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.
2026-10-05 12:07:37 +02:00

132 lines
5.3 KiB
TypeScript

/**
* Agent-owned display terminals for the commands that the agent runs.
*
* The agent reports a command through the terminal extension of ACP v1
* clients, as codex-acp does: `_meta.terminal_info` names the terminal of a
* tool call, whose content references it; `terminal_output` (a snapshot) or
* `terminal_output_delta` (a chunk) carries the output, and `terminal_exit` the
* exit. ACP v2 standardizes this as display-only terminals: the same content
* reference, and `terminal_update` and `terminal_output_chunk` for the
* terminal's state. The agent reports to a v2 client as to a client of the
* extension (`ToolCallClientCapabilities.from`), and {@link V2Terminals} turns
* the extension's keys into those updates.
*/
import type { ToolCall, ToolCallUpdate } from "@agentclientprotocol/sdk";
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2";
type V1ToolCallReport =
| (ToolCall & { sessionUpdate: "tool_call" })
| (ToolCallUpdate & { sessionUpdate: "tool_call_update" });
/** The `_meta` keys of the terminal extension. */
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 | null; signal: string | null };
}
const TERMINAL_KEYS = [
"terminal_info",
"terminal_output",
"terminal_output_delta",
"terminal_exit",
] as const;
/** The terminal updates of the tool call reports of one v2 connection. */
export class V2Terminals {
/** The terminal and the last reported command of each running command, by session and tool call. */
private readonly running = new Map<string, { terminalId: string; command?: string }>();
/**
* The terminal updates that a tool call report carries in the extension's
* keys, and the report without them, or undefined when nothing else is left
* in it.
*
* The extension names no command, so the terminal gets the command of the
* tool call's `rawInput` once a report carries it: the first report of a
* streamed tool use has no input yet.
*/
split(
sessionId: string,
report: V1ToolCallReport,
): { terminal: v2.SessionUpdate[]; report: V1ToolCallReport | undefined } {
const meta = (report._meta ?? {}) as TerminalMeta;
const key = `${sessionId} ${report.toolCallId}`;
if (meta.terminal_info) this.running.set(key, { terminalId: meta.terminal_info.terminal_id });
const terminal: v2.SessionUpdate[] = [];
const running = this.running.get(key);
if (running) {
const command = commandOf(report.rawInput);
const newCommand = command !== undefined && command !== running.command;
if (newCommand) running.command = command;
const { terminal_output: output, terminal_output_delta: delta, terminal_exit: exit } = meta;
if (meta.terminal_info || newCommand || output || exit) {
terminal.push({
sessionUpdate: "terminal_update",
terminalId: running.terminalId,
...(newCommand ? { command } : {}),
...(output ? { output: { data: base64(output.data) } } : {}),
...(exit
? {
exitStatus: {
...(exit.exit_code != null ? { exitCode: exit.exit_code } : {}),
...(exit.signal != null ? { signal: exit.signal } : {}),
},
}
: {}),
});
}
if (delta) {
terminal.push({
sessionUpdate: "terminal_output_chunk",
terminalId: running.terminalId,
data: base64(delta.data),
});
}
if (exit) this.running.delete(key);
}
return { terminal, report: withoutTerminalKeys(report) };
}
/** Forgets the terminals of a session. */
forget(sessionId: string): void {
for (const key of this.running.keys()) {
if (key.startsWith(`${sessionId} `)) this.running.delete(key);
}
}
}
/**
* The report without the extension's keys, or undefined when it carried
* nothing else but `_meta`: the output report of a command carries the output
* alone, and only the stamps that every report of the tool call repeats. In v2
* a concrete `_meta` replaces the stored one, so sending them would drop the
* other keys that the tool call holds.
*/
function withoutTerminalKeys(report: V1ToolCallReport): V1ToolCallReport | undefined {
if (!report._meta || !TERMINAL_KEYS.some((key) => key in report._meta!)) return report;
const meta = Object.fromEntries(
Object.entries(report._meta).filter(
([key]) => !(TERMINAL_KEYS as readonly string[]).includes(key),
),
);
const stripped = { ...report };
if (Object.keys(meta).length > 0) stripped._meta = meta;
else delete stripped._meta;
const fields = Object.keys(stripped).filter(
(key) => key !== "sessionUpdate" && key !== "toolCallId" && key !== "_meta",
);
return fields.length > 0 || report.sessionUpdate === "tool_call" ? stripped : undefined;
}
function commandOf(rawInput: unknown): string | undefined {
const command = (rawInput as { command?: unknown } | null | undefined)?.command;
return typeof command === "string" && command.length > 0 ? command : undefined;
}
/** The output bytes, base64-encoded as a v2 terminal carries them. */
function base64(text: string): string {
return Buffer.from(text, "utf8").toString("base64");
}