Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-30-web-read-card.md
Tianyi Cui 25dcd7293c docs: purge chain-of-thought leakage from prose
Delete design-session citations (decision/audit/plan ordinals, stack
positions), change narration, review choreography, and reviewer-addressed
justification from comments, JSDoc, docs, READMEs, Agent Notes, tests, and
generator templates; restate every affected fact as current-state contract
prose. Fix generated docs at their sources and regenerate the catalogs and
cordis-surface regions; re-paste type-equiv blocks; update every bilingual
counterpart and re-record the pairs. Record the citation rule in the
committed-artifact-citations Agent Note.
2026-08-09 21:10:59 +08:00

11 KiB

Agent Note: Read card — the read tool's structured line window reaches the client

Status: implemented

English | 中文

Problem

The read tool returns a canonical output object { path, offset, lines: [{ number, text }], totalLines }, but its presentation collapsed that structure. presentCall declared a GenericCallView (kind: 'read', a follow-along location) and presentResult returned a GenericResultView whose only content was the model-facing text with its <path>…</path><type>file</type><content>…</content> envelope stripped. A UI receiving that view saw one flattened text block: the line numbers were baked into the text as N: prefixes, the file's language was unknown, and totalLines was gone. There was no way for a capable client to render a read the way it renders a diff — a line-numbered, syntax-highlighted code view with the line-number gutter separate from the content.

The structured data cannot be recovered downstream. A tool result on the wire carries only the model-facing ContentBlock[] (the rendered text) plus an opaque meta; the canonical output object stays in the tool and never reaches the client or the session log. So a client that wants the line array, the total, and a language hint cannot parse them back out of the N: text text — the tool has to project them onto a channel that persists.

Decision

Add a fourth card tag, read, to the render-intent union — result-side only. ToolResultView gains ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? }; ReadFileLine { number; text } is the shared line unit. ToolCallView is untouched: the pending state stays a GenericCallView (kind: 'read') because a call carries no file content until execute returns, so there is nothing structured to show at call time. This diverges from the bash terminal card, which tags both sides — a terminal call already carries its command and cwd at call time, a read call carries neither content nor total, so tagging the call side would add an empty variant.

The read tool projects the structured window through output.presentationMeta, the same persisted channel write/edit use for their applied-diff hunks (canonical tool output contract). presentationMeta runs once for a top-level surface call, returns { path, offset, lines, totalLines, lang? } as JSON the session validates and stores on the result's meta, and presentResult narrows that meta back into the ReadResultView on both live and replay paths. offset (the 1-based first line the window requested) rides along because a byte cap below the first selected line yields an empty lines array with a positive totalLines; without the persisted offset a replayed card of such a window could not report where it starts or where a continuation resumes, and the last-line and re-parse fallbacks are both lossy. Without this channel the line array and total would be unreachable: the raw output object is not on the wire, and re-parsing the N: text text is lossy and fragile against the truncation footer.

presentResult returns undefined — the generic fallback — whenever the meta is absent or malformed (readMetaFromMeta narrows it defensively, so a replay of an older logged result never throws), whenever the result is an error, and whenever the single text block is not the read envelope. A pre-card logged result — a valid read envelope with no persisted meta, recorded before this card existed — takes that same undefined path deliberately: the client falls back to the raw result.content, so it shows the enveloped <path>/<type>/<content> text rather than the envelope-stripped generic card the old presenter returned. This is the accepted degradation under the pre-release stance: reject the old on-disk format rather than add an envelope-stripping compatibility branch, since this change re-records every published fixture and the session format promises no backward compatibility. On the success path presentResult carries content (the envelope-stripped text) alongside the structured fields, so a UI without the read capability renders the file text through its generic/default card arm. The former TUI established the need for this fallback: its non-exhaustive result switch read view.content, while a separate dim-Markdown gate also had to admit card: 'read'. That frontend has since been removed, but the content fallback remains part of the view contract for any consumer without a structured read card.

Language hint derivation

langFromPath (in read-render.ts) maps a file extension to a syntax-highlighting language id through a small fixed table (LANG_BY_EXTENSION) covering common source, config, and markup extensions. It reads the extension after the last path segment and last dot, is case-insensitive, and returns undefined for a dotfile (.gitignore), an extensionless name (/etc/hosts), a trailing dot, and any unknown extension — the card then omits lang and a UI renders plain text. The table is not a tunable: it is a display hint a UI may ignore, not a deployment-varying choice, and an unknown extension degrades to plain text rather than failing. It is deliberately small rather than an exhaustive language registry; extending it is a one-line table addition.

Alternatives considered

Re-parse the N: text model-facing text in presentResult. Rejected: the structured line array would have to be reconstructed by splitting each line on the first : , which is ambiguous (a line whose own text contains : ), loses the exact totalLines (the footer only states it in some branches), and breaks the moment the render format changes. presentationMeta carries the already-structured data with no re-parse.

Tag the call side too (ReadCallView), mirroring the terminal card's both-sides symmetry. Rejected: a read call has no content, no line array, and no total until it executes — a call-side read card would be an empty variant duplicating what GenericCallView (kind: 'read', follow-along location) already expresses. The terminal card tags both sides because a terminal call genuinely carries call-time data (command, cwd); a read call does not.

Put the structured window in a new service or a side channel instead of meta. Rejected: meta is the established persisted presentation channel (write/edit's applied diffs ride it), it replays for free with the session log, and it needs no new plumbing. A service would reinvent persistence and replay that the event log already provides.

A merge-extensible union instead of a closed tag. Rejected for the same reason the render-intent union closed: a new card needs consuming code to render it, so a variant a consumer silently drops is worse than a compile error. Adding read to the closed union is the sanctioned way to extend it — each consumer that switches on card keeps compiling because the new member falls through its generic default, and a consumer that wants the rich view adds its own arm.

Consequences

ToolResultView has a fourth member. A consumer may render the structured lines/lang/totalLines shape or route an unsupported card to its generic path; the read card carries content so the latter still shows the file text. This producer change is the backend that makes the structured data reachable without requiring every consumer to implement the richer view at once.

The read tool now computes presentationMeta for every top-level read, a small per-call projection (a lines.map and one langFromPath call) on data already in hand. The meta is persisted with the session log, so a read result is slightly larger on disk — the line array it already rendered as text, now also structured.

Testing

packages/fs/tool-fs/tests/read-render.spec.ts unit-tests langFromPath (known extensions case-insensitively, extension read after the last segment and last dot, and the undefined cases: dotfile, extensionless, trailing dot, unknown) and readMetaFromMeta (a well-formed narrow with and without lang, and every rejection: non-object, array, missing or wrong-typed path/totalLines/lines, a malformed line entry, a non-string lang, and — because the function narrows the opaque persisted meta boundary — the semantically invalid paths a well-typed replayed JSON can still carry: an offset that is not a 1-based integer, a first line number below offset, a line number that is not a 1-based integer (0, 1.5, NaN, Infinity), a totalLines that is not a non-negative integer (-1, 1.5, NaN), and lines whose numbers duplicate, decrease, or exceed totalLines; it also narrows an empty window at a positive offset (a byte cap below the first selected line). packages/fs/tool-fs/tests/tools.spec.ts pins the tool wiring: execute attaches the structured window (with and without a lang hint) as meta, presentResult narrows it into a card: 'read' view carrying the envelope-stripped content, and the decline paths (error result, non-single-text content, malformed envelope with valid meta, and valid envelope with absent or malformed meta) all fall back to undefined. Both changed source files hold per-file 100% coverage. This change carries the snapshot evidence for the persisted meta and the extended union, not for a new rendered view: the re-recorded ACP session fixtures (fs-read, fs-read-window, fs-edit, fs-policy-reject, fs-write-overwrite, parallel-tool-calls, workspace-context, workspace-edit) pin the persisted read meta (with {{cwd}}-tokenized paths), and cordis-inspect-jsdoc pins the four-member ToolResultView union. The then-current terminal snapshot also pinned that a consumer's generic dim-Markdown fallback stayed byte-identical; the structured card's own assembled-application transcript belonged to its consuming frontend change.