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 db0f744c1f..d7b0ee12f1 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 @@ -4,9 +4,9 @@ Status: implemented ## Problem -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 model's `description` plus the command as the `tool_call` title, `kind: 'execute'`, and the completed output wrapped in a fenced ` ```console ` text block. +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, and the command output rendered as a terminal — 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 our command rides inside the title.) +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.) ## Key finding: agent-executed terminals use a `_meta` convention, NOT `terminal/create` @@ -25,16 +25,17 @@ Keep `dsh-bash` agent-side execution; render the terminal card via the `_meta` c 1. **Capability.** `initialize` reads `clientCapabilities._meta.terminal_output` and the bridge remembers it per connection. 2. **Neutral presentation vocabulary.** `dsh-tools` gains a terminal-shaped presentation a tool can return — provider-neutral (`cwd`, the output `data`, an `exitCode`/`signal`), NO ACP types. `dsh-tool-bash` returns it for `bash` (cwd from the resolved workdir; output + exit parsed from the run result). -3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[{type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge uses the existing ` ```console ` text content — unchanged. -4. **No new execution path, no live streaming, no exit pill yet.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) is NOT emitted: it needs a structured exit code the pure `presentResult(args, result)` seam doesn't get (the result is content blocks), and the exit is already visible in the output text's `[exit code: N]` / `[killed by signal: …]` marker. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal. +3. **Bridge mapping.** When the client advertised the capability, the bridge maps that presentation to: on `tool_call`, `content:[…, {type:'terminal', terminalId}]` (any tool `content`, e.g. the description, rendered BEFORE the terminal block) + `_meta.terminal_info.{terminal_id,cwd}`; on `tool_call_update`, `_meta.terminal_output.{terminal_id,data}` (the captured output) + `_meta.terminal_exit.{terminal_id, exit_code|signal}` (the parsed exit), with the update's text `content` OMITTED (an ACP `tool_call_update.content` REPLACES the call's content collection, so re-sending the fenced block would clobber the terminal content block). `terminalId` is derived from the harness `callId` (stable, unique per call). When the capability is absent, the bridge sends the description content block on the call and the existing ` ```console ` text content on the update — unchanged. +4. **The exit pill is parsed from the rendered output; no new execution path, no live streaming.** Output is attached at completion (from the agent's own `tool/result`), not streamed token-by-token. The exit-status pill (`_meta.terminal_exit.{exit_code,signal}`) IS emitted: the pure `presentResult(args, result)` seam sees only content blocks, so `dsh-tool-bash` recovers the structured exit by parsing the status markers (`[exit code: N]` / `[killed by signal: …]`) that `renderResult` appended — the parse is the exact inverse of the marker emission, the two co-evolve in one file, and a round-trip test guards the pair. Disposal is unaffected: nothing new to tear down, since the bridge never creates a client-side terminal. ## Risks / trade-offs -- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys. +- **Zed-convention `_meta` keys.** The terminal card rides on Zed-specific keys (`terminal_info`/`terminal_output`/`terminal_exit`) inside ACP's spec-blessed `_meta` extensibility point, NOT on the ACP terminal sub-protocol. A client that doesn't recognize the keys still gets the text fallback (the capability gate ensures we only emit them when the client opted in via `_meta.terminal_output`), so a non-Zed client is never worse off. If ACP later standardizes agent-executed terminals, migrate to that and drop the convention keys. - **Capability honesty.** Emit terminal metadata ONLY when the client advertised `_meta.terminal_output`; the text fallback is the contract for everyone else and must never regress. Covered by a no-capability test asserting the ` ```console ` path. - **terminalId collisions.** Deriving it from the per-call `callId` keeps it unique within a session and stable across the call/result pair; never reuse one across calls. +- **Exit parsed from rendered text.** The exit pill recovers `exit_code`/`signal` by parsing `renderResult`'s status markers rather than threading a structured exit through the event schema (which the pure `presentResult` seam never sees). The parse is the exact inverse of the marker emission and lives in the same file; a round-trip test pins the pair so a marker-format change that breaks the parse fails the suite. If the markers ever need to diverge from what the pill wants, surface a structured exit on the result event instead. - **Provider-neutral vocabulary creep.** The terminal presentation widens the `dsh-tools` surface; keep it neutral (no ACP types leak into `dsh-tools`) and only as rich as a second UI consumer would also want. ## Out of scope / non-goals -The text-block baseline stays the no-capability default. Client-side `terminal/create` execution is explicitly rejected (it bypasses `dsh-bash`). Three follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: the **exit-status pill** (`_meta.terminal_exit.{exit_code,signal}`, which needs the structured exit surfaced from the run rather than parsed out of the rendered output text), **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes). +The text-block baseline stays the no-capability default. Client-side `terminal/create` execution is explicitly rejected (it bypasses `dsh-bash`). Two follow-ups are deliberately NOT built here and would each warrant their own RFC when someone takes them on: **live incremental streaming** (`_meta.terminal_output_delta` as chunks arrive, which needs an incremental-output seam on `dsh-bash`), and **command classification** (parsing a `cat`/`sed` as a `read` card with a file location, a `grep` as a `search`, etc., falling back to the terminal card — display-only, must never change what executes). diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 4a43429eb3..8dd8af6d01 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -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) }) diff --git a/packages/acp/README.md b/packages/acp/README.md index 4b6d81dae5..a47a7327c2 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -42,18 +42,18 @@ Each session runs in its own workspace, recorded as the session's `SessionHeader ## Tool-call presentation -How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, and the salient `rawInput` to show in a detail view) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the model `description` + the exact `command` ("List files in src — ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, and wraps the completed output in a fenced ` ```console ` block. (The command goes in the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools.) +How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.) The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. ## Terminal card (capability-gated) -A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: +A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output?, exitCode?, signal? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`: -- `tool_call`: `content:[{type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit `terminal.cwd` if it has one, else the session's workspace cwd (the bridge fills that, since the pure tool presenter can't see it). -- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` — the captured output, attached at completion. +- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card. +- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call. -When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted and the ` ```console ` text block (above) is the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). The exit-status pill, live streaming, and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). +When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md). ## Settle-exactly-once diff --git a/packages/acp/src/index.ts b/packages/acp/src/index.ts index 7d0624bc92..20d77f9a20 100644 --- a/packages/acp/src/index.ts +++ b/packages/acp/src/index.ts @@ -34,7 +34,7 @@ import type { Context } from 'cordis' import { Readable, Writable } from 'node:stream' import { randomUUID } from 'node:crypto' -import { isAbsolute } from 'node:path' +import { isAbsolute, resolve as resolvePath } from 'node:path' import Schema from 'schemastery' import { AgentSideConnection, @@ -747,6 +747,14 @@ export function streamSessionEventUpdate( // (the cwd header). Otherwise it is an ordinary tool_call and the output // arrives as text on the result. See the terminal-rendering RFC. const asTerminal = present.terminal !== undefined && terminal.enabled + // The tool's pending content (e.g. bash's `description`) renders ABOVE the + // card; when the card is shown, append the terminal block AFTER it so the + // description sits over the command (Zed renders content blocks in order). + // Without the capability the description still renders as the card's body. + const callContent: ({ type: 'content'; content: AcpContentBlock } | { type: 'terminal'; terminalId: string })[] = [ + ...present.content !== undefined ? toolResultContent(present.content) : [], + ...asTerminal ? [{ type: 'terminal' as const, terminalId: event.data.callId }] : [], + ] notify({ sessionId, update: { @@ -756,11 +764,9 @@ export function streamSessionEventUpdate( kind: present.kind, status: 'in_progress', ...present.rawInput !== undefined ? { rawInput: present.rawInput } : {}, + ...callContent.length > 0 ? { content: callContent } : {}, ...asTerminal - ? { - content: [{ type: 'terminal', terminalId: event.data.callId }], - _meta: { terminal_info: { terminal_id: event.data.callId, cwd: present.terminal?.cwd ?? terminal.cwd } }, - } + ? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } } : {}, }, }) @@ -769,23 +775,30 @@ export function streamSessionEventUpdate( case 'tool/result': { const present = presenter.result(event.data.callId, event.data.content, event.data.isError) const term = present.terminal - // When the call rendered as a terminal AND the client is capable, stream - // the output on the update's `_meta.terminal_output` (the terminal card - // consumes it). The text `content` is still sent as the record/fallback; - // a capable UI shows the terminal card, an incapable one shows the text. - // (The exit-status pill via `_meta.terminal_exit` needs a structured exit - // code the tool doesn't surface yet — see the RFC follow-up; the exit is - // already visible in the output text's `[exit code: N]` marker.) + // When the call rendered as a terminal AND the client is capable, the output + // and exit status ride on `_meta` (the terminal card consumes them) and the + // text `content` is OMITTED: a `tool_call_update.content` REPLACES the call's + // content collection in Zed, so sending the fenced ```console block here + // would clobber the terminal content block the call installed. The incapable + // path keeps sending `content` (the fenced fallback is the only rendering). const asTerminal = term?.output !== undefined && terminal.enabled + const terminalResultMeta = asTerminal + ? { + _meta: { + terminal_output: { terminal_id: event.data.callId, data: term.output }, + ...terminalExitMeta(event.data.callId, term), + }, + } + : {} notify({ sessionId, update: { sessionUpdate: 'tool_call_update', toolCallId: event.data.callId, status: event.data.isError ? 'failed' : 'completed', - content: toolResultContent(present.content), + ...asTerminal ? {} : { content: toolResultContent(present.content) }, ...present.title !== undefined ? { title: present.title } : {}, - ...asTerminal ? { _meta: { terminal_output: { terminal_id: event.data.callId, data: term.output } } } : {}, + ...terminalResultMeta, }, }) return @@ -823,6 +836,8 @@ interface ResolvedCallPresentation { title: string kind: ToolCallKind rawInput?: unknown + /** UI content shown on the pending call (e.g. a bash description text block above the card). */ + content?: ContentBlock[] /** Tool's request to render as a terminal (the pending side carries the cwd). */ terminal?: ToolTerminal } @@ -859,7 +874,7 @@ interface ResolvedResultPresentation { * stale entry's only cost is one map slot until the session ends. */ export class ToolPresenter { - private readonly pending = new Map() + private readonly pending = new Map() /** * @param tools the registry to resolve tool definitions by name. @@ -877,7 +892,6 @@ export class ToolPresenter { /** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */ call(callId: string, name: string, argsJson: string): ResolvedCallPresentation { const args = parseToolArguments(argsJson) - this.pending.set(callId, { name, args }) let present: ToolCallPresentation | undefined try { present = this.tools.get(name)?.presentCall?.(args) @@ -888,13 +902,21 @@ export class ToolPresenter { } if (present === undefined) { // No tool-owned presentation: fall back to the tool name as the title and - // the full parsed args as the raw input (the pre-seam behavior). + // the full parsed args as the raw input (the pre-seam behavior). A generic + // call is never a terminal, so a later result can't emit terminal output. + this.pending.set(callId, { name, args, isTerminal: false }) return { title: name, kind: toolKindFor(name), rawInput: args } } + // Remember whether THIS call rendered as a terminal, so `result()` only emits + // terminal output/exit for a call that actually registered a terminal — a + // `presentResult().terminal` without a matching `presentCall().terminal` + // would otherwise orphan `_meta.terminal_output` to a terminal Zed never made. + this.pending.set(callId, { name, args, isTerminal: present.terminal !== undefined }) return { title: present.title, kind: present.kind ?? 'other', rawInput: present.rawInput, + ...present.content !== undefined ? { content: present.content } : {}, ...present.terminal !== undefined ? { terminal: present.terminal } : {}, } } @@ -917,7 +939,10 @@ export class ToolPresenter { return { content: present.content ?? content, ...present.title !== undefined ? { title: present.title } : {}, - ...present.terminal !== undefined ? { terminal: present.terminal } : {}, + // Only propagate terminal output/exit when the PENDING call registered a + // terminal (finding: orphan terminal output otherwise). A result-only + // terminal with no matching call-side terminal is dropped. + ...present.terminal !== undefined && call.isTerminal ? { terminal: present.terminal } : {}, } } } @@ -961,3 +986,36 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content: } return out } + +/** + * Resolve the terminal card's header cwd. The tool's `terminal.cwd` (a model + * `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session + * cwd (matching how `dsh-tool-bash` resolves a relative workdir for execution, + * so the header matches where the command actually ran); when the tool gives no + * cwd, the session workspace cwd is the default. Returns `undefined` only when + * neither the tool nor the session supplies one (Zed then shows "current + * directory"). + */ +function terminalCwd(term: ToolTerminal | undefined, sessionCwd: string | undefined): string | undefined { + const toolCwd = term?.cwd + if (toolCwd === undefined) return sessionCwd + if (isAbsolute(toolCwd)) return toolCwd + return sessionCwd !== undefined ? resolvePath(sessionCwd, toolCwd) : toolCwd +} + +/** The `terminal_exit` `_meta` entry for a completed terminal call. */ +interface TerminalExitMeta { + terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string } +} + +/** + * Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta` + * from the tool's terminal result: a `signal` death yields `{signal}`, an + * `exitCode` yields `{exit_code}`, and neither yields nothing (the card simply + * shows no exit pill). Spread into the `_meta` object alongside `terminal_output`. + */ +function terminalExitMeta(callId: string, term: ToolTerminal): TerminalExitMeta { + if (term.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: term.signal } } + if (term.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: term.exitCode } } + return {} +} diff --git a/packages/acp/tests/load.spec.ts b/packages/acp/tests/load.spec.ts index 89bd8253f7..a10aca4bd4 100644 --- a/packages/acp/tests/load.spec.ts +++ b/packages/acp/tests/load.spec.ts @@ -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 diff --git a/packages/acp/tests/stream-update.spec.ts b/packages/acp/tests/stream-update.spec.ts index 8a22bc71f1..bf73fe0629 100644 --- a/packages/acp/tests/stream-update.spec.ts +++ b/packages/acp/tests/stream-update.spec.ts @@ -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({}) diff --git a/packages/acp/tests/turns.spec.ts b/packages/acp/tests/turns.spec.ts index 23f4c2e706..3cf12a67e8 100644 --- a/packages/acp/tests/turns.spec.ts +++ b/packages/acp/tests/turns.spec.ts @@ -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 () => { diff --git a/packages/tool-bash/README.md b/packages/tool-bash/README.md index 3068bc34dd..39e56a6126 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 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 diff --git a/packages/tool-bash/src/index.ts b/packages/tool-bash/src/index.ts index 908fdf274d..f48b1f7a08 100644 --- a/packages/tool-bash/src/index.ts +++ b/packages/tool-bash/src/index.ts @@ -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 } diff --git a/packages/tool-bash/tests/tools.spec.ts b/packages/tool-bash/tests/tools.spec.ts index a5a5cdb58f..06eb16ac07 100644 --- a/packages/tool-bash/tests/tools.spec.ts +++ b/packages/tool-bash/tests/tools.spec.ts @@ -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!( diff --git a/packages/tools/README.md b/packages/tools/README.md index d15b1d1687..6b1634cd70 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -72,8 +72,8 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods: -- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`). -- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card and a UI that can't ignores it and uses `content`. +- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`). +- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`. Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation. @@ -90,8 +90,8 @@ const bash = defineTool({ async execute(args) { return [{ type: 'text', text: `ran: ${args.command}` }] }, - // The model-written description is the readable title; the command is the detail. - presentCall: args => ({ title: args.description, kind: 'execute', rawInput: args.command }), + // The command is the readable title; the description rides as a content block. + presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }), // Wrap the output as a console block for the UI (not in the model-facing result). presentResult: (_args, result) => { const block = result.content.length === 1 ? result.content[0] : undefined diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index 4de993b5c9..fad513fa52 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -83,6 +83,16 @@ export interface ToolCallPresentation { * unless that is genuinely what a reader wants. */ rawInput?: unknown + /** + * UI-facing content to show on the PENDING call alongside the title/card — + * harness {@link ContentBlock}s, in render order. A terminal tool uses this to + * surface its human-readable `description` as a text block ABOVE the terminal + * card (the card itself is requested via {@link terminal} and labelled by the + * command in `title`), since the card has no description slot. Omit to show no + * extra content. A UI maps these to its own content blocks and renders a + * {@link terminal} block (if any) as a terminal card. + */ + content?: ContentBlock[] /** * Ask a capable UI to render this call as a TERMINAL (a command running in a * working directory), not a generic tool card — set by a tool whose call IS a @@ -96,16 +106,33 @@ export interface ToolCallPresentation { /** * A request to render a tool call as a terminal. The pending presentation * supplies the working directory; the result presentation (see - * {@link ToolResultPresentation.terminal}) supplies the captured output. - * Provider-neutral — no client-protocol types. A UI that supports terminals - * shows a cwd-headed terminal card with the command and its output; a UI that - * does not ignores this and renders the ordinary card/content. + * {@link ToolResultPresentation.terminal}) supplies the captured output and exit + * status. Provider-neutral — no client-protocol types. A UI that supports + * terminals shows a cwd-headed terminal card with the command, its output, and + * an exit-status pill; a UI that does not ignores this and renders the ordinary + * card/content. */ export interface ToolTerminal { - /** Absolute working directory the command ran in, shown as the terminal header. Omit if unknown. */ + /** + * Working directory the command ran in, shown as the terminal header. An + * ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge + * against the session workspace (the pure tool presenter can't see the + * session cwd). Omit entirely to let the bridge use the session workspace. + */ cwd?: string /** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */ output?: string + /** + * Process exit code, when the run ended by exiting (not a signal). Result-state + * only; lets a capable UI show an exit-status pill on the terminal card. Omit + * when the command was killed by a signal or the exit code is unknown. + */ + exitCode?: number + /** + * Signal name that killed the process (e.g. `SIGTERM`), when it died by signal + * rather than exiting. Result-state only; mutually exclusive with `exitCode`. + */ + signal?: string } /**