fs write/edit now emit a result-time contextual-diff tool_call_update
(the applied hunk with ±3 context lines, one hunk per replace_all site),
matching what claude-agent-acp sends and what makes an editor render the
change in place. The call-time snippet diff stays; the result hunk
supersedes it (ACP content-replace).
Mechanism:
- A persisted tool-private `meta` channel: execute may return
`{ content, meta }`; `meta` (JsonValue) rides on the tool/result event
and is handed back to presentResult, so the diff reproduces on replay
(event-sourced). JsonValue is now exported from dsh-session.
- The backend returns raw before/after text (storage facts) on
FsWriteOutcome/FsEditOutcome; the tool computes the hunk via the npm
`diff` package's structuredPatch. A create has no before → no result
diff; a failed/aborted mutation carries no meta.
- ToolResultView gains a DiffResultView; the bridge's result-side switch
renders it as {type:'diff'} content blocks.
RFC: docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md
(justifies the npm `diff` runtime dep over vendoring and the meta channel);
the render-intent-union RFC's Non-goal is updated to record this shipped.
All fs snapshot goldens re-recorded; edit/overwrite gain the contextual
result diff, create/read/policy-reject unchanged in structure.
7.5 KiB
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
terminalfields overlap, and the bridge reconciles acontentblock AND aterminalblock ANDrawInputper call, stitching them together with ad-hoc conditionals. - Which combinations are valid is unwritten: a
terminalcall that also setscontentmeans "description above the card"; a generic call that setsterminalis 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.contentis the LLMContentBlock[]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 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.
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): 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.
diffis a first-class intent.dsh-tool-fswrite/edit declarecard:'diff'; the bridge emits an ACP{type:'diff', path, oldText, newText}ToolCallContent(already in the SDK'sToolCallContentunion, previously unused by the bridge). This is the affordance the redesign unlocks.
Producer mapping
dsh-tool-fsread →generic(kind:'read', a follow-alonglocation); write →diff(oldText:null); edit →diff(oldText:old_string || null,newText:new_string ?? ''). This mirrorsclaude-agent-acp'stoolInfoFromToolUseRead/Write/Edit arms field-for-field.dsh-tool-bashforeground →terminalcall +terminalresult;run_in_backgroundandbash_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
- Live incremental
terminal_output_deltastreaming 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 — "wait for two real tools and two real consumers, then a tagged render-intent union"). That bar is now met; this is that union.
- Extended by Result-time applied-hunk diffs, which adds a persisted
metachannel so write/edit emit a result-time contextual-hunkDiffResultView(context lines + one hunk perreplace_allsite) on top of this union's call-time diff card. - Folds
ToolTerminalinto theterminalviews described by ACP terminal and tool-call rendering (the_metaterminal-card convention and capability gate are unchanged; only the harness-side presentation type changes). - The ACP SDK's
Diff/ToolCallContenttypes back the newdiffcard.