fix(fs): persist read window offset in the read card meta

An empty read window (byte cap below the first selected line: `lines: []`
with `totalLines > 0`) dropped `offset` from the persisted presentation
meta, so a replayed read card could not report where the window starts or
where a continuation resumes. Carry `offset` on `FsReadMeta`,
`ReadResultView`, and the `presentationMeta` projection, and validate it in
`readMetaFromMeta` (1-based integer; the first line number may not fall
below it). Re-record the ACP fixtures and the cordis api catalog.

Also correct the Note's `parallel-file-reads` golden path
(examples/tui-agent -> apps/cli) and record the pre-card replay-degradation
tradeoff in the Decision section.
This commit is contained in:
Chinesezjc
2026-07-30 22:00:35 +08:00
parent ca19468ae2
commit 4fbe46c381
24 changed files with 75 additions and 42 deletions

View File

@@ -2101,7 +2101,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ReadResultView',
declaration: 'export interface ReadResultView {\n card: \'read\';\n title?: string;\n path: string;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n}',
declaration: 'export interface ReadResultView {\n card: \'read\';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n}',
},
{
name: 'ReasoningBlock',

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: bff80e34d8b8a03424263ae978fe43c143bcb4fa
README.zh.md: 9d141cdfe91f14420bcd5a394b8bbd5871407b42
README.md: dc3d059c1ce16f11cb0650e266762eb6d7466e34
README.zh.md: 8d1ee0139b2d311fed07a0673cd772222ae22032

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? }`, `{ 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).
- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }` (a completed file read → a line-numbered, optionally syntax-highlighted code view; `offset` is the 1-based first line the window requested, kept even when `lines` is empty; `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: 'read', title?, path, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`lines``{ number, text }[]`,保留每一行的文件行号,`content` 是无读取能力的 UI 回退时使用的去信封文本)。
- 结果视图为 `{ card: 'generic', title?, content? }``{ card: 'terminal', title?, output?, exitCode?, signal? }``{ card: 'diff', title?, diffs }``{ card: 'read', title?, path, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`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

@@ -207,6 +207,12 @@ export interface ReadResultView {
title?: string
/** The read file's path (the model-facing path; the bridge relativizes it). */
path: string
/**
* The 1-based first line the window requested, preserved even when `lines` is
* empty (a byte cap below the first selected line yields an empty window) so a
* UI knows where the window starts and where a continuation resumes.
*/
offset: number
/** The returned window's lines, in file order, each keeping its file line number. */
lines: ReadFileLine[]
/** Exact total line count in the file, so a UI can show a "showing N of M" affordance. */

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: 9e72ed53324d5f5efeaf659427c02d826221425c
README.zh.md: aa94a7f6144b6cc6da34b059f5699312737d38a2
README.md: c00b59fed06249e6d9479c4a809cdf7d78f93239
README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6

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, 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.
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, offset, 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 }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `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, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。
## 工具就是执行器;策略是事件门禁

View File

@@ -220,6 +220,8 @@ export function langFromPath(path: string): string | undefined {
export interface FsReadMeta {
/** The read file's model-facing path. */
path: string
/** The 1-based first line the window requested, kept even when `lines` is empty. */
offset: number
/** The returned window's lines, each keeping its file line number. */
lines: FileTextLine[]
/** Exact total line count in the file. */
@@ -245,24 +247,26 @@ function isFileTextLine(value: unknown): value is FileTextLine {
* Malformed metadata returns `undefined` so presentation can fall back to the
* 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.
* well-typed but out of range: `offset` must be a 1-based integer, `totalLines`
* must be a non-negative integer, each line number must be a 1-based integer no
* less than `offset`, 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, 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
const { path, offset, lines, totalLines, lang } = meta as Record<string, unknown>
if (typeof path !== 'string' || typeof totalLines !== 'number' || typeof offset !== 'number') return undefined
if (!Number.isInteger(offset) || offset < 1) 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
let previous = offset - 1
for (const { number } of lines) {
if (number <= previous || number > totalLines) return undefined
previous = number
}
return { path, lines, totalLines, ...lang === undefined ? {} : { lang } }
return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } }
}

View File

@@ -125,6 +125,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
const lang = langFromPath(value.path)
return {
path: value.path,
offset: value.offset,
lines: value.lines.map(({ number, text }) => ({ number, text })),
totalLines: value.totalLines,
...lang === undefined ? {} : { lang },
@@ -186,6 +187,7 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
return {
card: 'read',
path: meta.path,
offset: meta.offset,
lines: meta.lines,
totalLines: meta.totalLines,
...meta.lang === undefined ? {} : { lang: meta.lang },

View File

@@ -151,14 +151,19 @@ describe('langFromPath', () => {
})
describe('readMetaFromMeta', () => {
const good = { path: '/abs/a.ts', lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
const good = { path: '/abs/a.ts', offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1, lang: 'ts' }
it('narrows a well-formed read meta, with and without a lang hint', () => {
expect(readMetaFromMeta(good)).toEqual(good)
const noLang = { path: '/abs/a', lines: [], totalLines: 0 }
const noLang = { path: '/abs/a', offset: 1, lines: [], totalLines: 0 }
expect(readMetaFromMeta(noLang)).toEqual(noLang)
})
it('narrows an empty window at a positive offset (byte cap below the first selected line)', () => {
const empty = { path: '/abs/a', offset: 5, lines: [], totalLines: 9 }
expect(readMetaFromMeta(empty)).toEqual(empty)
})
it('returns undefined for absent, non-object, or array meta', () => {
expect(readMetaFromMeta(undefined)).toBeUndefined()
expect(readMetaFromMeta(null)).toBeUndefined()
@@ -168,6 +173,7 @@ describe('readMetaFromMeta', () => {
it('returns undefined when a field is missing or the wrong type (defensive narrowing)', () => {
expect(readMetaFromMeta({ ...good, path: 5 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: '1' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, totalLines: '1' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: 'nope' })).toBeUndefined()
expect(readMetaFromMeta({ ...good, lines: [{ number: '1', text: 'x' }] })).toBeUndefined()
@@ -176,6 +182,17 @@ describe('readMetaFromMeta', () => {
expect(readMetaFromMeta({ ...good, lang: 5 })).toBeUndefined()
})
it('rejects an offset that is not a 1-based integer', () => {
expect(readMetaFromMeta({ ...good, offset: 0 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: 1.5 })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: NaN })).toBeUndefined()
expect(readMetaFromMeta({ ...good, offset: Infinity })).toBeUndefined()
})
it('rejects a first line number below offset', () => {
expect(readMetaFromMeta({ ...good, offset: 2, lines: [{ number: 1, text: 'x' }], totalLines: 2 })).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()
@@ -190,7 +207,7 @@ describe('readMetaFromMeta', () => {
})
it('rejects lines that do not strictly increase or exceed totalLines', () => {
const twoLines = { path: '/abs/a', lang: 'ts' }
const twoLines = { path: '/abs/a', offset: 1, 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.

View File

@@ -329,6 +329,7 @@ describe('read tool', () => {
// The extension drives the lang hint; the window rides on persisted meta.
expect(result.meta).toEqual({
path: '/abs/a.ts',
offset: 1,
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'ts',
@@ -337,6 +338,7 @@ describe('read tool', () => {
expect(view).toEqual({
card: 'read',
path: '/abs/a.ts',
offset: 1,
lines: [{ number: 1, text: 'const x = 1' }, { number: 2, text: 'const y = 2' }],
totalLines: 2,
lang: 'ts',
@@ -349,7 +351,7 @@ describe('read tool', () => {
fs.files.set('key:notes', 'plain')
const result = await call(ctx, 'read', { file_path: 'notes' })
if (result.isError) throw new Error('expected read success')
expect(result.meta).toEqual({ path: '/abs/notes', lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
expect(result.meta).toEqual({ path: '/abs/notes', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
})
})
@@ -485,7 +487,7 @@ describe('tool-owned presentation (pure presentCall)', () => {
// The structured line data rides on persisted meta (the raw output object is
// not on the wire); presentResult narrows it and appends the stripped text as
// the no-capability `content` fallback.
const meta = { path: '/tmp/a.ts', lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
const meta = { path: '/tmp/a.ts', offset: 1, lines: [{ number: 1, text: 'hello' }], totalLines: 1, lang: 'ts' }
expect(await presentResult('read', { file_path: 'a.ts' }, {
content: [{ type: 'text', text: '<path>/tmp/a.ts</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
isError: false,
@@ -493,6 +495,7 @@ describe('tool-owned presentation (pure presentCall)', () => {
})).toEqual({
card: 'read',
path: '/tmp/a.ts',
offset: 1,
lines: [{ number: 1, text: 'hello' }],
totalLines: 1,
lang: 'ts',
@@ -502,10 +505,11 @@ describe('tool-owned presentation (pure presentCall)', () => {
expect(await presentResult('read', { file_path: 'notes' }, {
content: [{ type: 'text', text: '<path>/tmp/notes</path>\n<type>file</type>\n<content>\nbody\n</content>' }],
isError: false,
meta: { path: '/tmp/notes', lines: [{ number: 1, text: 'body' }], totalLines: 1 },
meta: { path: '/tmp/notes', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 },
})).toEqual({
card: 'read',
path: '/tmp/notes',
offset: 1,
lines: [{ number: 1, text: 'body' }],
totalLines: 1,
content: [{ type: 'text', text: 'body' }],