فهرست منبع

Merge latest master into web transcript projection

Tianyi Cui 1 ماه پیش
والد
کامیت
3dc2a449e5
33فایلهای تغییر یافته به همراه522 افزوده شده و 61 حذف شده
  1. 6 0
      .agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml
  2. 42 0
      .agents/notes/implemented/feature/2026-07-30-web-read-card.md
  3. 42 0
      .agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md
  4. 1 1
      docs/config-catalog.md
  5. 6 6
      docs/cordis-catalog/events.md
  6. 1 1
      docs/cordis-catalog/services.md
  7. 2 2
      docs/core-data-structures/tools.i18n.yaml
  8. 2 2
      docs/core-data-structures/tools.md
  9. 2 2
      docs/core-data-structures/tools.zh.md
  10. 6 6
      docs/event-producer-consumer.md
  11. 0 0
      examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
  12. 1 1
      examples/acp-agent/tests/snapshots/fs-edit/session.jsonl
  13. 1 1
      examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl
  14. 1 1
      examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl
  15. 1 1
      examples/acp-agent/tests/snapshots/fs-read/session.jsonl
  16. 1 1
      examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl
  17. 2 2
      examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl
  18. 2 2
      examples/acp-agent/tests/snapshots/workspace-context/session.jsonl
  19. 1 1
      examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl
  20. 9 1
      packages/cordis/tool-cordis/src/api-catalog.ts
  21. 2 2
      packages/core/tools/README.i18n.yaml
  22. 1 1
      packages/core/tools/README.md
  23. 1 1
      packages/core/tools/README.zh.md
  24. 2 0
      packages/core/tools/src/index.ts
  25. 54 1
      packages/core/tools/src/presentation.ts
  26. 2 2
      packages/fs/tool-fs/README.i18n.yaml
  27. 1 1
      packages/fs/tool-fs/README.md
  28. 1 1
      packages/fs/tool-fs/README.zh.md
  29. 102 0
      packages/fs/tool-fs/src/read-render.ts
  30. 34 4
      packages/fs/tool-fs/src/read.ts
  31. 101 1
      packages/fs/tool-fs/tests/read-render.spec.ts
  32. 77 5
      packages/fs/tool-fs/tests/tools.spec.ts
  33. 15 11
      packages/ui/tui/src/components/transcript.ts

+ 6 - 0
.agents/notes/implemented/feature/2026-07-30-web-read-card.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# 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: 1fb3d61a113d26f6daf023fc791f3638055b5be0
+2026-07-30-web-read-card.zh.md: 946bcca95bcc9bb50beb1ef22e77e4a21728b538

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 42 - 0
.agents/notes/implemented/feature/2026-07-30-web-read-card.md


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 42 - 0
.agents/notes/implemented/feature/2026-07-30-web-read-card.zh.md


+ 1 - 1
docs/config-catalog.md

@@ -1992,7 +1992,7 @@ export interface Config {
 export type ToolPresentationMode = 'native' | 'code' | 'both'
 ```
 
-Source: [`packages/core/tools/src/index.ts:582`](../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:584`](../packages/core/tools/src/index.ts)
 
 ## `@deepseek-ai/dsh-tui`
 

+ 6 - 6
docs/cordis-catalog/events.md

@@ -938,7 +938,7 @@ A tool was registered or unregistered, or a scoped restriction changed (the avai
 'tools/change'(): void
 ```
 
-Source: [`packages/core/tools/src/index.ts:160`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:162`](../../packages/core/tools/src/index.ts)
 
 ### `tools/code-dispatch-log` — waterfall
 
@@ -962,7 +962,7 @@ Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bri
 
 Types: [CodeDispatchLog](../core-data-structures/tools.md) · [ContentBlock](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:142`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:144`](../../packages/core/tools/src/index.ts)
 
 ### `tools/execute` — waterfall
 
@@ -984,7 +984,7 @@ Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a nor
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolDispatchExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:117`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:119`](../../packages/core/tools/src/index.ts)
 
 ### `tools/post-execute` — waterfall
 
@@ -1007,7 +1007,7 @@ Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts
 
 Types: [PostToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:129`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:131`](../../packages/core/tools/src/index.ts)
 
 ### `tools/pre-execute` — waterfall
 
@@ -1028,7 +1028,7 @@ Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approv
 
 Types: [PreToolDecision](../core-data-structures/tools.md) · [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:108`](../../packages/core/tools/src/index.ts)
 
 ### `tools/result` — emit
 
@@ -1047,7 +1047,7 @@ Observe the frozen, lossless-JSON final outcome. Listener failures are contained
 
 Types: [Scoped](../core-data-structures/scope.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolRegistry](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:150`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:152`](../../packages/core/tools/src/index.ts)
 
 ## `workflow/*`
 

+ 1 - 1
docs/cordis-catalog/services.md

@@ -2313,7 +2313,7 @@ async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>
 
 Types: [ScopeKey](../core-data-structures/scope.md) · [ToolDefinition](../core-data-structures/tools.md) · [ToolExecutionInput](../core-data-structures/tools.md) · [ToolExecutionMode](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) · [ToolGuard](../core-data-structures/tools.md) · [ToolRestriction](../core-data-structures/tools.md) · [ToolSchema](../core-data-structures/tools.md)
 
-Source: [`packages/core/tools/src/index.ts:704`](../../packages/core/tools/src/index.ts)
+Source: [`packages/core/tools/src/index.ts:706`](../../packages/core/tools/src/index.ts)
 
 ## `ctx.tui` — `TuiExtensionService` (abstract seam)
 

+ 2 - 2
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: 3c94f1093001e8c65365cc5baa50c1393d53b3ee
-tools.zh.md: 7a6aad81c4cfe83be8625411e4313d0c36018821
+tools.md: 62acd00a3afe50c90b2cab0bb0f170ab82852a4f
+tools.zh.md: 1ec638d6062d6496aebc019e531126343d6ead28

+ 2 - 2
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), `{ 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: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). 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), `{ 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; `lang` is a language hint from the extension, and `content` is the envelope-stripped text a UI without read support falls back to), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; `kind: 'search'` carries the structured `sources`/`answer?`/`truncated`, `kind: 'fetch'` carries `url`/`statusCode`/`truncated`, and a UI without the `web` capability falls back to the raw result content — the body is not duplicated into the view). 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).

+ 2 - 2
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)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 UI 回退到原始结果内容——正文不会重复进视图)。已完成视图会替换待执行视图,因此变更工具即使与调用时的片段重复也要返回 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, offset, lines, totalLines, lang?, content? }`(已完成的文件读取→带行号、可选语法高亮的代码视图;`offset` 是窗口请求的 1-based 起始行,即使 `lines` 为空也保留;`lang` 是从扩展名推得的语言提示,`content` 是无读取能力的 UI 回退时使用的去信封文本)、或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind: 'search'` 携带结构化的 `sources`/`answer?`/`truncated`,`kind: 'fetch'` 携带 `url`/`statusCode`/`truncated`,不具备 `web` 能力的 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)。

+ 6 - 6
docs/event-producer-consumer.md

@@ -48,12 +48,12 @@ This matrix shows which packages dispatch each harness-owned event and which pac
 | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) |
 | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
 | `telemetry/record` | `waterfall` | [`packages/telemetry/session-telemetry/src/index.ts:41`](../packages/telemetry/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/telemetry/session-telemetry) (`waterfall`) | - |
-| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:160`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
-| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:142`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
-| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:117`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
-| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:129`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
-| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:106`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
-| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:150`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
+| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:162`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - |
+| `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:144`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
+| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:119`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) |
+| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:131`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) |
+| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:108`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) |
+| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:152`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) |
 | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
 | `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:91`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl


+ 1 - 1
examples/acp-agent/tests/snapshots/fs-edit/session.jsonl

@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":68,"time":1783352086057,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":69,"time":1783352086059,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."},{"type":"tool-call","id":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"36ebf262-429c-4398-abbc-a197e2522f1d"},"usage":{"inputTokens":2900,"outputTokens":91,"cacheReadTokens":0,"reasoningTokens":46}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68],"surfaceOp":"append"}
 {"type":"tool/call","seq":70,"time":1783352086059,"data":{"turn":1,"step":1,"callId":"call_00_S6krdIDHoSCGWv7WnXX97617","name":"read","arguments":"{\"file_path\": \"config.txt\"}"}}
-{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"<path>{{cwd}}/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"}},"sourceEventSeqs":[70],"surfaceOp":"append"}
+{"type":"tool/result","seq":71,"time":1783352086065,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_S6krdIDHoSCGWv7WnXX97617"},"content":[{"type":"tool-result","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","content":[{"type":"text","text":"<path>{{cwd}}/config.txt</path>\n<type>file</type>\n<content>\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n</content>"}],"isError":false}],"role":"user","id":"1c3ce978-55ee-4337-a586-084a77ed44e7"},"meta":{"path":"{{cwd}}/config.txt","offset":1,"lines":[{"number":1,"text":"mode=DEBUG"},{"number":2,"text":"level=info"}],"totalLines":2}},"sourceEventSeqs":[70],"surfaceOp":"append"}
 {"type":"step/end","seq":72,"time":1783352086065,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":73,"time":1783352086066,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":74,"time":1783352086901,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 1 - 1
examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl

@@ -27,7 +27,7 @@
 {"type":"assistant/chunk","seq":143,"time":1783611705572,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":144,"time":1783611705573,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."},{"type":"tool-call","id":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"de588e3c-b10c-4eee-93a5-26e9a665dcbc"},"usage":{"inputTokens":235,"outputTokens":89,"cacheReadTokens":3072,"reasoningTokens":44}},"sourceEventSeqs":[82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143],"surfaceOp":"append"}
 {"type":"tool/call","seq":145,"time":1783611705573,"data":{"turn":1,"step":2,"callId":"call_00_tfr0gdIclW92oIEgi7NF0891","name":"read","arguments":"{\"file_path\": \"settings.txt\"}"}}
-{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"<path>{{cwd}}/settings.txt</path>\n<type>file</type>\n<content>\n1: color: blue\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"}},"sourceEventSeqs":[145],"surfaceOp":"append"}
+{"type":"tool/result","seq":146,"time":1783611705579,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_00_tfr0gdIclW92oIEgi7NF0891"},"content":[{"type":"tool-result","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","content":[{"type":"text","text":"<path>{{cwd}}/settings.txt</path>\n<type>file</type>\n<content>\n1: color: blue\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"fade9382-14c7-47e3-8da4-286008d7e9b8"},"meta":{"path":"{{cwd}}/settings.txt","offset":1,"lines":[{"number":1,"text":"color: blue"}],"totalLines":1}},"sourceEventSeqs":[145],"surfaceOp":"append"}
 {"type":"step/end","seq":147,"time":1783611705579,"data":{"turn":1,"step":2}}
 {"type":"step/start","seq":148,"time":1783611705579,"data":{"turn":1,"step":3}}
 {"type":"assistant/chunk","seq":149,"time":1783611706200,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 1 - 1
examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl

@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":90,"time":1783352101346,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":91,"time":1783352101348,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."},{"type":"tool-call","id":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5620412c-8fae-4d17-aac4-0801f3b02461"},"usage":{"inputTokens":2894,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":56}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90],"surfaceOp":"append"}
 {"type":"tool/call","seq":92,"time":1783352101348,"data":{"turn":1,"step":1,"callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","name":"read","arguments":"{\"file_path\": \"big.txt\", \"offset\": 5, \"limit\": 4}"}}
-{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"<path>{{cwd}}/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"}},"sourceEventSeqs":[92],"surfaceOp":"append"}
+{"type":"tool/result","seq":93,"time":1783352101353,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_GIZwZS9a7vhWTFCIc7Z35497"},"content":[{"type":"tool-result","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","content":[{"type":"text","text":"<path>{{cwd}}/big.txt</path>\n<type>file</type>\n<content>\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n</content>"}],"isError":false}],"role":"user","id":"02513672-93cb-4f70-9ee7-ad19542a5f6b"},"meta":{"path":"{{cwd}}/big.txt","offset":5,"lines":[{"number":5,"text":"line five"},{"number":6,"text":"line six"},{"number":7,"text":"line seven"},{"number":8,"text":"line eight"}],"totalLines":10}},"sourceEventSeqs":[92],"surfaceOp":"append"}
 {"type":"step/end","seq":94,"time":1783352101353,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":95,"time":1783352101354,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":96,"time":1783352102021,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 1 - 1
examples/acp-agent/tests/snapshots/fs-read/session.jsonl

@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":52,"time":1783352073705,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":53,"time":1783352073708,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."},{"type":"tool-call","id":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5452254c-4843-458c-9732-12fe8b7c1468"},"usage":{"inputTokens":2882,"outputTokens":75,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52],"surfaceOp":"append"}
 {"type":"tool/call","seq":54,"time":1783352073709,"data":{"turn":1,"step":1,"callId":"call_00_hHPZCcivsIkXAGS9jTGy8417","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}
-{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"}},"sourceEventSeqs":[54],"surfaceOp":"append"}
+{"type":"tool/result","seq":55,"time":1783352073717,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_hHPZCcivsIkXAGS9jTGy8417"},"content":[{"type":"tool-result","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"6dd015cf-8c8b-4fd3-a1b2-fa243d67d8e9"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[54],"surfaceOp":"append"}
 {"type":"step/end","seq":56,"time":1783352073718,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":57,"time":1783352073719,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":58,"time":1783352074666,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 1 - 1
examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl

@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":64,"time":1783352093614,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":65,"time":1783352093617,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""},{"type":"tool-call","id":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"00272d0c-8ed0-436a-8d10-4a7091447dfe"},"usage":{"inputTokens":2899,"outputTokens":87,"cacheReadTokens":0,"reasoningTokens":42}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64],"surfaceOp":"append"}
 {"type":"tool/call","seq":66,"time":1783352093617,"data":{"turn":1,"step":1,"callId":"call_00_n4eRJuGoxNR07svgNtk82243","name":"read","arguments":"{\"file_path\": \"data.txt\"}"}}
-{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"}},"sourceEventSeqs":[66],"surfaceOp":"append"}
+{"type":"tool/result","seq":67,"time":1783352093624,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_n4eRJuGoxNR07svgNtk82243"},"content":[{"type":"tool-result","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","content":[{"type":"text","text":"<path>{{cwd}}/data.txt</path>\n<type>file</type>\n<content>\n1: original contents\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"c14ef7fe-2bb8-4adf-ab15-5a988fbf5f55"},"meta":{"path":"{{cwd}}/data.txt","offset":1,"lines":[{"number":1,"text":"original contents"}],"totalLines":1}},"sourceEventSeqs":[66],"surfaceOp":"append"}
 {"type":"step/end","seq":68,"time":1783352093624,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":69,"time":1783352093625,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":70,"time":1783352094455,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 2 - 2
examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl

@@ -15,8 +15,8 @@
 {"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"380ff5b4-d7f1-4c36-b87d-9a42ce1b264c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[5,6,7,8,9,10,11,12],"surfaceOp":"append"}
 {"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}
 {"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}
-{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"}},"sourceEventSeqs":[14],"surfaceOp":"append"}
-{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"}},"sourceEventSeqs":[15],"surfaceOp":"append"}
+{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_a"},"content":[{"type":"tool-result","toolCallId":"call_read_a","content":[{"type":"text","text":"<path>{{cwd}}/a.txt</path>\n<type>file</type>\n<content>\n1: alpha\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"ccdf47d2-0e79-4ca6-a70f-2c8c42e2341e"},"meta":{"path":"{{cwd}}/a.txt","offset":1,"lines":[{"number":1,"text":"alpha"}],"totalLines":1}},"sourceEventSeqs":[14],"surfaceOp":"append"}
+{"type":"tool/result","seq":17,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_read_b"},"content":[{"type":"tool-result","toolCallId":"call_read_b","content":[{"type":"text","text":"<path>{{cwd}}/b.txt</path>\n<type>file</type>\n<content>\n1: beta\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"49a6bffd-3a0e-490e-bf49-e9d3e6370f83"},"meta":{"path":"{{cwd}}/b.txt","offset":1,"lines":[{"number":1,"text":"beta"}],"totalLines":1}},"sourceEventSeqs":[15],"surfaceOp":"append"}
 {"type":"step/end","seq":18,"time":0,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":19,"time":0,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}

+ 2 - 2
examples/acp-agent/tests/snapshots/workspace-context/session.jsonl

@@ -12,7 +12,7 @@
 {"type":"assistant/chunk","seq":10,"time":1784903339801,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":11,"time":1784903339801,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2093472f-8f2c-4cfd-8d71-515e3242dad2"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[6,7,8,9,10],"surfaceOp":"append"}
 {"type":"tool/call","seq":12,"time":1784903339802,"data":{"turn":1,"step":1,"callId":"call_workspace_read","name":"read","arguments":"{\"file_path\":\"nested/task.txt\"}"}}
-{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"d9a6b9c3-715b-42b0-9d20-04319a83eea8"}},"sourceEventSeqs":[12],"surfaceOp":"append"}
+{"type":"tool/result","seq":13,"time":1784903339813,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_workspace_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_read","content":[{"type":"text","text":"<path>{{cwd}}/nested/task.txt</path>\n<type>file</type>\n<content>\n1: snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"d9a6b9c3-715b-42b0-9d20-04319a83eea8"},"meta":{"path":"{{cwd}}/nested/task.txt","offset":1,"lines":[{"number":1,"text":"snapshot task"}],"totalLines":1}},"sourceEventSeqs":[12],"surfaceOp":"append"}
 {"type":"user/message","seq":14,"time":1784903339813,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nThese instructions apply to work under `nested`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nNested snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"nested\u0000AGENTS.md","path":"nested/AGENTS.md","digest":"c446df9a85c7e73a3055f394a4822a19ac9ead5a"}]},"role":"user","id":"68629935-05e9-4af7-bddb-aabfbbd70208"},"surfaceOp":"append"}
 {"type":"step/end","seq":15,"time":1784903339813,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":16,"time":1784903339820,"data":{"turn":1,"step":2}}
@@ -23,7 +23,7 @@
 {"type":"assistant/chunk","seq":21,"time":1784903339821,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":22,"time":1784903339821,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"04824453-a12a-43d7-8580-4b75d0e4a694"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[17,18,19,20,21],"surfaceOp":"append"}
 {"type":"tool/call","seq":23,"time":1785394278014,"data":{"turn":1,"step":2,"callId":"call_workspace_delimiter_read","name":"read","arguments":"{\"file_path\":\"scope</system-reminder>/task.txt\"}"}}
-{"type":"tool/result","seq":24,"time":1785394278026,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"<path>{{cwd}}/scope</system-reminder>/task.txt</path>\n<type>file</type>\n<content>\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"06c93dad-5ee2-4c56-ba7b-c1228bee7090"}},"sourceEventSeqs":[23],"surfaceOp":"append"}
+{"type":"tool/result","seq":24,"time":1785394278026,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"call_workspace_delimiter_read"},"content":[{"type":"tool-result","toolCallId":"call_workspace_delimiter_read","content":[{"type":"text","text":"<path>{{cwd}}/scope</system-reminder>/task.txt</path>\n<type>file</type>\n<content>\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"06c93dad-5ee2-4c56-ba7b-c1228bee7090"},"meta":{"path":"{{cwd}}/scope</system-reminder>/task.txt","offset":1,"lines":[{"number":1,"text":"delimiter path snapshot task"}],"totalLines":1}},"sourceEventSeqs":[23],"surfaceOp":"append"}
 {"type":"user/message","seq":25,"time":1785394278026,"data":{"content":[{"type":"text","text":"<system-reminder>\nAdditional instructions from: scope<\\/system-reminder>/AGENTS.md\n\nThese instructions apply to work under `scope<\\/system-reminder>`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.\n\nDelimiter path snapshot instruction.\n\n</system-reminder>"}],"source":{"kind":"workspace-instructions","changes":[{"action":"set","scope":"scope</system-reminder>\u0000AGENTS.md","path":"scope</system-reminder>/AGENTS.md","digest":"38803cd13e2dff9105ba5fbbc703fe27e989e26e"}]},"role":"user","id":"7bcf58d7-7f2f-4242-8bd6-00577c9c3153"},"surfaceOp":"append"}
 {"type":"step/end","seq":26,"time":1785394278026,"data":{"turn":1,"step":2}}
 {"type":"step/start","seq":27,"time":1785394278034,"data":{"turn":1,"step":3}}

+ 1 - 1
examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl

@@ -14,7 +14,7 @@
 {"type":"assistant/chunk","seq":78,"time":1783352265489,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
 {"type":"assistant/message","seq":79,"time":1783352265491,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."},{"type":"tool-call","id":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3f154ea9-6cf0-4d0a-a478-503962bfe8e1"},"usage":{"inputTokens":2918,"outputTokens":101,"cacheReadTokens":0,"reasoningTokens":55}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78],"surfaceOp":"append"}
 {"type":"tool/call","seq":80,"time":1783352265491,"data":{"turn":1,"step":1,"callId":"call_00_OjRFB4zvxu6UALDjytZD0978","name":"read","arguments":"{\"file_path\": \"greeting.txt\"}"}}
-{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"}},"sourceEventSeqs":[80],"surfaceOp":"append"}
+{"type":"tool/result","seq":81,"time":1783352265504,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_OjRFB4zvxu6UALDjytZD0978"},"content":[{"type":"tool-result","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","content":[{"type":"text","text":"<path>{{cwd}}/greeting.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>"}],"isError":false}],"role":"user","id":"59ffbde4-d450-4564-a907-beeec29af0d0"},"meta":{"path":"{{cwd}}/greeting.txt","offset":1,"lines":[{"number":1,"text":"hello"}],"totalLines":1}},"sourceEventSeqs":[80],"surfaceOp":"append"}
 {"type":"step/end","seq":82,"time":1783352265504,"data":{"turn":1,"step":1}}
 {"type":"step/start","seq":83,"time":1783352265505,"data":{"turn":1,"step":2}}
 {"type":"assistant/chunk","seq":84,"time":1783352266385,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}

+ 9 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -2211,6 +2211,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
     name: 'PtyWaitReason',
     declaration: 'export type PtyWaitReason = \'stdin_read\' | \'inferred_idle\' | \'timeout\' | \'session_exit\';',
   },
+  {
+    name: 'ReadFileLine',
+    declaration: 'export interface ReadFileLine {\n    number: number;\n    text: string;\n}',
+  },
+  {
+    name: 'ReadResultView',
+    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',
     declaration: 'export interface ReasoningBlock {\n    type: \'reasoning\';\n    text: string;\n}',
@@ -2849,7 +2857,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   {
     name: 'ToolResultView',
-    declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView;',
+    declaration: 'export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView;',
   },
   {
     name: 'ToolRunContext',

+ 2 - 2
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: e7f395f8c1d6417db856e590f5267cf6887e4d12
-README.zh.md: acb4c047bf86e36c828882ff751d4be1f627f99e
+README.md: dcce455f9551318f3871e3df84c29789078fef7c
+README.zh.md: 63eaa2e0b66797c74a1d4845c29ca970b63f5f99

+ 1 - 1
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? }`, `{ card: 'diff', title?, diffs }`, or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
+- Result views are `{ card: 'generic', title?, content? }`, `{ card: 'terminal', title?, output?, exitCode?, signal? }`, `{ card: 'diff', title?, diffs }`, `{ 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), or `{ card: 'web', kind: 'search' | 'fetch', title?, … }` (a completed web retrieval; the `kind` arms carry the structured search sources or the fetch summary, and a UI without the `web` capability falls back to the raw result content).
 
 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.
 

+ 1 - 1
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: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 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 回退时使用的去信封文本)或 `{ card: 'web', kind: 'search' | 'fetch', title?, … }`(已完成的 web 检索;`kind` 各分支携带结构化的搜索来源或抓取摘要,不具备 `web` 能力的 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) 规定卡片词汇。
 

+ 2 - 0
packages/core/tools/src/index.ts

@@ -74,6 +74,7 @@ export type {
   ToolCallKind,
   FileLocation,
   FileDiff,
+  ReadFileLine,
   ToolCallView,
   GenericCallView,
   TerminalCallView,
@@ -82,6 +83,7 @@ export type {
   GenericResultView,
   TerminalResultView,
   DiffResultView,
+  ReadResultView,
   WebResultView,
   WebSearchResultView,
   WebFetchResultView,

+ 54 - 1
packages/core/tools/src/presentation.ts

@@ -117,6 +117,18 @@ export interface DiffCallView {
   locations?: FileLocation[]
 }
 
+/**
+ * One numbered line of a file, the unit a {@link ReadResultView} carries so a
+ * capable UI can render a syntax-highlighted, line-numbered code view. `number`
+ * is the 1-based line number in the file (a window past `offset` keeps the file's
+ * own numbering, not a 1-based re-count); `text` is the line without its trailing
+ * newline, already truncated to the read tool's per-line cap.
+ */
+export interface ReadFileLine {
+  number: number
+  text: string
+}
+
 /**
  * How a tool wants the COMPLETED call shown — the *result* state, after `execute`
  * returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
@@ -125,7 +137,7 @@ export interface DiffCallView {
  * `ToolDefinition.presentResult`; omitting the method keeps the pending
  * title and renders the raw result content.
  */
-export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | WebResultView
+export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | ReadResultView | WebResultView
 
 /**
  * The default completed card: an optional replacement title and reformatted
@@ -177,6 +189,47 @@ export interface DiffResultView {
   diffs: FileDiff[]
 }
 
+/**
+ * A completed file read rendered as a line-numbered, optionally syntax-highlighted
+ * code view by a capable UI. Set by a tool whose call reads file text (e.g.
+ * `read`); the pending state stays a {@link GenericCallView} (`kind: 'read'`)
+ * because a call carries no content until `execute` returns. The structured
+ * `lines`/`path`/`lang`/`totalLines` fields cannot be reconstructed from the
+ * model-facing result text alone, so the read tool projects them through its
+ * `output.presentationMeta` (persisted with the session log) and `presentResult`
+ * narrows that metadata back into this view on live and replay paths alike. A UI
+ * without the read capability falls back to `content` (the model-facing text with
+ * its envelope stripped), so this view degrades to the generic text card.
+ */
+export interface ReadResultView {
+  card: 'read'
+  /** Replacement title for the completed call. Omit to keep the pending-state title. */
+  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. */
+  totalLines: number
+  /**
+   * A syntax-highlighting language hint derived from the file extension (e.g.
+   * `ts`, `py`), or omitted when the extension maps to no known language so a UI
+   * renders the lines as plain text.
+   */
+  lang?: string
+  /**
+   * The model-facing result content with its envelope stripped, for a UI without
+   * the read capability. Omit to let such a UI render the raw result content.
+   */
+  content?: ContentBlock[]
+}
+
 /**
  * One citeable source in a completed {@link WebSearchResultView}, the faithful
  * projection of one web-search source. The presentation projection of `dsh-web`'s

+ 2 - 2
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: c00b59fed06249e6d9479c4a809cdf7d78f93239
+README.zh.md: f90fbb36391c1388ab0f6836daa2a9061d046be6

+ 1 - 1
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, 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
 

+ 1 - 1
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, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化
 
 ## 工具就是执行器;策略是事件门禁
 

+ 102 - 0
packages/fs/tool-fs/src/read-render.ts

@@ -168,3 +168,105 @@ export function formatReadOutput(displayPath: string, outcome: FileReadOutcome):
 ${body}
 </content>`
 }
+
+/**
+ * Lowercased file-extension to syntax-highlighting language hint. Keys are the
+ * extension without its dot; a UI treats an absent key as plain text. The map is
+ * intentionally small — common source, config, and markup extensions a
+ * line-numbered code view benefits from highlighting — not an exhaustive registry.
+ */
+const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
+  ts: 'ts', tsx: 'tsx', mts: 'ts', cts: 'ts',
+  js: 'js', jsx: 'jsx', mjs: 'js', cjs: 'js',
+  json: 'json', jsonc: 'json',
+  py: 'py', rb: 'rb', go: 'go', rs: 'rs', java: 'java',
+  c: 'c', h: 'c', cc: 'cpp', cpp: 'cpp', hpp: 'cpp', cxx: 'cpp',
+  cs: 'cs', kt: 'kotlin', swift: 'swift', php: 'php',
+  sh: 'sh', bash: 'sh', zsh: 'sh',
+  yaml: 'yaml', yml: 'yaml', toml: 'toml', ini: 'ini',
+  md: 'md', markdown: 'md', mdx: 'mdx',
+  html: 'html', htm: 'html', css: 'css', scss: 'scss', less: 'less',
+  sql: 'sql', xml: 'xml', lua: 'lua',
+}
+
+/**
+ * Derive a syntax-highlighting language hint from a read path's file extension.
+ * Pure and case-insensitive on the extension; a dotfile with no extension
+ * (`.gitignore`) and an unknown extension both yield `undefined`.
+ * @param path - the model-facing path the read reported.
+ * @returns the language hint for {@link LANG_BY_EXTENSION}, or `undefined` when the extension maps to none.
+ */
+export function langFromPath(path: string): string | undefined {
+  const base = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
+  const dot = base.lastIndexOf('.')
+  // A leading dot is a dotfile (no extension), not an empty extension.
+  if (dot <= 0) return undefined
+  const ext = base.slice(dot + 1).toLowerCase()
+  // Own-property check only: a filename whose extension is an Object.prototype
+  // key (`foo.constructor`, `foo.__proto__`) must map to no language, not to the
+  // inherited member — otherwise a function would reach `lang` and fail the
+  // tool-output JSON validation.
+  return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
+}
+
+/**
+ * The `read` tool's private `tool/result` `meta` payload: the structured
+ * line-numbered window a capable UI renders as a code view. Attached opaquely (as
+ * `unknown`) on the tool result and persisted with the session log — it must be
+ * JSON-serializable (the session validates this at `append`), so `presentResult`
+ * reproduces the read card on replay when the raw structured output is no longer
+ * on the wire. The producing tool owns and narrows this opaque shape.
+ */
+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. */
+  totalLines: number
+  /** Syntax-highlighting language hint from the extension, or omitted for plain text. */
+  lang?: string
+}
+
+/**
+ * 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' && 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. Beyond shape, the
+ * semantic contract of a read window is enforced against replayed JSON that is
+ * 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, 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 = offset - 1
+  for (const { number } of lines) {
+    if (number <= previous || number > totalLines) return undefined
+    previous = number
+  }
+  return { path, offset, lines, totalLines, ...lang === undefined ? {} : { lang } }
+}

+ 34 - 4
packages/fs/tool-fs/src/read.ts

@@ -6,11 +6,11 @@
 
 import type { Context } from 'cordis'
 import { defineTool } from '@deepseek-ai/dsh-tools'
-import type { GenericCallView, GenericResultView, ToolResult } from '@deepseek-ai/dsh-tools'
+import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools'
 import { FsError } from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-fs'
 import type {} from '@deepseek-ai/dsh-system-prompt'
-import { buildWindow, formatReadOutput } from './read-render.ts'
+import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts'
 import { sessionResolveOptions } from './session-cwd.ts'
 
 /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
@@ -118,6 +118,19 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
           }),
         }]
       },
+      // Project the structured window into persisted `meta` so a UI's read card
+      // survives replay: the raw canonical output object is not on the wire, only
+      // the model-facing text, from which the line/lang data cannot be recovered.
+      presentationMeta: (_args, value) => {
+        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 },
+        }
+      },
     },
     // Observation races fail closed because guarded mutations re-check the version in-lock.
     isConcurrencySafe: () => true,
@@ -154,15 +167,32 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
       ctx.emit('fs/observed', target, info.version, exec)
       return outcome
     },
-    presentResult(_args, result: ToolResult): GenericResultView | undefined {
+    // Result-time display: a `read` card carrying the structured line window a
+    // capable UI renders as a line-numbered, syntax-highlighted view. The
+    // structured data is narrowed from the persisted `meta` (replay-safe); the
+    // envelope-stripped model-facing text rides along as `content` so a UI without
+    // the read capability still shows the file text. A malformed or absent meta,
+    // or a result whose text is not the read envelope, declines to `undefined`
+    // (the generic fallback), never throwing on replay of obsolete logged output.
+    presentResult(_args, result: ToolResult): ReadResultView | undefined {
       if (result.isError) return undefined
+      const meta = readMetaFromMeta(result.meta)
+      if (meta === undefined) return undefined
       const only = result.content.length === 1 ? result.content[0] : undefined
       const text = only?.type === 'text' ? only.text : undefined
       if (text === undefined) return undefined
       // Group 1 always captures (possibly empty) when the envelope matches.
       const body = /^<path>[^\n]*<\/path>\n<type>file<\/type>\n<content>\n([\s\S]*)\n<\/content>$/u.exec(text)?.[1]
       if (body === undefined) return undefined
-      return { card: 'generic', content: [{ type: 'text', text: body }] }
+      return {
+        card: 'read',
+        path: meta.path,
+        offset: meta.offset,
+        lines: meta.lines,
+        totalLines: meta.totalLines,
+        ...meta.lang === undefined ? {} : { lang: meta.lang },
+        content: [{ type: 'text', text: body }],
+      }
     },
     // Pure display: a generic card titled by the file with the read window appended (`Read
     // foo.txt (5 - 8)`), `read` kind (icon), and a follow-along location whose line is the

+ 101 - 1
packages/fs/tool-fs/tests/read-render.spec.ts

@@ -6,7 +6,7 @@
  */
 
 import { describe, expect, it } from 'vitest'
-import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
+import { buildWindow, langFromPath, readMetaFromMeta, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
 import type { ReadWindow } from '../src/read-render.ts'
 
 const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
@@ -116,3 +116,103 @@ describe('buildWindow', () => {
     })
   })
 })
+
+describe('langFromPath', () => {
+  it('maps a known extension to its language hint, case-insensitively', () => {
+    expect(langFromPath('src/a.ts')).toBe('ts')
+    expect(langFromPath('src/a.TSX')).toBe('tsx')
+    expect(langFromPath('/abs/module.mjs')).toBe('js')
+    expect(langFromPath('conf.yml')).toBe('yaml')
+    expect(langFromPath('README.md')).toBe('md')
+  })
+
+  it('reads the extension after the last path segment and last dot', () => {
+    expect(langFromPath('a.py.bak')).toBeUndefined()
+    expect(langFromPath('archive.tar.gz')).toBeUndefined()
+    expect(langFromPath('/dir.py/plain')).toBeUndefined()
+    expect(langFromPath('C:\\src\\main.rs')).toBe('rs')
+  })
+
+  it('returns undefined for a dotfile, an extensionless name, and an unknown extension', () => {
+    expect(langFromPath('.gitignore')).toBeUndefined()
+    expect(langFromPath('/etc/hosts')).toBeUndefined()
+    expect(langFromPath('data.unknownext')).toBeUndefined()
+    expect(langFromPath('trailingdot.')).toBeUndefined()
+  })
+
+  it('returns undefined for a filename whose extension is an Object.prototype key', () => {
+    // Own-property lookup only: these must not resolve to the inherited member
+    // (a function/object), which would fail the tool-output JSON validation.
+    expect(langFromPath('foo.constructor')).toBeUndefined()
+    expect(langFromPath('foo.__proto__')).toBeUndefined()
+    expect(langFromPath('foo.toString')).toBeUndefined()
+    expect(langFromPath('foo.hasOwnProperty')).toBeUndefined()
+  })
+})
+
+describe('readMetaFromMeta', () => {
+  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', 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()
+    expect(readMetaFromMeta('nope')).toBeUndefined()
+    expect(readMetaFromMeta([good])).toBeUndefined()
+  })
+
+  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()
+    expect(readMetaFromMeta({ ...good, lines: [{ number: 1 }] })).toBeUndefined()
+    expect(readMetaFromMeta({ ...good, lines: [null] })).toBeUndefined()
+    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()
+    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', 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.
+    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()
+  })
+})

+ 77 - 5
packages/fs/tool-fs/tests/tools.spec.ts

@@ -320,6 +320,39 @@ describe('read tool', () => {
     expect(text(result)).toContain('Output capped.')
   })
 
+  it('attaches the structured window as presentation meta, and presentResult narrows it into a read card', async () => {
+    const { ctx, fs } = await setup()
+    fs.files.set('key:a.ts', 'const x = 1\nconst y = 2')
+    const result = await call(ctx, 'read', { file_path: 'a.ts' })
+    expect(result.isError).toBe(false)
+    if (result.isError) throw new Error('expected read success')
+    // 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',
+    })
+    const view = ctx.tools.get('read')?.presentResult?.({ file_path: 'a.ts' }, result)
+    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',
+      content: [{ type: 'text', text: '1: const x = 1\n2: const y = 2\n\n(End of file - total 2 lines)' }],
+    })
+  })
+
+  it('omits the lang hint in meta for an extension that maps to no language', async () => {
+    const { ctx, fs } = await setup()
+    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', offset: 1, lines: [{ number: 1, text: 'plain' }], totalLines: 1 })
+  })
 })
 
 describe('formatReadOutput footer variants', () => {
@@ -450,33 +483,72 @@ describe('tool-owned presentation (pure presentCall)', () => {
     })
   })
 
-  it('read: completed presentation removes the model-facing XML envelope', async () => {
-    expect(await presentResult('read', { file_path: 'a.txt' }, {
-      content: [{ type: 'text', text: '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\n1: hello\n\n(End of file - total 1 lines)\n</content>' }],
+  it('read: completed presentation is a read card carrying the structured window with the envelope stripped', async () => {
+    // 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', 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,
+      meta,
     })).toEqual({
-      card: 'generic',
+      card: 'read',
+      path: '/tmp/a.ts',
+      offset: 1,
+      lines: [{ number: 1, text: 'hello' }],
+      totalLines: 1,
+      lang: 'ts',
       content: [{ type: 'text', text: '1: hello\n\n(End of file - total 1 lines)' }],
     })
-    expect(await presentResult('read', { file_path: 'a.txt' }, {
+    // A window whose extension maps to no language omits `lang` from the card.
+    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', 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' }],
+    })
+    // Malformed envelope text with valid meta still declines (the fallback text is unavailable).
+    expect(await presentResult('read', { file_path: 'a.ts' }, {
       content: [{ type: 'text', text: 'malformed replay' }],
       isError: false,
+      meta,
+    })).toBeUndefined()
+    // Valid envelope but absent/malformed meta declines to the generic fallback.
+    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</content>' }],
+      isError: false,
+    })).toBeUndefined()
+    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</content>' }],
+      isError: false,
+      meta: { path: '/tmp/a.ts', lines: 'nope', totalLines: 1 },
     })).toBeUndefined()
   })
 
   it('read: completed presentation declines errors and non-single-text content', async () => {
     const envelope = '<path>/tmp/a.txt</path>\n<type>file</type>\n<content>\nbody\n</content>'
+    const meta = { path: '/tmp/a.txt', offset: 1, lines: [{ number: 1, text: 'body' }], totalLines: 1 }
     expect(await presentResult('read', { file_path: 'a.txt' }, {
       content: [{ type: 'text', text: envelope }],
       isError: true,
+      meta,
     })).toBeUndefined()
     expect(await presentResult('read', { file_path: 'a.txt' }, {
       content: [{ type: 'text', text: envelope }, { type: 'text', text: 'second' }],
       isError: false,
+      meta,
     })).toBeUndefined()
     expect(await presentResult('read', { file_path: 'a.txt' }, {
       content: [{ type: 'reasoning', text: envelope }],
       isError: false,
+      meta,
     })).toBeUndefined()
   })
 

+ 15 - 11
packages/ui/tui/src/components/transcript.ts

@@ -402,12 +402,15 @@ export class ToolCardComponent implements Component {
     const glyph = this.result === undefined ? '○' : '●'
     const rawBody = this.renderBody()
     const view = this.resultView ?? this.callView
-    // A generic card's own content, or a web card's fallback to the raw result
-    // content (the `web` view carries no `content` copy), both render as one dim
-    // Markdown block below, so links/lists/headings keep the unified dim styling
-    // rather than reading as bare text. Terminal and diff cards own their body
-    // styling, so they are excluded (mirrors renderBody's post-terminal/diff fallback).
-    const markdownContent = view.card === 'generic'
+    // A generic card's own content, or a read card's `content` fallback (the
+    // envelope-stripped file text — the TUI has no dedicated read rendering, so a
+    // read renders exactly as before the read card existed), or a web card's
+    // fallback to the raw result content (the `web` view carries no `content`
+    // copy), all render as one dim Markdown block below, so links/lists/headings
+    // keep the unified dim styling rather than reading as bare text. Terminal and
+    // diff cards own their body styling, so they are excluded (mirrors
+    // renderBody's post-terminal/diff fallback).
+    const markdownContent = view.card === 'generic' || view.card === 'read'
       ? view.content ?? this.result?.content
       : view.card === 'web'
         // A web resultView is only assigned alongside this.result (the result
@@ -532,11 +535,12 @@ export class ToolCardComponent implements Component {
       // rather than under the dim result-output color.
       return { prelude: [...hunks, footer], lines: [] }
     }
-    // The web card carries no `content` copy, so a `web` result view falls back
-    // to the raw result content here (`view.card === 'generic'` narrows the
-    // generic union arm; a `web` card takes the same fallback, mirroring the
-    // `markdownContent` selection in render()).
-    const content = (view.card === 'generic' ? view.content : undefined) ?? this.result?.content
+    // A generic or read card carries its own envelope-stripped `content`; a `web`
+    // card carries no `content` copy and falls back to the raw result content
+    // here. (Mirrors the `markdownContent` selection in render(); a read card has
+    // no dedicated TUI rendering, so its `content` takes the same body path,
+    // keeping read output as it was before the read card existed.)
+    const content = (view.card === 'generic' || view.card === 'read' ? view.content : undefined) ?? this.result?.content
     const prelude: string[] = []
     const lines: string[] = []
     // The presenter title headlines the body now that the header is a fixed

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است