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:
@@ -34,7 +34,7 @@ The owning agent is recorded per task id at spawn and kept for the lifetime of t
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the model-written `description` followed by the exact `command` ("List files in src — ls -la src"), `kind` is `execute` (terminal/run treatment), and the `command` is ALSO the **rawInput**. Why both in the title: an execute-kind card hides `rawInput` (Zed renders it only for non-terminal tools), so the command must ride in the always-visible title to be seen — the reference ACP adapters (claude-agent-acp, codex-acp) likewise put the command in an execute tool's title. The completed output is wrapped in a fenced ` ```console ` block — a UI-only affordance, so the model-facing result text stays unfenced. `bash` also flags itself as a **terminal** (the neutral `terminal` field on its presentation: `presentCall` sets a `cwd` from an explicit absolute `workdir`, else leaves it for the UI bridge to fill from the session cwd; `presentResult` carries the output) so a capable client (Zed) renders a terminal card instead of the text block — see `packages/acp` ("Terminal card"). `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Terminal card" / "Tool-call presentation").
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card (a terminal card has no description slot, so it sits over the command; claude-agent-acp likewise surfaces the description as a separate content block). The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. `bash` also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/acp` ("Terminal card"). `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## Background completion notices
|
||||
|
||||
|
||||
@@ -132,51 +132,76 @@ export function renderResult(result: BashRunResult): string {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pending-state presentation for a `bash` call. The title is the model-written
|
||||
* `description` followed by the exact `command` ("List files — ls -la src"):
|
||||
* `kind: 'execute'` gets a terminal/run treatment in a UI, but an execute-kind
|
||||
* card HIDES `rawInput` (Zed: `should_show_raw_input = !is_terminal_tool`), so
|
||||
* the command MUST ride in the always-visible title to be seen — the reference
|
||||
* ACP adapters (claude-agent-acp, codex-acp) likewise put the command in the
|
||||
* title for execute tools. The description leads (a readable summary the schema
|
||||
* requires); the command follows so the verbatim text is still there. `rawInput`
|
||||
* still carries the bare command for non-execute UIs that DO render it.
|
||||
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
|
||||
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
|
||||
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
|
||||
* = !is_terminal_tool`), so the command must BE the title to be seen. This
|
||||
* 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.
|
||||
*
|
||||
* `terminal` marks the call so a capable UI renders a TERMINAL card. The cwd
|
||||
* header comes from an explicit absolute model `workdir` when given; otherwise
|
||||
* the call ran in the session workspace, which this PURE presenter (args only,
|
||||
* no `exec`) can't see — the UI bridge fills that default from the session's own
|
||||
* cwd. An empty `terminal: {}` still flags "this is a terminal".
|
||||
* `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).
|
||||
*/
|
||||
function presentBashCall(args: { command: string; description: string; workdir?: string }): ToolCallPresentation {
|
||||
const cwd = args.workdir !== undefined && isAbsolute(args.workdir) ? args.workdir : undefined
|
||||
return {
|
||||
title: `${args.description} — ${args.command}`,
|
||||
title: args.command,
|
||||
kind: 'execute',
|
||||
rawInput: args.command,
|
||||
terminal: cwd !== undefined ? { cwd } : {},
|
||||
content: [{ type: 'text', text: args.description }],
|
||||
terminal: args.workdir !== undefined ? { cwd: args.workdir } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-state presentation for a `bash` call. Two parallel renderings of the
|
||||
* same output: `terminal.output` for a UI that shows a terminal card (the run's
|
||||
* stdout/stderr + status markers, exactly as the model sees them — it already
|
||||
* carries the `[exit code: N]` marker), and a fenced ```console `content` block
|
||||
* as the fallback for a UI without terminal support (the fences are a UI-only
|
||||
* affordance, so they live here, not in `renderResult`). A non-text result
|
||||
* (unexpected for bash) falls through to `undefined` (UI keeps the raw result).
|
||||
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
|
||||
* newlines preserved, since a terminal renderer relies on exact bytes), and a
|
||||
* fenced ```console `content` block as the fallback for a UI without terminal
|
||||
* 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).
|
||||
*/
|
||||
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 text = block.text.replace(/\n+$/, '')
|
||||
const raw = block.text
|
||||
const fenced = raw.replace(/\n+$/, '')
|
||||
return {
|
||||
content: [{ type: 'text', text: `\`\`\`console\n${text}\n\`\`\`` }],
|
||||
terminal: { output: text },
|
||||
content: [{ type: 'text', text: `\`\`\`console\n${fenced}\n\`\`\`` }],
|
||||
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.
|
||||
*/
|
||||
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
|
||||
const signal = /\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { signal: signal[1] }
|
||||
const exit = /\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
|
||||
return { exitCode: 0 }
|
||||
}
|
||||
|
||||
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation {
|
||||
return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
|
||||
@@ -564,34 +564,74 @@ describe('status lines', () => {
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash presentCall: title is "description — command", marks a terminal; explicit absolute workdir → cwd header', async () => {
|
||||
it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => {
|
||||
const ctx = await setup()
|
||||
// No explicit workdir → the call still flags a terminal, but with no cwd (the
|
||||
// UI bridge fills the session cwd it owns; the pure presenter can't see it).
|
||||
// The command is the title (an execute card hides rawInput); the description
|
||||
// rides as a content text block (shown above the terminal card).
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
|
||||
.toEqual({ title: 'List files in src — ls -la src', kind: 'execute', rawInput: 'ls -la src', terminal: {} })
|
||||
// An explicit ABSOLUTE workdir is surfaced as the terminal cwd header.
|
||||
.toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} })
|
||||
// An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
|
||||
.toEqual({ title: 'Print dir — pwd', kind: 'execute', rawInput: 'pwd', terminal: { cwd: '/tmp/x' } })
|
||||
// A RELATIVE workdir is not an absolute cwd → omitted (terminal still flagged).
|
||||
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } })
|
||||
// A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
|
||||
// the session cwd, matching where execution runs) — not dropped.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
|
||||
.toEqual({ title: 'Print dir — pwd', kind: 'execute', rawInput: 'pwd', terminal: {} })
|
||||
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } })
|
||||
})
|
||||
|
||||
it('bash presentResult: console-block content AND terminal.output (both renderings of the run)', async () => {
|
||||
it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'echo hi', description: 'echo' },
|
||||
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
|
||||
)
|
||||
// Trailing blank lines trimmed; content is the fenced ```console fallback,
|
||||
// terminal.output is the same text for a capable terminal card.
|
||||
// The fenced ```console content trims trailing blank lines for a tidy block;
|
||||
// terminal.output keeps the RAW bytes (newlines intact) a terminal renderer
|
||||
// needs; exitCode is parsed back from the [exit code: N] marker.
|
||||
expect(present).toEqual({
|
||||
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
|
||||
terminal: { output: 'hi\n[exit code: 0]' },
|
||||
terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
|
||||
expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 })
|
||||
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!
|
||||
// For each renderResult outcome, the rendered text fed back through
|
||||
// presentResult recovers the matching structured exit — the parse and the
|
||||
// marker emission co-evolve in one file, so this pins the pair.
|
||||
const base = {
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'out', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
const cases = [
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
|
||||
{ result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
|
||||
{ result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
|
||||
// A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const rendered = renderResult(c.result)
|
||||
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
||||
const { output: _o, ...exit } = out?.terminal ?? {}
|
||||
expect(exit).toEqual(c.expect)
|
||||
}
|
||||
})
|
||||
|
||||
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
|
||||
Reference in New Issue
Block a user