refactor(tools): tagged render-intent union for tool-call presentation
Replace the "bag of optional fields" tool-presentation types
(ToolCallPresentation / ToolResultPresentation / ToolTerminal) with a
card-tagged discriminated union — the standing FIXME(tool-presentation).
A tool declares one render intent per call/result and the ACP bridge
switches on `card`:
ToolCallView = generic | terminal | diff
ToolResultView = generic | terminal
The `diff` card is new: fs write/edit now emit an ACP {type:'diff'}
content block (an editor's inline diff), which the old shapes could not
express. The bridge also relativizes a file card's title against the
session cwd (mirroring claude-agent-acp's toDisplayPath) while keeping
locations/diff paths raw, and derives the no-capability fenced console
fallback from a terminal result's output. read gains the window-in-title
(`Read foo.txt (5 - 8)`) and an always-set location line, matching the
reference adapter field-for-field.
Migrates all three producer families (tool-fs, tool-bash, tool-todo) and
the sole consumer (the ACP bridge) together — the source does not compile
piecewise. Adds snapshot coverage for the terminal _meta path (a new
capability-advertising scenario) and re-records the fs goldens to show the
diff cards. Applied-hunk (result-time, context-line) diffs need a new
result/event shape and are a follow-up.
RFC: docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
This commit is contained in:
@@ -48,6 +48,26 @@ Follow tool-bash's background pattern: a `run_in_background` flag returns a task
|
||||
|
||||
Prefer not to build policy into the tool. The seam is the `tools/execute` waterfall (veto or wrap — see the permission-gate example in [extension-cookbook.md](./extension-cookbook.md)), or a sandboxing implementation behind the tool's executor seam.
|
||||
|
||||
## How your tool renders in an editor (ACP presentation)
|
||||
|
||||
Your tool's `execute` returns model-facing content; its **editor card** is a separate, optional concern you declare with two pure display methods on the `defineTool` options. Design this alongside `execute`, not after — an editor (Zed, over the ACP bridge) shows the card, and a tool with no presentation falls back to a bland generic card (title = tool name, raw args as input).
|
||||
|
||||
Both methods return a **`card`-tagged render intent** — pick the card kind that matches what your tool does:
|
||||
|
||||
- `presentCall(args)` → a `ToolCallView` (the PENDING card):
|
||||
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default. Set `kind` for an icon (`read`/`search`/…); set `locations: [{ path, line? }]` for any file your tool touches so a capable editor follows along / jumps to it.
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — your call IS a shell command. `title` is the command, `description` renders above the terminal card. (tool-bash.)
|
||||
- `{ card: 'diff', title, diffs, locations? }` — your call creates or modifies a file. `diffs: [{ path, oldText, newText }]` (`oldText: null` for a new file) renders as an inline diff card. (tool-fs `write`/`edit`.)
|
||||
- `presentResult(args, { content, isError })` → a `ToolResultView` (the COMPLETED card): `{ card: 'generic', title?, content? }` or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the run's captured output + exit — the bridge shows an exit pill and derives a fenced ` ```console ` fallback for editors without the terminal capability).
|
||||
|
||||
Hard rules (they bite if broken):
|
||||
|
||||
- **Purity.** These run on live streaming AND on session-log REPLAY, so they must be pure functions of `args` (+ the result) — NO I/O, NO reading session state, NO clock/random. A diff is derived from the args (`write` uses `oldText: null` because a call-time presenter has no prior file content); the BRIDGE, not the tool, fills the session cwd and relativizes a display-path title. If you find yourself wanting the file's old content or the working directory inside `presentCall`, stop — that belongs on the bridge or a future result-event shape, not the presenter.
|
||||
- **UI-only formatting stays out of the model result.** A fenced ` ```console ` block, a diff, a relativized path — none of these may appear in what `execute` returns to the model; they live only in the presentation. (A `terminal` result view carries RAW `output`; the bridge adds the fences.)
|
||||
- **`defineTool` soft-validates the display path.** A malformed/older logged arg shape makes the wrapper return `undefined` (a generic fallback) rather than throw — display must never crash a replay.
|
||||
|
||||
The neutral vocabulary lives in `dsh-tools` (never import an ACP type into a tool); the ACP bridge maps each `card` to the wire. The design and the why are in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); `dsh-tool-fs` (generic/diff) and `dsh-tool-bash` (terminal) are the reference implementations.
|
||||
|
||||
## Tests every tool needs
|
||||
|
||||
Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events.
|
||||
Arg-validation rejections, result shaping for every outcome, the HMR disposal test, and — for tools with side effects — an integration spec that drives the tool through the agent loop with a scripted `MockAdapter` (`packages/core/agent-loop/tests/mock-adapter.ts`), asserting the `tool/call` / `tool/result` session events. **If your tool has an editor card, also add:** a unit test on `presentCall`/`presentResult` asserting the exact view shape, AND — because a unit test proves the shape but not that an editor renders it — a **snapshot scenario** under `examples/acp-agent/tests/snapshots/` that drives the real tool through the ACP bridge and pins the rendered `tool_call` transcript (the card kind is only verified end-to-end there; see the [ACP snapshot-tests RFC](../rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). A tool whose card is a `terminal` needs a scenario whose `input.json` sets `terminalOutput: true` to exercise the capable-client `_meta` path.
|
||||
|
||||
@@ -547,7 +547,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult>
|
||||
|
||||
Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md)
|
||||
|
||||
Source: [`packages/core/tools/src/index.ts:287`](../../packages/core/tools/src/index.ts)
|
||||
Source: [`packages/core/tools/src/index.ts:319`](../../packages/core/tools/src/index.ts)
|
||||
|
||||
## Inherited tier (cordis core + loader/hmr/timer)
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ Precisely, a data structure is **core** if either:
|
||||
1. it flows through the agent-loop spine — the loop holds it, derives it, streams it, or logs it on every turn (a `Message`, a `StreamChunk`, a `SessionEvent`, the `Agent` handle itself), independent of which plugins are present; **or**
|
||||
2. it is the single headline type a plugin author writes against a pipeline — `ToolDefinition` (what every tool *is*).
|
||||
|
||||
Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallPresentation` vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below.
|
||||
Everything else is documented on a **sub-page**, not here. The rule that draws the line: *the type you write, hold, or receive is core; the machinery that types it, renders it, or persists it is a sub-page detail.* So `ToolDefinition` is core, but the `SchemaSpec`/`InferArgs` DSL that types it, the `ToolCallView`/`ToolResultView` render-intent vocabulary that renders it, and the `SessionPersistence` seam that stores the event log are not — they live on the sub-pages below.
|
||||
|
||||
| Sub-page | Owns |
|
||||
|---|---|
|
||||
|
||||
@@ -12,21 +12,23 @@ A `ToolSchema` (the model-facing fields) plus the `execute` function and optiona
|
||||
interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived
|
||||
* from the call's `args` (parsed arguments, `unknown` — the tool validates/
|
||||
* narrows its own input). Returning `undefined` (or omitting the method) tells
|
||||
* a UI to fall back to a generic presentation (title = tool name, raw args as
|
||||
* input). Pure and side-effect-free: a UI may call it during live streaming
|
||||
* AND a session-log replay, so it must depend only on `args`.
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived from
|
||||
* the call's `args` (parsed arguments, `unknown` — the tool validates/narrows
|
||||
* its own input). Returns a {@link ToolCallView} (a `card`-tagged render intent),
|
||||
* or `undefined` (or omit the method) to fall back to a generic presentation
|
||||
* (title = tool name, raw args as input). Pure and side-effect-free: a UI may
|
||||
* call it during live streaming AND a session-log replay, so it must depend
|
||||
* only on `args`.
|
||||
*/
|
||||
presentCall?(args: unknown): ToolCallPresentation | undefined
|
||||
presentCall?(args: unknown): ToolCallView | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the same `args` and the
|
||||
* `result` (`execute`'s content + whether it errored). Returning `undefined`
|
||||
* (or omitting the method) tells a UI to keep the pending title and render the
|
||||
* raw result content. Pure and side-effect-free for the same replay reason.
|
||||
* `result` (`execute`'s content + whether it errored). Returns a
|
||||
* {@link ToolResultView}, or `undefined` (or omit the method) to keep the
|
||||
* pending title and render the raw result content. Pure and side-effect-free
|
||||
* for the same replay reason.
|
||||
*/
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
|
||||
}
|
||||
```
|
||||
|
||||
@@ -105,8 +107,11 @@ A waterfall listener receives `(exec, next)`: call `next()` to proceed (possibly
|
||||
|
||||
## Tool-presentation UI vocabulary
|
||||
|
||||
How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall` returns a `ToolCallPresentation` (pending state: `title`, `kind`, `rawInput`, `content`, `locations` — `{ path, line? }[]` files the call reads/modifies, for editor follow-along — and optional `terminal`); `presentResult` returns a `ToolResultPresentation` (completed state: replacement `title`, reformatted `content`, terminal `output`/exit). `ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon. A `ToolTerminal` asks a capable UI to render the call as a terminal card (cwd header, output, exit-status pill).
|
||||
How a tool wants its call shown in a UI (an editor tool-call card, a CLI log line), provider-neutral so a tool describes itself without depending on any client protocol. `presentCall`/`presentResult` return a **`card`-tagged render intent** — a discriminated union a UI bridge switches on:
|
||||
|
||||
> These shapes carry a `FIXME(tool-presentation)` in source: they grew incrementally and the call-vs-result terminal split is muddy. Before more tools/UIs depend on them, they will be redesigned (a tagged union over card kinds) and pinned in an RFC, migrating `dsh-tool-bash` and the ACP bridge together. Treat the field-level shapes here as provisional; the source is authoritative.
|
||||
- `ToolCallView` (pending): `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` (the default card; `locations` is `{ path, line? }[]` files the call reads/modifies, for editor follow-along), `{ card: 'terminal', title, description?, cwd? }` (a shell command → a terminal card), or `{ card: 'diff', title, diffs, locations? }` (a file create/modify → an inline diff card; `diffs` is `{ path, oldText, newText }[]`, `oldText: null` for a new file).
|
||||
- `ToolResultView` (completed): `{ card: 'generic', title?, content? }` or `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, an incapable one gets a fenced ` ```console ` fallback the bridge derives from `output`).
|
||||
|
||||
`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`) and `FileDiff` (`{ path, oldText, newText }`) are the shared file-card vocabulary. The design is pinned in [the render-intent-union RFC](../rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md); the ACP bridge maps a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention, and relativizes a file card's title against the session cwd.
|
||||
|
||||
The full presentation field docs live in [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index.ts). The bash tool's own schemas (`bash`/`bash_output`/`bash_kill`) and the executor they drive are on [bash.md](bash.md).
|
||||
|
||||
@@ -125,6 +125,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r
|
||||
| [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 |
|
||||
| [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 |
|
||||
| [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 |
|
||||
| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 |
|
||||
|
||||
### Process
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# RFC: Tagged render-intent union for tool-call presentation
|
||||
|
||||
Status: implemented
|
||||
|
||||
## Problem
|
||||
|
||||
A tool declares how its calls render in a UI (an editor's tool-call card) through two callbacks, `presentCall`/`presentResult` on `ToolDefinition`, returning `ToolCallPresentation` / `ToolResultPresentation` with an optional `ToolTerminal` sub-shape. These grew incrementally into a **bag of optional fields**: `title`, `kind`, `rawInput`, `content`, `locations`, `terminal` on the call; `title`, `content`, `terminal` on the result; `cwd`/`output`/`exitCode`/`signal` on `ToolTerminal`. The split of responsibility is muddy:
|
||||
|
||||
- The call-side and result-side `terminal` fields overlap, and the bridge reconciles a `content` block AND a `terminal` block AND `rawInput` per call, stitching them together with ad-hoc conditionals.
|
||||
- Which combinations are *valid* is unwritten: a `terminal` call that also sets `content` means "description above the card"; a generic call that sets `terminal` is meaningless but representable. The type permits nonsense.
|
||||
- There is no way to express the one file-tool affordance an editor most wants — a **diff card** (`{path, oldText, newText}`, which Zed renders as an inline diff / new-file preview). `ToolCallPresentation.content` is the *LLM* `ContentBlock[]` vocabulary (text/image), so a tool literally cannot ask for a diff.
|
||||
|
||||
The existing `FIXME(tool-presentation)` in `packages/core/tools/src/index.ts` named the fix: "redesign the type so a tool declares its render INTENT once (e.g. a tagged union over card kinds) rather than a bag of optional fields the bridge stitches together." The rejected RFC [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) deferred it explicitly: rich rendering "should return later as a tagged render-intent union after there are at least two real tools and two real consumers to validate the vocabulary." That bar is now met — two producer families (`dsh-tool-bash`, `dsh-tool-fs`) and two consumers (the ACP bridge live path + the snapshot-golden replay path).
|
||||
|
||||
## Decision
|
||||
|
||||
Replace the optional-field bag with a **`card`-tagged discriminated union**. A tool declares one render intent per call/result; the bridge switches on the tag.
|
||||
|
||||
```ts ignore-check
|
||||
type FileLocation = { path: string; line?: number }
|
||||
type FileDiff = { path: string; oldText: string | null; newText: string } // oldText null ⇒ new file
|
||||
|
||||
// presentCall → ToolCallView
|
||||
type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
|
||||
interface GenericCallView { card: 'generic'; title: string; kind?: ToolCallKind; rawInput?: unknown; content?: ContentBlock[]; locations?: FileLocation[] }
|
||||
interface TerminalCallView { card: 'terminal'; title: string; description?: string; cwd?: string }
|
||||
interface DiffCallView { card: 'diff'; title: string; diffs: FileDiff[]; locations?: FileLocation[] }
|
||||
|
||||
// presentResult → ToolResultView
|
||||
type ToolResultView = GenericResultView | TerminalResultView
|
||||
interface GenericResultView { card: 'generic'; title?: string; content?: ContentBlock[] }
|
||||
interface TerminalResultView { card: 'terminal'; title?: string; output?: string; exitCode?: number; signal?: string }
|
||||
```
|
||||
|
||||
`card` is **required** on every variant — a real discriminant, not an optional default. The bridge does `switch (view.card) { case 'generic': … case 'terminal': … case 'diff': … default: assertNever(view) }`. The union is **closed** (per the [switch-exhaustiveness convention](../../../../AGENTS.md)): a fourth render intent (a table, a chart) needs new bridge code to render it anyway, so a plugin-added variant that the bridge silently drops would be worse than a compile error. Adding a variant breaks compilation at the bridge switch — exactly the signal we want.
|
||||
|
||||
### Why a tagged union beats the field-bag
|
||||
|
||||
- **Invalid states become unrepresentable.** A generic card cannot carry terminal output; a terminal card cannot carry a diff. The old bag permitted all of these.
|
||||
- **The bridge switches instead of stitching.** One arm per card kind, each producing exactly the wire shape that card needs, rather than reconciling five optional fields whose interactions are undocumented.
|
||||
- **`diff` is a first-class intent.** `dsh-tool-fs` write/edit declare `card:'diff'`; the bridge emits an ACP `{type:'diff', path, oldText, newText}` `ToolCallContent` (already in the SDK's `ToolCallContent` union, previously unused by the bridge). This is the affordance the redesign unlocks.
|
||||
|
||||
### Producer mapping
|
||||
|
||||
- `dsh-tool-fs` read → `generic` (`kind:'read'`, a follow-along `location`); write → `diff` (`oldText:null`); edit → `diff` (`oldText:old_string || null`, `newText:new_string ?? ''`). This mirrors `claude-agent-acp`'s `toolInfoFromToolUse` Read/Write/Edit arms field-for-field.
|
||||
- `dsh-tool-bash` foreground → `terminal` call + `terminal` result; `run_in_background` and `bash_output`/`bash_kill` → `generic`.
|
||||
- `dsh-tool-todo` → `generic`.
|
||||
|
||||
### Terminal fallback ownership
|
||||
|
||||
`TerminalResultView` carries only `output`/`exitCode`/`signal`. A UI without the terminal capability needs a fenced ` ```console ` text fallback; that derivation moves to the **bridge** (it wraps `output` in a fenced block on the no-capability path), rather than the tool double-encoding it. This keeps the bash tool's result a single structured shape and preserves the existing capability-gated behavior byte-for-byte.
|
||||
|
||||
### Purity preserved
|
||||
|
||||
`presentCall`/`presentResult` remain pure functions of `args` (+ the result for `presentResult`) — they run on live streaming AND session-log replay, so they must be replay-deterministic. Every view is derived from args alone: write's diff is new-file style (`oldText:null`) because the tool has no old content at call time; edit's diff is `old_string`→`new_string`.
|
||||
|
||||
## Relative-path display titles
|
||||
|
||||
`claude-agent-acp` relativizes a file card's title path against the session cwd (`toDisplayPath`) — `Read src/foo.ts`, not `/abs/proj/src/foo.ts` — while keeping `locations[]`/`diff.path` **raw** (the editor opens the real path). Our `presentCall` is pure/args-only and cannot see the session cwd, so this relativization happens at the **bridge**, which already threads the session cwd into tool-call rendering (the same cwd it uses to resolve a terminal card's header). The bridge relativizes the title only, by an exact structured replace of the known `locations[0].path`/`diffs[0].path` substring — generic over the file-card kinds, never special-casing tool names.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Applied-hunk diffs.** `claude-agent-acp` additionally rewrites Write/Edit diffs at *result* time with real structured-patch hunks (via a PostToolUse hook: `toolUpdateFromDiffToolResponse`). Our diffs are call-time and args-derived (the whole `old_string`→`new_string`, no surrounding context lines), because `presentResult` sees only `{content, isError}` and `FsEditOutcome` carries a replacement count/version, not hunk text. Real hunks would need a new result/event shape carrying the patch — a follow-up, not this change. This is the one remaining representation difference from `claude-agent-acp`, and it is architectural (needs a new event), not cosmetic.
|
||||
- **Live incremental `terminal_output_delta` streaming** and **command classification** — the terminal-rendering RFC's own deferred follow-ups, untouched here.
|
||||
|
||||
## Related
|
||||
|
||||
- Supersedes the deferral in [Collapse tool-owned UI presentation](../../rejected/simplification/2026-06-20-generic-tool-rendering.md) (rejected — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union.
|
||||
- Folds `ToolTerminal` into the `terminal` views described by [ACP terminal and tool-call rendering](../feature/2026-06-18-acp-terminal-and-tool-rendering.md) (the `_meta` terminal-card convention and capability gate are unchanged; only the harness-side presentation type changes).
|
||||
- The ACP SDK's `Diff` / `ToolCallContent` types back the new `diff` card.
|
||||
@@ -23,7 +23,7 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei
|
||||
- A data structure is **core** if it flows through the agent-loop spine — the loop holds, derives, streams, or logs it on every turn regardless of which plugins load (`Message`, `StreamChunk`, `SessionEvent`, the `Agent` handle) — **or** it is the single headline type a plugin author writes against a pipeline (`ToolDefinition`).
|
||||
- `ToolDefinition` is core (it is what every tool author writes) **even though the loop never holds one** — authoring-importance overrides the strict flows-through-spine rule for this one headline type. But its typing machinery — the `SchemaSpec`/`InferArgs` DSL — is a sub-page detail (you write a `ToolDefinition`; the type-level machinery that types it you do not). That is the spine-vs-seam line made sharp.
|
||||
- `ToolSchema` is core (it is a field of `GenerateOptions`, the model request that flows through every step) even though it is conceptually part of the tool pipeline — *flows through the spine* wins over *conceptual home* when they conflict.
|
||||
- The tool-presentation vocabulary (`ToolCallPresentation`, …, carrying a `FIXME(tool-presentation)` redesign marker), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages.
|
||||
- The tool-presentation vocabulary (`ToolCallView`/`ToolResultView`, …), the `SessionPersistence` durability seam, and bash vocabulary are sub-pages.
|
||||
|
||||
`core.md` is a **self-contained spine doc**: it states the exact type definition of each spine structure with minimal prose and links to sub-pages for the per-seam detail. The sub-pages are `llm-streaming.md`, `session.md`, `persistence.md` (split from session along the in-memory-model vs. durability-seam line), `tools.md`, and `bash.md`.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user