fix(fs): validate read meta semantics and sync public result-view docs

readMetaFromMeta narrows the opaque persisted meta boundary, so beyond
shape it now rejects replayed JSON that is well-typed but semantically
invalid: line numbers must be 1-based integers, totalLines a non-negative
integer, and line numbers must strictly increase without exceeding
totalLines. Any violation declines to the generic fallback.

Sync the public ToolResultView contract across the core/tools and
tool-fs READMEs and docs/core-data-structures/tools for the fourth
result-view member and the ReadFileLine vocabulary, and expand the
Agent Note Testing section with the new rejection paths and the snapshot
evidence this PR carries.
This commit is contained in:
Chinesezjc
2026-07-30 19:42:25 +08:00
parent 1d0e6eea32
commit 26488d6e82
15 changed files with 61 additions and 23 deletions

View File

@@ -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

View File

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

View File

@@ -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) 规定卡片词汇。

View File

@@ -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

View File

@@ -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

View File

@@ -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`,只有派生出的呈现元数据会被持久化
## 工具就是执行器;策略是事件门禁

View File

@@ -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<string, unknown>
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<string, unknown>
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 } }
}

View File

@@ -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()
})
})

View File

@@ -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', () => {