mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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.
132 lines
5.3 KiB
TypeScript
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");
|
|
}
|