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

@@ -81,7 +81,10 @@ describe('acp bridge — session/load replay', () => {
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
expect(call).toMatchObject({ toolCallId: 'c1', title: 'Print a greeting — echo hello', kind: 'execute', rawInput: 'echo hello' })
expect(call).toMatchObject({ toolCallId: 'c1', title: 'echo hello', kind: 'execute', rawInput: 'echo hello' })
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Capability OFF on this loader: the description renders as a content block, no terminal block.
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
expect(update?.sessionUpdate).toBe('tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
@@ -90,6 +93,42 @@ describe('acp bridge — session/load replay', () => {
expect(content[0]?.content.text).toBe('```console\nhello\n```')
})
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
// The presentation is resolved at replay time, so a loader that advertised
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
// from the persisted log — identical to how it would have streamed live.
live = await makeBridgeHarness({
storageDir,
withBash: true,
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
})
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
await live.dispose()
live = undefined
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Replay reconstructs the terminal card: description block, then terminal block.
expect(call.content).toEqual([
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
])
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
// Terminal mode: content omitted, output + exit on _meta — matching live.
expect(update.content).toBeUndefined()
const meta = update._meta as { terminal_output?: { data: string }; terminal_exit?: { exit_code?: number } }
expect(meta.terminal_output?.data).toBe('hi\n')
expect(meta.terminal_exit?.exit_code).toBe(0)
})
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
// A session/load is mid-resume() when the client transport closes. The load
// must NOT end up with a live registered agent for the connection that is

View File

@@ -281,6 +281,116 @@ describe('ToolPresenter (tool-owned presentation via the tool registry)', () =>
})
})
describe('terminal-card mapping (capability-gated)', () => {
// A tool that asks to render as a terminal — a stand-in for tool-bash's shape,
// letting us drive the bridge's terminal mapping without the real executor.
type CallTerm = { cwd?: string } | undefined
type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined
const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({
name: 'bash',
description: 'run a command',
parameters: {},
execute: async () => [],
presentCall: (args: unknown) => ({
title: (args as { command: string }).command,
kind: 'execute',
rawInput: (args as { command: string }).command,
content: [{ type: 'text', text: (args as { description: string }).description }],
...callTerminal !== undefined ? { terminal: callTerminal } : {},
}),
presentResult: () => ({
content: [{ type: 'text', text: 'fallback' }],
...resultTerminal !== undefined ? { terminal: resultTerminal } : {},
}),
})
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
const presenter = new ToolPresenter(registryOf(tool))
const out: SessionNotification['update'][] = []
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd })
return out
}
it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
expect(call).toMatchObject({
sessionUpdate: 'tool_call',
content: [
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
],
_meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } },
})
// The update OMITS content (it would clobber the terminal block) and carries output + exit.
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
_meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
})
})
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
// Relative workdir resolved against the session cwd — the card header matches
// where execution actually ran (tool-bash resolves the same way).
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
})
it('capability ON: a signal kill maps to terminal_exit.signal', () => {
const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' })
})
it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => {
// A terminal-rendering tool that reports no structured exit (neither exitCode
// nor signal) — the card shows output but no exit pill.
const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent)
const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' })
expect(meta.terminal_exit).toBeUndefined()
})
it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => {
const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
expect(call).toEqual({
sessionUpdate: 'tool_call',
toolCallId: 'c1',
title: 'echo hi',
kind: 'execute',
status: 'in_progress',
rawInput: 'echo hi',
content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
})
expect(update).toEqual({
sessionUpdate: 'tool_call_update',
toolCallId: 'c1',
status: 'completed',
content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }],
})
})
it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => {
// presentCall declares NO terminal, but presentResult returns one — the
// bridge must not emit _meta.terminal_output for a terminal Zed never made.
const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
// The call had no terminal → ordinary tool_call (description content, no _meta).
expect((call as { _meta?: unknown })._meta).toBeUndefined()
expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }])
// The result falls back to text content; NO terminal _meta.
expect((update as { _meta?: unknown })._meta).toBeUndefined()
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }])
})
})
describe('agentOptions', () => {
it('includes only the fields present in config', () => {
expect(agentOptions({})).toEqual({})

View File

@@ -75,7 +75,7 @@ describe('acp bridge — turn outcomes', () => {
expect(callIdx).toBeLessThan(updIdx)
})
it('the REAL bash tool drives the tool-call UI end-to-end: description—command title + console output', async () => {
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
// stand-in, so this verifies the actual presentCall/presentResult the editor
// sees (AGENTS.md "prefer the real implementation over a mock in tests").
@@ -93,16 +93,20 @@ describe('acp bridge — turn outcomes', () => {
const sessionId = await newSession(harness)
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
// presentCall: execute kind, title is "description — command" (an execute
// card hides rawInput, so the command rides in the title), command in rawInput.
// presentCall: execute kind, title IS the command (an execute card hides
// rawInput, so the command is the title), the description rides as a content
// text block, the command is also rawInput for non-terminal UIs.
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
expect(call).toMatchObject({
toolCallId: 'c1',
title: 'Print a greeting — echo hello',
title: 'echo hello',
kind: 'execute',
rawInput: 'echo hello',
status: 'in_progress',
})
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// Capability OFF: the description renders as the only content block (no terminal block).
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
// presentResult: the REAL command output, wrapped in a fenced console block.
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
expect(update?.sessionUpdate).toBe('tool_call_update')
@@ -115,11 +119,12 @@ describe('acp bridge — turn outcomes', () => {
expect((update as { _meta?: unknown })._meta).toBeUndefined()
})
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta)', async () => {
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
// capability in initialize. The bridge must then emit the terminal CARD: a
// terminal content block + `_meta.terminal_info` (cwd header) on the call,
// and `_meta.terminal_output`/`terminal_exit` on the result.
// capability in initialize. The bridge must then emit the terminal CARD: the
// description content block THEN a terminal content block + `_meta.terminal_info`
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
// result — and OMIT the update's text content (it would clobber the card).
harness = await makeBridgeHarness({
storageDir,
withBash: true,
@@ -131,18 +136,27 @@ describe('acp bridge — turn outcomes', () => {
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
// A terminal content block keyed by the callId, and terminal_info with the
// The description content block FIRST (renders above the card), then a
// terminal content block keyed by the callId; terminal_info carries the
// session cwd (the bridge fills it from the session header).
expect(call.content).toEqual([{ type: 'terminal', terminalId: 'c1' }])
expect(call.content).toEqual([
{ type: 'content', content: { type: 'text', text: 'Greet' } },
{ type: 'terminal', terminalId: 'c1' },
])
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
// Output rides on _meta.terminal_output; the text content is still present
// as the fallback for a UI that ignores the _meta.
const meta = update._meta as { terminal_output?: { terminal_id: string; data: string } }
expect(meta.terminal_output?.terminal_id).toBe('c1')
expect(meta.terminal_output?.data).toBe('hi')
// In terminal mode the text content is OMITTED (a tool_call_update.content
// REPLACES the call's content — it would clobber the terminal block).
expect(update.content).toBeUndefined()
// Output rides on _meta.terminal_output; the parsed exit on _meta.terminal_exit.
const meta = update._meta as {
terminal_output?: { terminal_id: string; data: string }
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
}
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'hi\n' })
expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 })
})
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {