fix(acp): address review of the terminal-card alignment (exit parse, background/error, capability snapshot)

Codex + an independent review pass found three real defects in the prior commit:

1. parseExitStatus could misreport a SUCCESSFUL command as a failure: a clean
   exit 0 appends no marker, so output ending in "[exit code: 5]" (no trailing
   newline) was read as the marker. Anchor the parse to a LEADING newline —
   renderResult always inserts one before a real marker, so a body that merely
   ends in marker-like text no longer matches. A narrow residual (a clean exit 0
   whose final line is exactly the marker) is inherent to the replay-only-sees-
   text design and documented; the complete fix (a structured exit on the event)
   is the RFC's named escape hatch.

2. A run_in_background start and an isError result were rendered as exited
   terminal cards with a false exit-0 pill. A background start returns a task-id
   ack (not a streamed terminal) and is no longer marked terminal; an isError
   result (spawn failure / abort) carries no exit pill.

3. The terminal capability was re-read live on the result path, so a second
   initialize between a call and its result could desync them (orphan
   terminal_output or clobbered card). Snapshot the capability per session at
   creation (SessionRecord.terminalEnabled) so call and result always agree.

Also reword the reference-parity claim: keeping the description as a content
block in terminal mode is a DELIBERATE divergence (claude-agent-acp drops it).
Tests added for each; with-key e2e still green.
This commit is contained in:
Tianyi Cui
2026-06-18 19:35:15 +08:00
parent e51dabbb8b
commit 9f6b96c555
6 files changed
+152 -35

No files matched your search

+59 -29
View File
@@ -139,23 +139,32 @@ export function renderResult(result: BashRunResult): string {
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card, since a terminal card has no description slot — claude-agent-acp
* likewise surfaces its description as a separate content block. `rawInput` still
* carries the bare command for non-execute UIs that DO render it.
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
* shows only the card; surfacing it as a content block is a deliberate
* divergence here — we keep the human summary visible alongside the card.)
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
*
* `terminal` marks the call so a capable UI renders a TERMINAL card. Its `cwd`
* (header) is the model `workdir` when given — ABSOLUTE as-is, RELATIVE for the
* UI bridge to resolve against the session cwd; when omitted entirely the bridge
* fills the session workspace cwd (this PURE presenter, args only, can't see it).
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
* immediately (it never streams a terminal; its output is polled via
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
* execute card. For a foreground run the `terminal.cwd` (header) is the model
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
* against the session cwd; when omitted the bridge fills the session workspace
* cwd (this PURE presenter, args only, can't see it).
*/
function presentBashCall(args: { command: string; description: string; workdir?: string }): ToolCallPresentation {
return {
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
function presentBashCall(args: BashCallArgs): ToolCallPresentation {
const base = {
title: args.command,
kind: 'execute',
kind: 'execute' as const,
rawInput: args.command,
content: [{ type: 'text', text: args.description }],
terminal: args.workdir !== undefined ? { cwd: args.workdir } : {},
content: [{ type: 'text' as const, text: args.description }],
}
// A background start is not an interactive terminal — no terminal card.
if (args.run_in_background === true) return base
return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} }
}
/**
@@ -167,37 +176,58 @@ function presentBashCall(args: { command: string; description: string; workdir?:
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended
* (this parse is the exact inverse of those markers — they co-evolve in this
* file and a round-trip test guards the pair). A non-text result (unexpected for
* bash) falls through to `undefined` (UI keeps the raw result).
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* fall back to the fenced `content` block with no terminal metadata. The bridge's
* orphan guard also drops a result terminal when the call wasn't terminal, so a
* background call (not marked terminal in `presentBashCall`) is doubly safe.
* A non-text result (unexpected for bash) falls through to `undefined`.
*/
function presentBashResult(_args: unknown, result: ToolResult): ToolResultPresentation | undefined {
function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
const raw = block.text
const fenced = raw.replace(/\n+$/, '')
return {
content: [{ type: 'text', text: `\`\`\`console\n${fenced}\n\`\`\`` }],
terminal: { output: raw, ...parseExitStatus(raw) },
}
const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }]
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
// No exit pill / terminal output for a background ack or an errored run.
if (isBackground || result.isError) return { content }
return { content, terminal: { output: raw, ...parseExitStatus(raw) } }
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* a clean run appends neither, so absent both we report `{exitCode:0}`. (A
* trapped-timeout run that exits 0 has no signal/exit marker either and reads as
* exitCode 0, which is accurate — it did exit 0.) `renderResult` always appends
* the exit/signal marker LAST (after any timeout marker) onto a non-empty body,
* so the marker is anchored at end-of-string here — output that merely CONTAINS
* such text earlier is not mistaken for it.
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
*
* Why parse rendered text at all: `presentResult` is replay-safe and on a
* `session/load` the ONLY thing persisted is this content text — the structured
* `BashRunResult` is long gone — so unless the exit were added to the persisted
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
* is the only channel. The match is anchored to a LEADING newline + end-of-string
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
* a non-empty body: a real marker is therefore always its own final line. That
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
*
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
* whose body's FINAL line is itself exactly `\n[exit code: N]` (the program
* printed that line and nothing after) is still indistinguishable from a real
* marker and would show a wrong pill. This is display-only (execution and the
* model-facing text are unaffected) and narrow; the complete fix is to persist a
* structured exit on the result event, which the RFC names as the escape hatch.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\[killed by signal: ([^\]\n]+)\]$/.exec(text)
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\[exit code: (\d+)\]$/.exec(text)
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}