diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml index f8e81a4abe..60cd249263 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-read-card.md -2026-07-30-web-read-card.md: 028c344261e8e637344252fcdfee4b2b788d1846 -2026-07-30-web-read-card.zh.md: b392f9f1eb9f05b92b1f264d605e4fc91960de33 +2026-07-30-web-read-card.md: 076959680737d3bc439deb367dcdcdc5da83dfb3 +2026-07-30-web-read-card.zh.md: c3e3ec1c3ccd8b36832bc12b6a7ffd000745fb74 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md index 028c344261..0769596807 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.md @@ -40,7 +40,7 @@ The read tool now computes `presentationMeta` for every top-level read, a small ## 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, and a non-string `lang`). `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. A keyless snapshot and the assembled-application transcript for the rendered card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering. +`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: 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`). `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 PR 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 keyless snapshot and assembled-application transcript for the rendered read card belong to the follow-up Web PR that consumes the view, since this PR adds no new product-user-visible rendering — the TUI routes the read card through its existing generic dim-Markdown fallback (`transcript.ts` treats `card: 'read'` like `card: 'generic'`), so its output is unchanged. ## Related diff --git a/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md index b392f9f1eb..c3e3ec1c3c 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md @@ -40,7 +40,7 @@ read 工具现在为每次顶层 read 计算 `presentationMeta`,这是对已 ## Testing -`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、以及非字符串 `lang`)。`packages/fs/tool-fs/tests/tools.spec.ts` 固定工具接线:`execute` 把结构化窗口(含与不含 `lang` 提示)作为 `meta` 附上、`presentResult` 把它收窄为携带剥信封 `content` 的 `card: 'read'` 视图、以及各拒绝路径(错误结果、非单文本内容、meta 有效但信封畸形、信封有效但 meta 缺失或畸形)都回退到 `undefined`。两个改动的源文件保持逐文件 100% 覆盖率。已渲染 card 的 keyless 快照与组装应用 transcript 属于消费该视图的后续 Web PR,因为本 PR 不新增任何面向产品用户可见的渲染。 +`packages/fs/tool-fs/tests/read-render.spec.ts` 单测 `langFromPath`(已知扩展名的大小写不敏感、扩展名在最后一段与最后一个点之后读取、以及 `undefined` 各情况:dotfile、无扩展名、结尾的点、未知)与 `readMetaFromMeta`(含与不含 `lang` 的良构收窄,以及每种拒绝:非对象、数组、缺失或类型错误的 `path`/`totalLines`/`lines`、畸形行项、非字符串 `lang`,以及——因为该函数收窄持久化的 opaque `meta` 边界——良构类型的回放 JSON 仍可能携带的语义无效路径:不是 1-based 整数的行 `number`(`0`、`1.5`、`NaN`、`Infinity`)、不是非负整数的 `totalLines`(`-1`、`1.5`、`NaN`)、以及行号重复、递减或超过 `totalLines` 的情况)。`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'` 处理),因此其输出保持不变。 ## Related diff --git a/docs/core-data-structures/tools.i18n.yaml b/docs/core-data-structures/tools.i18n.yaml index fa49f46c59..612aaf889b 100644 --- a/docs/core-data-structures/tools.i18n.yaml +++ b/docs/core-data-structures/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/tools.md -tools.md: dad7f7421caa94940801407fd4ef7fd936eb05c9 -tools.zh.md: 8386e5870e665e90ee0dbada8cb98084281001a7 +tools.md: dc4c535e615ddfd0b0b49ceefd4bcdeaede8f926 +tools.zh.md: f7c1a20ff2e3f655af7bb6ac7a3fa2436491a7b2 diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index dad7f7421c..dc4c535e61 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -447,8 +447,8 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } 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: - `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? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), or `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. +- `ToolResultView` (completed): `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit; a capable UI shows an exit-status pill, while another may derive a fenced ` ```console ` fallback), `{ card: 'diff', title?, diffs }` (a completed file mutation → the change to show, typically the applied hunks with context lines computed from the before/after content, or a whole-file diff when there is no before-image), or `{ card: 'read', title?, path, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to). Completed views replace pending views, so mutation tools return a diff result even when it duplicates the call-time snippet. -`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 Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. +`ToolCallKind` (`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`) picks an icon on a generic card. `FileLocation` (`{ path, line? }`), `FileDiff` (`{ path, oldText, newText }`), and `ReadFileLine` (`{ number, text }`, one 1-based numbered line of a read window) are the shared file-card vocabulary. The design is pinned in [the render-intent-union Agent Note](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md); the TUI and host/client runtime project this neutral vocabulary into their own views. The full presentation field docs live in [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts). The `bash` schema and executor are on [bash.md](bash.md); generic background controls are on [tasks.md](tasks.md). diff --git a/docs/core-data-structures/tools.zh.md b/docs/core-data-structures/tools.zh.md index 8386e5870e..f7c1a20ff2 100644 --- a/docs/core-data-structures/tools.zh.md +++ b/docs/core-data-structures/tools.zh.md @@ -447,8 +447,8 @@ type ObjectJsonSchema = JsonSchemaNode & { type: 'object' } 工具希望其调用在 UI 中如何呈现(编辑器工具调用卡片、CLI(命令行界面)日志行),提供方无关,使工具在不依赖任何客户端协议的情况下描述自身。`presentCall`/`presentResult` 返回一个 **`card` 标签的渲染意图**——一个可辨识联合类型,UI 桥接层据此分发: - `ToolCallView`(待执行):`{ card: 'generic', title, kind?, rawInput?, content?, locations? }`(默认卡片;`locations` 是 `{ path, line? }[]`,表示调用读取/修改的文件,供编辑器跟随)、`{ card: 'terminal', title, description?, cwd? }`(shell 命令→终端卡片)、或 `{ card: 'diff', title, diffs, locations? }`(文件创建/修改→行内 diff 卡片;`diffs` 是 `{ path, oldText, newText }[]`,新文件时 `oldText: null`)。 -- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、或 `{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 +- `ToolResultView`(已完成):`{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`(捕获的运行输出 + 退出状态;有能力的 UI 显示退出状态标签,其他 UI 可以派生围栏 ` ```console ` 回退)、`{ card: 'diff', title?, diffs }`(已完成的文件变更→要展示的变更,通常是从变更前后内容计算出带上下文行的已应用 hunk,或在没有前像时的整文件 diff)、或 `{ card: 'read', title?, path, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 diff 结果。 -`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)与 `FileDiff`(`{ path, oldText, newText }`)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 +`ToolCallKind`(`'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'`)用于为通用卡片选择图标。`FileLocation`(`{ path, line? }`)、`FileDiff`(`{ path, oldText, newText }`)与 `ReadFileLine`(`{ number, text }`,读取窗口中一行带 1-based 行号的内容)是共享的文件卡片词汇。该设计由[渲染意图联合类型 Agent Note(agent 决策记录)](../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md)固定;TUI 和 host/client 运行时将这套中性词汇投影为各自的视图。 完整的展示字段文档见 [`packages/core/tools/src/presentation.ts`](../../packages/core/tools/src/presentation.ts)。`bash` schema 与执行器见 [bash.md](bash.md);通用后台控制见 [tasks.md](tasks.md)。 diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index 429e76ed6a..10ae5b4c64 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/tools/README.md -README.md: e5adb153e77d7a2d8c4068b016194ab6abb6473e -README.zh.md: c67a2f2ee4ac2a9d587c6efbf2b5c60d14fc58c2 +README.md: bff80e34d8b8a03424263ae978fe43c143bcb4fa +README.zh.md: 9d141cdfe91f14420bcd5a394b8bbd5871407b42 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index e5adb153e7..bff80e34d8 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -108,7 +108,7 @@ Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. E Tools optionally own pure `presentCall()` and `presentResult()` render intents, so UIs do not special-case tool names: - Call views are `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`, `{ card: 'terminal', title, description?, cwd? }`, or `{ card: 'diff', title, diffs, locations? }`. -- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, or `{ card: 'diff', title?, diffs }`. +- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'read', title?, path, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `lines` is `{ number, text }[]` keeping each file line number, and `content` is the envelope-stripped text a UI without read support falls back to). Returning `undefined` selects generic fallback. Presenters depend only on their arguments and the durable result because UIs call them during live streaming and log replay. `output.presentationMeta(args, value)` derives JSON metadata for direct surface calls; that metadata persists with `tool/result` and returns to `presentResult`, while the canonical value itself remains execution-local and is never replayed. Nested Code dispatches do not compute metadata. `defineTool` soft-validates older logged arguments and falls back instead of crashing replay. `dsh-tool-bash` and `dsh-tool-fs` are the reference implementations; the [canonical-output Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) owns the value/presentation split and the [render-intent Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) owns card vocabulary. diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index c67a2f2ee4..9d141cdfe9 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -108,7 +108,7 @@ ctx.tools.register(defineTool({ 工具可以选择拥有纯 `presentCall()` 和 `presentResult()` 呈现意图,使 UI 无需特殊处理工具名称: - 调用视图为 `{ card: 'generic', title, kind?, rawInput?, content?, locations? }`、`{ card: 'terminal', title, description?, cwd? }` 或 `{ card: 'diff', title, diffs, locations? }`。 -- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }` 或 `{ card: 'diff', title?, diffs }`。 +- 结果视图为 `{ card: 'generic', title?, content? }`、`{ card: 'terminal', title?, output?, exitCode?, signal? }`、`{ card: 'diff', title?, diffs }` 或 `{ card: 'read', title?, path, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`lines` 是 `{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)。 返回 `undefined` 会选择通用回退。呈现器只依赖其参数和持久结果,因为 UI 会在实时流式输出和日志回放期间调用它们。`output.presentationMeta(args, value)` 为直接接口调用派生 JSON 元数据;该元数据随 `tool/result` 持久化并传回 `presentResult`,而规范值本身仍只存在于执行局部,绝不会回放。嵌套 Code 分发不会计算元数据。`defineTool` 会软验证较旧的日志参数并回退,而不会使回放崩溃。`dsh-tool-bash` 与 `dsh-tool-fs` 是参考实现;[规范输出 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-20-canonical-tool-output-contract.md) 规定值/呈现拆分,[呈现意图 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-tool-render-intent-union.md) 规定卡片词汇。 diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index a3ebe97bc4..0fd5a37feb 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/fs/tool-fs/README.md -README.md: 4ff9b043525e8e7a0b59e3d91410951d88bb9a69 -README.zh.md: ce93e10072d74ce268273aa472bfbb3f34f46259 +README.md: 9e72ed53324d5f5efeaf659427c02d826221425c +README.zh.md: aa94a7f6144b6cc6da34b059f5699312737d38a2 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 4ff9b04352..9e72ed5332 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -34,7 +34,7 @@ All keys are optional; the defaults are the shipped read caps. Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. Write/edit derive replayable diff-card metadata from these values; the values themselves are execution-local and are not added to `tool/result`. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. ## The tool is the executor; policy is an event gate diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index ce93e10072..aa94a7f614 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -34,7 +34,7 @@ await ctx.plugin(ToolFs) // this package — re 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。写入/编辑从这些值派生可回放的 diff 卡片元数据;这些值本身仅限于本次执行,不会添加到 `tool/result`。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 ## 工具就是执行器;策略是事件门禁 diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index b2cd7cbbe0..43ff2b8ca5 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -223,25 +223,41 @@ export interface FsReadMeta { lang?: string } -/** Whether `value` is a valid {@link FileTextLine} (defensive narrowing from opaque `meta`). */ +/** + * Whether `value` is a valid {@link FileTextLine} (defensive narrowing from + * opaque `meta`). `number` must be a 1-based integer line number, since a card + * rendered from a zero, fractional, or non-finite line number would violate the + * 1-based numbering contract the read window promises. + */ function isFileTextLine(value: unknown): value is FileTextLine { if (typeof value !== 'object' || value === null || Array.isArray(value)) return false const { number, text } = value as Record - return typeof number === 'number' && typeof text === 'string' + return typeof number === 'number' && Number.isInteger(number) && number >= 1 && typeof text === 'string' } /** * Narrow opaque live or replayed result metadata to a structured read window. * Malformed metadata returns `undefined` so presentation can fall back to the - * generic text card instead of throwing during replay. + * generic text card instead of throwing during replay. Beyond shape, the + * semantic contract of a read window is enforced against replayed JSON that is + * well-typed but out of range: `totalLines` must be a non-negative integer, each + * line number must be a 1-based integer, the line numbers must strictly increase, + * and no line number may exceed `totalLines`. Any violation declines to the + * generic fallback rather than emitting a card that misnumbers or overcounts. * @param meta - result metadata. - * @returns the validated read window, or `undefined` for absent or malformed data. + * @returns the validated read window, or `undefined` for absent, malformed, or semantically invalid data. */ export function readMetaFromMeta(meta: unknown): FsReadMeta | undefined { if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined const { path, lines, totalLines, lang } = meta as Record if (typeof path !== 'string' || typeof totalLines !== 'number') return undefined + if (!Number.isInteger(totalLines) || totalLines < 0) return undefined if (!Array.isArray(lines) || !lines.every(isFileTextLine)) return undefined if (lang !== undefined && typeof lang !== 'string') return undefined + let previous = 0 + for (const { number } of lines) { + if (number <= previous || number > totalLines) return undefined + previous = number + } return { path, lines, totalLines, ...lang === undefined ? {} : { lang } } } diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index 462df231c2..14c9c57bf1 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -166,4 +166,27 @@ describe('readMetaFromMeta', () => { expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined() expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined() }) + + it('rejects a line number that is not a 1-based integer', () => { + expect(readMetaFromMeta({ ...good, lines: [{ number: 0, text: 'x' }], totalLines: 1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: 1.5, text: 'x' }], totalLines: 2 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: NaN, text: 'x' }], totalLines: 1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, lines: [{ number: Infinity, text: 'x' }], totalLines: 1 })).toBeUndefined() + }) + + it('rejects a totalLines that is not a non-negative integer', () => { + expect(readMetaFromMeta({ ...good, totalLines: -1 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: 1.5 })).toBeUndefined() + expect(readMetaFromMeta({ ...good, totalLines: NaN })).toBeUndefined() + }) + + it('rejects lines that do not strictly increase or exceed totalLines', () => { + const twoLines = { path: '/abs/a', lang: 'ts' } + // Duplicate line numbers. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 1, text: 'a' }, { number: 1, text: 'b' }], totalLines: 2 })).toBeUndefined() + // Out-of-order line numbers. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 2, text: 'b' }, { number: 1, text: 'a' }], totalLines: 2 })).toBeUndefined() + // A line number past totalLines. + expect(readMetaFromMeta({ ...twoLines, lines: [{ number: 3, text: 'c' }], totalLines: 2 })).toBeUndefined() + }) }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 4bf64cb8a5..87029e2a87 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -351,7 +351,6 @@ describe('read tool', () => { if (result.isError) throw new Error('expected read success') expect(result.meta).toEqual({ path: '/abs/notes', lines: [{ number: 1, text: 'plain' }], totalLines: 1 }) }) - }) describe('formatReadOutput footer variants', () => {