Files
deepseek-harness/docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md
T
Tianyi Cui e6fad266a6 docs(rfc): define and enforce a uniform RFC format; adopt it across the corpus
Define the in-file RFC contract in docs/rfc/README.md § The file format:
the header block (`# RFC: <title>` plus a dateless Status enum
cross-checked against the lifecycle folder), the per-lifecycle body
skeleton (a Problem opener everywhere; Proposal/Alternatives considered/
Acceptance criteria/Risks in proposed/; present-tense Decision/
Consequences with proposal-era headings banned in implemented/; the
frozen proposal shape in rejected/), and a mandatory Alternatives
considered section with a date-fenced grandfather comment for pre-format
RFCs whose alternatives are not reconstructible from the record.

Enforce it with a new doc-sync gate, scripts/verify-rfc-format.ts, and
normalize all 112 RFCs to it: ~15 Status-line spellings collapse to the
enum, 29 Context openers become Problem, the 39 legacy-format XXX debt
markers are resolved and banned from reappearing, proposal-era sections
in implemented RFCs are rewritten to shipped reality (including the
web/fs/subagent seam RFCs' migration plans and test checklists, closing
the doc-tiers deferred-work item on the web seam), every RFC gains an
Alternatives considered section or the grandfather comment, and the
bilingual pair is re-mirrored and re-recorded.

Move the generated index tables out of README.md into a fully generated
docs/rfc/INDEX.md — gen-rfc-index now writes the whole file, and
verify-rfc-classification checks its freshness and rejects index-shaped
rows in the curated README — which makes room for the format contract to
live in the README front door instead of a separate FORMAT.md.

The decision record, and the first RFC written in the new format, is
docs/rfc/implemented/process/2026-07-05-uniform-rfc-format.md.
2026-07-05 22:58:25 +08:00

8.7 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 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 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.
  • 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_killgeneric.
  • dsh-tool-todogeneric.

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_stringnew_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.

Alternatives considered

  • Delete tool-owned presentation entirelythe rejected collapse proposal; its own verdict deferred to exactly this union once two real tools and two real consumers existed, and that bar is now met.
  • A merge-extensible union (the ContentBlockMap pattern) — rejected: a new render intent needs new bridge code to render it anyway, so a plugin-added variant the bridge silently drops would be worse than the compile error the closed union raises at the bridge's assertNever switch.
  • Keeping the optional-field bag — the status quo the Problem dissects: invalid states representable, undocumented field interactions, and no way to ask for a diff card at all.

Consequences

A new render intent is a compile-breaking change at the bridge switch — deliberately: rendering code must exist before a card kind does. Invalid card/field combinations are now unrepresentable, and the bash fallback derivation lives in the bridge, so a tool returns one structured shape. The bar for a fourth card (a table, a chart) is writing its bridge arm in the same change.

Non-goals

  • Live incremental terminal_output_delta streaming and command classification — the terminal-rendering RFC's own deferred follow-ups, untouched here.
  • 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 meta channel so write/edit emit a result-time DiffResultView — the applied change (a contextual hunk with context lines / one per replace_all site, or a whole-file diff for a create) — on top of this union's call-time diff card.
  • Folds ToolTerminal into the terminal views described by ACP terminal and tool-call rendering (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.