feat(acp): align bash terminal card with reference adapters (command title, description block, exit pill)

Match claude-agent-acp / codex-acp: the bash tool_call title IS the command
(an execute card hides rawInput), the model description rides as a content
text block above the card, and the completed card carries an exit-status pill
via _meta.terminal_exit.

Bridge fixes found in review of the prior terminal-card commit:
- tool_call_update.content is OMITTED in terminal mode (an ACP update.content
  REPLACES the call's content collection in Zed, so the fenced ```console block
  would clobber the terminal content block).
- terminal.output preserves RAW newlines (terminal renderers rely on exact
  bytes); only the fenced fallback trims trailing blank lines.
- a relative workdir is resolved against the session cwd for the card header,
  matching where the command actually ran.
- result-side terminal output is gated on the pending call having registered a
  terminal (no orphan _meta.terminal_output for a terminal Zed never made).

The exit pill is recovered by parsing renderResult's status markers (the pure
presentResult seam sees only content blocks); a round-trip test pins the parse
to the marker emission. Neutral ToolTerminal gains exitCode/signal; widened
ToolCallPresentation with a content block. Docs (RFC + 3 READMEs) updated;
with-key e2e verifies the card + exit pill against the real model.
This commit is contained in:
Tianyi Cui
2026-06-18 18:54:32 +08:00
parent c8dbe6567a
commit e51dabbb8b
12 changed files with 417 additions and 96 deletions

View File

@@ -178,8 +178,8 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
expect(toolCalls.length).toBeGreaterThan(0)
// Tool-call UI quality (the tool owns its presentation): the bash tool's
// `presentCall` sets the title to the model's human-readable `description`
// and the `rawInput` to the exact command — NOT the bare tool name "bash".
// `presentCall` sets the title to the exact command (an execute card hides
// rawInput, so the command IS the title) — NOT the bare tool name "bash".
// A `bash` call must therefore carry an execute kind, a non-"bash" title,
// and a string rawInput (the command). `toolCalls` is already narrowed to
// the `tool_call` shape by the filter above, so these fields are reachable.
@@ -194,7 +194,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
expect((bashCall as { _meta?: unknown })._meta).toBeUndefined()
}, 180_000)
it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta)', async () => {
it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => {
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
spawned = spawnAcpAgent(workdir)
const { client, updates } = spawned
@@ -214,12 +214,19 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
// on _meta.terminal_output.
const bashCall = updates.find(u => u.sessionUpdate === 'tool_call' && u.kind === 'execute')
if (bashCall?.sessionUpdate !== 'tool_call') throw new Error('expected an execute tool_call')
const block = bashCall.content?.[0] as { type: string; terminalId?: string } | undefined
expect(block?.type).toBe('terminal')
expect(typeof block?.terminalId).toBe('string')
// The content carries the description text block AND a terminal block (the
// description renders above the card) — find the terminal block by type, not
// by position.
const blocks = (bashCall.content ?? []) as { type: string; terminalId?: string }[]
const terminalBlock = blocks.find(b => b.type === 'terminal')
expect(terminalBlock).toBeDefined()
expect(typeof terminalBlock?.terminalId).toBe('string')
const info = (bashCall._meta as { terminal_info?: { terminal_id: string; cwd?: string } }).terminal_info
expect(info?.cwd).toBe(workdir)
const updatesForTerminal = updates.filter(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_output?: unknown } | undefined)?.terminal_output !== undefined)
expect(updatesForTerminal.length).toBeGreaterThan(0)
// The completed update also carries the parsed exit on _meta.terminal_exit.
const exitUpdate = updates.find(u => u.sessionUpdate === 'tool_call_update' && (u._meta as { terminal_exit?: unknown } | undefined)?.terminal_exit !== undefined)
expect(exitUpdate).toBeDefined()
}, 180_000)
})