feat(acp): render bash as a terminal card via the _meta convention

When the client advertises clientCapabilities._meta.terminal_output (Zed), a
bash tool call now renders as a real TERMINAL card — a cwd header + the command
+ its output — instead of the plain ```console text block. Keeps agent-side
dsh-bash execution; rejects the spec's client-side terminal/create (which would
bypass sandbox/env-scrub/ownership/cwd). Matches what claude-agent-acp and
codex-acp do; wire contract verified against Zed's source.

- dsh-tools: a provider-neutral ToolTerminal shape ({ cwd?, output? }) on
  ToolCallPresentation/ToolResultPresentation — a tool asks "render me as a
  terminal"; no ACP types leak in.
- dsh-tool-bash: bash presentCall marks terminal (cwd from an explicit absolute
  workdir, else left for the bridge to fill from the session cwd); presentResult
  carries the output alongside the ```console fallback.
- dsh-acp: initialize reads/remembers the _meta.terminal_output capability;
  streamSessionEventUpdate maps a terminal presentation to
  content:[{type:'terminal',terminalId}] + _meta.terminal_info on the call and
  _meta.terminal_output on the update WHEN capable — else the unchanged text
  path. terminalId is the callId; cwd defaults to the session header. The pure
  translator gained a TerminalRendering {enabled,cwd} param (off by default).

Tests via the REAL tool-bash + bash-local: capability ON -> terminal content +
_meta; OFF -> no _meta (text path). The with-key e2e adds a real-model terminal
card case (echo over ACP with the capability on). 773 tests, 100% coverage.

The exit-status pill (_meta.terminal_exit), live streaming
(_meta.terminal_output_delta), and command classification are RFC follow-ups.
This commit is contained in:
Tianyi Cui
2026-06-18 17:25:09 +08:00
parent 386ee14af3
commit 149ab1bba4
10 changed files with 224 additions and 30 deletions

View File

@@ -190,5 +190,36 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over
expect(bashCall.title.length).toBeGreaterThan(0)
expect(bashCall.title).not.toBe('bash') // the old, unhelpful title
expect(typeof bashCall.rawInput).toBe('string') // the exact command
// Capability OFF: no terminal _meta — the ```console text path renders.
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 () => {
workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-'))
spawned = spawnAcpAgent(workdir)
const { client, updates } = spawned
// Advertise the Zed `_meta.terminal_output` capability so the bridge emits
// the terminal card for the real bash tool.
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
const { sessionId } = await client.newSession({ cwd: workdir, mcpServers: [] })
const res = await client.prompt({
sessionId,
prompt: [{ type: 'text', text: 'Use the bash tool to run: echo ACP_TERMINAL_OK. Then stop.' }],
})
expect(['end_turn', 'max_tokens']).toContain(res.stopReason)
// A bash tool_call now carries a terminal content block + _meta.terminal_info
// with the session cwd as the header; the matching update streams the output
// 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')
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)
}, 180_000)
})