Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
T
Chinesezjc fe5098a2bc fix(fs): correct Note pre-release link and restore read.ts decline coverage
The Note's pre-release-stance link used the wrong depth and target
(../../../CLAUDE.md); point it at ../../../../AGENTS.md with the section
anchor so verify-md-links passes. The presentResult decline test's meta
lacked the now-required offset, so it declined at meta narrowing instead of
exercising the content-shape decline (read.ts:181-183); add offset back.
2026-07-30 22:26:14 +08:00

11 KiB
Raw Blame History

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

Status: implemented

English | 中文

Problem

read 工具返回规范化输出对象 { path, offset, lines: [{ number, text }], totalLines },但它的展示层把这个结构压平了。presentCall 声明为 GenericCallView(kind: 'read',一个跟随定位),presentResult 返回 GenericResultView,其唯一内容是剥掉 <path>…</path><type>file</type><content>…</content> 信封后的面向模型文本。收到该视图的 UI 只看到一个压平的文本块:行号以 N: 前缀烘焙进文本、文件语言未知、totalLines 丢失。capable 客户端无法像渲染 diff 那样渲染一次 read——即带行号、语法高亮、行号槽与内容分离的代码视图。

结构化数据在下游无法恢复。线上(wire)的工具结果只携带面向模型的 ContentBlock[](已渲染文本)加上一个不透明的 meta;规范化输出对象留在工具内,从不到达客户端或会话日志。因此想要行数组、总数和语言提示的客户端无法从 N: text 文本里解析回它们——工具必须把它们投影到一个会持久化的通道上。

Decision

给渲染意图 union 新增第四个 card 标签 read——仅在结果侧。ToolResultView 增加 ReadResultView { card: 'read'; title?; path; lines: ReadFileLine[]; totalLines; lang?; content? };ReadFileLine { number; text } 是共享的行单元。ToolCallView 不动:待定状态仍是 GenericCallView(kind: 'read'),因为一次调用在 execute 返回前不携带文件内容,调用时没有可展示的结构。这与 bash 终端 card 不同——终端 card 两侧都打标签,因为终端调用在调用时已携带命令和 cwd,而 read 调用既无内容也无总数,给调用侧打标签只会新增一个空变体。

read 工具通过 output.presentationMeta 投影结构化窗口,这与 write/edit 用来投影其应用 diff hunk 的持久化通道相同(规范化工具输出契约)。presentationMeta 对一次顶层 surface 调用运行一次,返回 { path, offset, lines, totalLines, lang? } 作为会话校验并存储在结果 meta 上的 JSON,presentResult 在 live 和回放路径上都把该 meta 收窄回 ReadResultView。offset(窗口请求的 1-based 起始行)一并携带,是因为当字节上限低于首个选中行时,窗口会返回空的 lines 数组而 totalLines 为正;没有持久化的 offset,这类窗口的回放 card 就无法报告它从哪行开始、或续读应从哪行继续,而末行推断与文本重解析两种兜底都有损。没有这个通道,行数组和总数就无法触及:原始输出对象不在线上,而重新解析 N: text 文本既有损又对截断脚注脆弱。

presentResult 在以下情况返回 undefined——即 generic 回退:meta 缺失或畸形(readMetaFromMeta 防御性收窄它,因此回放旧的已记录结果永不抛错)、结果是错误、以及单个文本块不是 read 信封。本 card 出现之前记录的结果——信封合法但无持久化 meta——有意走同一条 undefined 路径:客户端回退到原始 result.content,因此显示带 <path>/<type>/<content> 信封的原文,而非旧展示器返回的剥信封 generic card。这是 pre-release 立场下接受的降级:拒绝旧的磁盘格式,而非加一个剥信封的兼容分支——本 PR 已重录全部已发布 fixtures,且 session 格式不承诺向后兼容。在成功路径上,presentResult 在结构化字段之外携带 content(剥信封后的文本),因此不具备 read 能力的 UI(包括当前的 TUI)通过 generic/default card 分支渲染文件文本,与之前完全一致。TUI 的 renderBody switch(packages/ui/tui/src/components/transcript.ts)不是 assertNever 穷尽的:terminal 和 diff 有分支,其余都落入 generic 分支,该分支读取 view.content。仅有该默认分支还不够:render() 在一个独立门控上设置 genericContent(连同 dim-Markdown 的 dimBody 处理),该门控原先只判 card === 'generic',因此 read card 虽保留文本却会丢失 dim 样式。现在该门控也接纳 card: 'read',让 content 走同一条 dim-Markdown 路径,因此 read 在 TUI 中的渲染与 read card 出现之前完全一致。除这一处门控外,TUI 无需 read 专属代码。

语言提示推导

langFromPath(在 read-render.ts 中)通过一张固定小表(LANG_BY_EXTENSION,覆盖常见源码、配置、标记扩展名)把文件扩展名映射到语法高亮语言 id。它读取最后一个路径段与最后一个点之后的扩展名,大小写不敏感,并对以下情况返回 undefined:dotfile(.gitignore)、无扩展名(/etc/hosts)、结尾的点、以及任何未知扩展名——此时 card 省略 lang,UI 渲染纯文本。该表不是可调项(tunable):它是 UI 可忽略的展示提示,而非随部署变化的选择,未知扩展名降级为纯文本而非失败。它有意保持小规模而非穷尽的语言注册表;扩展它是一行表项新增。

Alternatives considered

在 presentResult 中重新解析 N: text 面向模型文本。 已否决:结构化行数组将不得不通过按第一个 : 切分每行来重建,这既有歧义(某行文本自身含 : ),又丢失精确的 totalLines(脚注只在部分分支中陈述它),并在渲染格式变化时立即失效。presentationMeta 携带已经结构化的数据,无需重新解析。

调用侧也打标签(ReadCallView),镜像终端 card 的两侧对称。 已否决:read 调用在执行前没有内容、没有行数组、没有总数——调用侧 read card 会是一个空变体,重复 GenericCallView(kind: 'read',跟随定位)已经表达的东西。终端 card 两侧都打标签是因为终端调用确实携带调用时数据(命令、cwd);read 调用没有。

把结构化窗口放进新服务或旁路通道而非 meta。 已否决:meta 是既有的持久化展示通道(write/edit 的应用 diff 就搭它),它随会话日志免费回放,无需新接线。服务会重新发明事件日志已提供的持久化与回放。

用 merge-extensible union 而非封闭标签。 出于渲染意图 union 封闭的相同理由否决:新 card 需要消费代码来渲染它,因此被消费者静默丢弃的变体比编译错误更糟。把 read 加入封闭 union 是扩展它的许可方式——每个在 card 上 switch 的消费者都继续编译,因为新成员落入其 generic default,而想要富视图的消费者新增自己的分支。

Consequences

ToolResultView 多了第四个成员。每个在 card 上 switch 的消费者都继续编译:TUI 和当前 Web 客户端把未知 card 路由到其 generic 路径,而 read card 携带 content 使该路径显示文件文本。从 lines/lang/totalLines 渲染带行号、语法高亮视图的 Web 前端是单独的后续 PR;本 PR 是让数据可触及的后端。在它落地前,read 在各处的渲染与之前完全一致(generic 文本 card)。

read 工具现在为每次顶层 read 计算 presentationMeta,这是对已在手数据的一次小投影(一次 lines.map 和一次 langFromPath 调用)。meta 随会话日志持久化,因此 read 结果在磁盘上略大——它已渲染为文本的行数组,现在也以结构化形式存在。

Testing

packages/fs/tool-fs/tests/read-render.spec.ts 单测 langFromPath(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 undefined 各情况:dotfile、无扩展名、结尾的点、未知)与 readMetaFromMeta(含与不含 lang 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 path/totalLines/lines、畸形行项、非字符串 lang,以及——因为该函数收窄持久化的 opaque meta 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的 offset、小于 offset 的首行 number、不是 1-based 整数的行 number(0、1.5、NaN、Infinity)、不是非负整数的 totalLines(-1、1.5、NaN)、以及行号重复、递减或超过 totalLines 的情况;并且收窄正 offset 处的空窗口(字节上限低于首个选中行))。packages/fs/tool-fs/tests/tools.spec.ts 固定工具接线:execute 把结构化窗口(含与不含 lang 提示)作为 meta 附上、presentResult 把它收窄为携带剥信封 content 的 card: 'read' 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 undefined。两个改动的源文件保持逐文件 100% 覆盖率。本 PR 携带的是持久化 meta 与扩展后联合类型的快照证据,而非新渲染视图的证据:重录的 ACP session fixtures(fs-read、fs-read-window、fs-edit、fs-policy-reject、fs-write-overwrite、parallel-tool-calls、workspace-context、workspace-edit)钉住持久化的读取 meta(含 {{cwd}} 令牌化路径),cordis-inspect-jsdoc 钉住四成员的 ToolResultView 联合类型。已渲染读取 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染——TUI 通过其现有的通用 dim-Markdown 回退路由读取 card(transcript.ts 把 card: 'read' 当作 card: 'generic' 处理),因此其输出保持不变。apps/cli 的 parallel-file-reads 终端 golden(apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt)正钉住这一点:一次真实回放执行 read 工具、经新的 card: 'read' 门渲染,golden 的 dim-Markdown 行与本 card 出现前 generic read 所产出的逐字节一致。