diff --git a/docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md b/docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md index d7b0ee12f1..3ff014f55f 100644 --- a/docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md +++ b/docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md @@ -6,7 +6,7 @@ Status: implemented The ACP bridge lets each tool own its call rendering via `presentCall`/`presentResult` (see [tool-call UI presentation](../proposed/2026-06-14-acp-agent-client-protocol.md) and `packages/tools`). For `bash` we surface the exact command as the `tool_call` title, the model's `description` as a content text block, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. -That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same — and the human-readable description rides as a separate content block, since a terminal card has no description slot.) +That is a correct, capability-free baseline, but not how the reference editors render a *terminal* tool at its best. An editor like Zed has a dedicated terminal tool-call card — a header showing the working directory, the command as the label, the command output rendered as a terminal, and an exit-status pill — but it only builds that card when the `tool_call` carries terminal metadata (below). With a plain text block the output appears as static markdown and there is no cwd header. (Zed also HIDES `rawInput` for `kind: 'execute'`, which is why the command IS the title — both reference adapters do the same. The human-readable description rides as a separate content block above the card; note this is a DELIBERATE divergence — claude-agent-acp DROPS the description in terminal mode and renders only the card — we keep the summary visible alongside.) ## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create` diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 20d77f9a20..84c1b585ed 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -141,6 +141,17 @@ interface SessionRecord { * tool state. */ presenter: ToolPresenter + /** + * Whether THIS session renders shell tools as terminal cards — snapshotted + * from the client's `_meta.terminal_output` capability at session creation + * (`session/new`/`session/load`), NOT re-read live. A capability snapshot per + * session means the `tool_call` (which registers the terminal) and the matching + * `tool_call_update` (which streams its output) ALWAYS agree, even if a later + * `initialize` mutates the connection-level capability between them — otherwise + * a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal, + * result terminal) or clobber the card (call terminal, result non-terminal). + */ + terminalEnabled: boolean /** * The in-flight `session/prompt`, or `undefined` when none is pending. A * prompt resolves with a {@link StopReason} or rejects with an Error (a @@ -290,7 +301,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const rec = sessions.get(session.header.id) if (rec === undefined) return streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, { - enabled: terminalOutputCap, + enabled: rec.terminalEnabled, cwd: session.header.cwd, }) const inflight = rec.inflight @@ -422,7 +433,7 @@ export function apply(ctx: Context, config: AcpConfig): void { agentOptions: agentOptions(config), }) bySession.set(agent, sessionId) - sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), inflight: undefined }) + sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined }) return Promise.resolve({ sessionId }) }, @@ -475,7 +486,13 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams('connection closed during session/load') } bySession.set(agent, params.sessionId) - const record: SessionRecord = { sessionId: params.sessionId, agent, presenter: makePresenter(), inflight: undefined } + // Snapshot the terminal capability ONCE for this session (used by both + // the replay below and the post-load live stream) so a later + // `initialize` can't desync the call/result of a tool card. + const terminalEnabled = terminalOutputCap + const record: SessionRecord = { + sessionId: params.sessionId, agent, presenter: makePresenter(), terminalEnabled, inflight: undefined, + } sessions.set(params.sessionId, record) // Replay the persisted event log to the client as session/update. Use // the raw event log (NOT deriveMessages, which drops assistant/chunk @@ -492,7 +509,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // so the record's presenter starts clean for the post-load live stream. const replayPresenter = makePresenter() const replayTerminal: TerminalRendering = { - enabled: terminalOutputCap, + enabled: terminalEnabled, cwd: agent.session.header.cwd, } for (const event of agent.session.events) { diff --git a/packages/acp/tests/turns.spec.ts b/packages/acp/tests/turns.spec.ts index 3cf12a67e8..19c3be2556 100644 --- a/packages/acp/tests/turns.spec.ts +++ b/packages/acp/tests/turns.spec.ts @@ -159,6 +159,34 @@ describe('acp bridge — turn outcomes', () => { expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 }) }) + it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => { + // The session is created with the capability ON. A SECOND initialize then + // turns it OFF at the connection level — but this session keeps its snapshot, + // so its bash call STILL renders as a terminal card (call + result agree). + // Without the snapshot, the result path would re-read the now-OFF capability + // and either clobber the card (content sent) or be inconsistent with the call. + harness = await makeBridgeHarness({ + storageDir, + withBash: true, + script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } }) + const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + // A re-initialize that DROPS the capability after the session exists. + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] }) + + const call = harness.updates.find(u => u.sessionUpdate === 'tool_call') + if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call') + // Still a terminal card (the session's snapshot, not the mutated connection cap). + expect((call._meta as { terminal_info?: unknown }).terminal_info).toBeDefined() + 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') + // The result AGREES with the call: terminal output present, content omitted. + expect(update.content).toBeUndefined() + expect((update._meta as { terminal_output?: unknown }).terminal_output).toBeDefined() + }) + it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => { // A buggy tool whose presentCall throws must not fail the live turn — the // bridge's presenter contains the throw (logging via its onError sink) and diff --git a/packages/tool-bash/README.md b/packages/tool-bash/README.md index 39e56a6126..f5e0e1dfda 100644 --- a/packages/tool-bash/README.md +++ b/packages/tool-bash/README.md @@ -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 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"). +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. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) 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. A FOREGROUND `bash` run 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"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `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 diff --git a/packages/tool-bash/src/index.ts b/packages/tool-bash/src/index.ts index f48b1f7a08..5ea6a93a8f 100644 --- a/packages/tool-bash/src/index.ts +++ b/packages/tool-bash/src/index.ts @@ -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 } } diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/tool-bash/tests/tools.spec.ts index 06eb16ac07..cb54a8a60f 100644 --- a/packages/tool-bash/tests/tools.spec.ts +++ b/packages/tool-bash/tests/tools.spec.ts @@ -632,6 +632,48 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { } }) + it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => { + const ctx = await setup() + const args = { command: 'printf "[exit code: 5]"', description: 'print' } + // A successful command can print text that looks like a marker. renderResult + // for a clean exit 0 appends NOTHING (and no trailing newline), so the body's + // own tail is `[exit code: 5]`. The parse requires a LEADING newline before + // the marker (renderResult always inserts one before a REAL marker), so this + // no-trailing-newline body is NOT mistaken for a failure → exitCode 0. + const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false }) + expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 }) + // Same for a fake signal marker with no leading newline. + const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false }) + expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 }) + }) + + it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => { + const ctx = await setup() + // The background start returns a task-id ack, not a streamed run — no terminal. + const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true }) + expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] }) + expect((call as { terminal?: unknown }).terminal).toBeUndefined() + // The ack result is fenced text only — no terminal output / exit pill. + const result = ctx.tools.get('bash')!.presentResult!( + { command: 'sleep 100', description: 'wait', run_in_background: true }, + { content: [{ type: 'text', text: 'started background task bash-1' }], isError: false }, + ) + expect(result?.terminal).toBeUndefined() + expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }]) + }) + + it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => { + const ctx = await setup() + // A spawn failure / abort has no process exit — the body is an error message, + // not renderResult output, so no terminal output/exit is emitted. + const out = ctx.tools.get('bash')!.presentResult!( + { command: 'x', description: 'x' }, + { content: [{ type: 'text', text: 'command aborted' }], isError: true }, + ) + expect(out?.terminal).toBeUndefined() + expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }]) + }) + 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!(