mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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}}}}
|
||||
|
||||
@@ -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}}}}
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 } : {}),
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user