feat(cli): include exitCode in command tool_result events (#11820)

Propagate the command exit code through the JSON event emitter so CLI
consumers can distinguish between successful and failed command
executions without parsing output text.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Estreich 2026-03-02 00:03:05 -08:00 committed by GitHub
parent 7118a14188
commit eb2147148e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 28 additions and 7 deletions

View file

@ -302,7 +302,7 @@ describe("JsonEventEmitter streaming deltas", () => {
emitter.emitCommandOutputChunk("line1\n")
emitter.emitCommandOutputChunk("line1\nline2\n")
emitter.emitCommandOutputDone()
emitter.emitCommandOutputDone(17)
// This completion say is expected from the extension, but should be suppressed
// because we already streamed and completed via commandExecutionStatus.
@ -339,7 +339,7 @@ describe("JsonEventEmitter streaming deltas", () => {
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command" },
tool_result: { name: "execute_command", exitCode: 17 },
done: true,
})
})

View file

@ -310,7 +310,12 @@ export class JsonEventEmitter {
return normalized.startsWith(previous) ? normalized.slice(previous.length) : normalized
}
private emitCommandOutputEvent(commandId: number, fullOutput: string | undefined, isDone: boolean): void {
private emitCommandOutputEvent(
commandId: number,
fullOutput: string | undefined,
isDone: boolean,
exitCode?: number,
): void {
if (this.mode === "stream-json") {
const outputDelta = this.computeCommandOutputDelta(commandId, fullOutput)
const event: JsonEvent = {
@ -324,6 +329,13 @@ export class JsonEventEmitter {
event.tool_result = { name: "execute_command", output: outputDelta }
}
if (isDone && exitCode !== undefined) {
event.tool_result = {
...(event.tool_result ?? { name: "execute_command" }),
exitCode,
}
}
if (isDone) {
event.done = true
this.previousCommandOutputByToolUseId.delete(commandId)
@ -347,7 +359,11 @@ export class JsonEventEmitter {
type: "tool_result",
id: commandId,
subtype: "command",
tool_result: { name: "execute_command", output: fullOutput },
tool_result: {
name: "execute_command",
output: fullOutput,
...(isDone && exitCode !== undefined ? { exitCode } : {}),
},
...(isDone ? { done: true } : {}),
})
@ -371,7 +387,7 @@ export class JsonEventEmitter {
this.emitCommandOutputEvent(commandId, outputSnapshot, false)
}
public emitCommandOutputDone(): void {
public emitCommandOutputDone(exitCode?: number): void {
const commandId = this.activeCommandToolUseId
if (commandId === undefined) {
return
@ -379,7 +395,7 @@ export class JsonEventEmitter {
this.statusDrivenCommandOutputIds.add(commandId)
this.suppressNextCommandOutputSay = true
this.emitCommandOutputEvent(commandId, undefined, true)
this.emitCommandOutputEvent(commandId, undefined, true, exitCode)
}
/**

View file

@ -419,7 +419,11 @@ export async function runStdinStreamMode({ host, jsonEmitter, setStreamRequestId
parsedStatus.status === "timeout" ||
parsedStatus.status === "fallback"
) {
jsonEmitter.emitCommandOutputDone()
const exitCode =
parsedStatus.status === "exited" && typeof parsedStatus.exitCode === "number"
? parsedStatus.exitCode
: undefined
jsonEmitter.emitCommandOutputDone(exitCode)
return
}

View file

@ -115,6 +115,7 @@ export const rooCliToolResultSchema = z.object({
name: z.string(),
output: z.string().optional(),
error: z.string().optional(),
exitCode: z.number().optional(),
})
export type RooCliToolResult = z.infer<typeof rooCliToolResultSchema>