DSH / Atlas
2026-07-02implementedarchitecturearchived 2026-07-27

Result-time applied-hunk diffs for file mutations

结果时刻的 applied-hunk diff 用于文件变更

The [tagged render-intent union](2026-07-02-tool-render-intent-union.md) gives `dsh-tool-fs` write/edit a `card:'diff'` at call time, derived purely from the tool's args: write ⇒ `{oldText:null, newText:content}` (the whole new file), edit ⇒ `{oldText:old_string, newText:new_string}` (the bare replaced snippet). A UI can render that as an inline diff, but it is a **context-free** diff — the bare `old_string`→`new_str

English

Problem

The tagged render-intent union gives dsh-tool-fs write/edit a card:'diff' at call time, derived purely from the tool's args: write ⇒ {oldText:null, newText:content} (the whole new file), edit ⇒ {oldText:old_string, newText:new_string} (the bare replaced snippet). A UI can render that as an inline diff, but it is a context-free diff — the bare old_stringnew_string with no surrounding lines, and a replace_all that touched five scattered sites still renders as one snippet pair.

Driving claude-agent-acp's own ACP bridge shows what a full editor diff looks like: after the mutation applies, it emits a SECOND tool_call_update whose diff is the applied hunk with ±3 context lines (and one hunk per changed site for replace_all), reconstructed from the tool's structuredPatch. That result-time hunk is what makes Zed show the change in place in the file rather than as a floating snippet. Our tools stopped at the call-time snippet; the completed result carried only the plain "updated successfully" text, no diff.

The obstacle is a seam boundary: presentResult(args, result) is a pure function of args + the model-facing result ({content, isError}) — it runs on live streaming AND on session-log replay, so it must be replay-deterministic and cannot do I/O. It never sees the file's before/after content, and FsEditOutcome/FsWriteOutcome carried only a replacement count + version, not the text. So there was no way to compute — or even carry — an applied hunk to the presenter.

Decision

Add a persisted, tool-private presentation channel so a tool's execute can attach a result-time render payload that survives replay, and use it to carry the applied-hunk diff.

1. A replayable presentation projection on canonical tool output (core)

The original implementation let execute return { content, meta }. The canonical tool-output contract supersedes that authoring shape: every tool now returns one schema-declared JSON value, output.render(args, value) derives model-facing blocks, and optional output.presentationMeta(args, value) derives replayable UI data.

presentationMeta is tool-owned JsonValue that the core persists without interpreting its fields. Session.append validates it with the rest of the event, and replay passes the stored payload back to presentResult; presentations therefore reproduce without I/O or recomputation. The canonical value itself remains execution-local and is not added to the session format.

This remains the general shape ("a tool projects durable result presentation"), not an fs-specific one—any tool can use it.

2. The tool computes the hunk; the backend returns before/after (fs)

Per the capability-seam split, the storage backend returns only storage facts and the model-facing tool owns presentation:

  • dsh-fs widens FsEditOutcome with { before: string; after: string } and FsWriteOutcome with { before: string | null; after: string } (before: null ⇒ a create, or an existing-but-undiffable binary/non-UTF-8 file). The local backend already holds both texts at write time; it returns them as raw LF-normalized text, with no diff/UI concept entering the seam.
  • dsh-tool-fs returns canonical before/after mutation facts and projects contextual hunks as meta: { diffs: FileDiff[] }. Successful mutations complete with a diff view: creates or unchanged overwrites fall back to an args-derived whole-file diff, while edits use applied hunks. Failed mutations carry no diff metadata and render their error normally.

3. UI transports render a diff result view

ToolResultView includes DiffResultView { card:'diff'; title?; diffs: FileDiff[] }. TUI and JSON-RPC/Web consumers switch on the same tagged view and replace the pending call's context-free snippet with the applied result hunk. The automation-only ACP bridge does not carry tool presentation.

Alternatives considered

Hand-rolling or vendoring the diff algorithm. Contextual hunks have established edge cases, so dsh-tool-fs uses the typed diff package and normalizes structuredPatch output in one module. The repository's vendoring policy applies to its framework source, not every leaf utility.

Consequences

tool/result events carry a tool-private meta payload—part of the on-disk vocabulary, runtime-gated to JSON by Session.append—and any tool can project durable result presentation without another core change. The diff card reproduces on session reload and snapshot replay for free: it is read back from the log, never recomputed. The costs: an overwrite holds both the prior and new text in memory to compute a UI-only hunk (TODO(overwrite-diff-bound)), and dsh-tool-fs carries a small, well-known runtime dependency.

Non-goals

  • Live incremental diff streaming. The hunk is computed once, after the mutation completes; there is no per-keystroke diff.
  • Diffing a binary/non-UTF-8 overwrite. before is null for such a file (it has no text diff basis); the write still succeeds and the result renders a whole-file diff (oldText: null) rather than a contextual hunk.
  • Rename/move diffs. Only content diffs of a single resolved path.
  • Bounding the overwrite diff basis. An overwrite reads the whole prior file into memory to compute the contextual hunk (on top of the new content already held), so a very large text overwrite allocates both texts for a UI-only diff. A future refinement can bound the pre-read and fall back to a whole-file / no contextual diff above a size threshold; tracked as TODO(overwrite-diff-bound) at the read site.

Related

  • Completes the one remaining representation difference named as a non-goal in Tagged render-intent union — that Agent Note's Non-goals section is updated to record that applied-hunk diffs shipped here.
  • Builds on the filesystem capability seam (the before/after are storage facts the backend returns) and event-sourced sessions (the meta payload persists on the tool/result event, so replay reproduces the card).
  • The meta channel is deliberately generic: a future tool (a structured search, a data-table result) can attach its own durable result presentation without another core change.

中文

问题

带标签的 render-intent 联合类型dsh-tool-fs 的 write/edit 在调用时刻提供 card:'diff',纯粹从工具参数推导:write ⇒ {oldText:null, newText:content}(整个新文件),edit ⇒ {oldText:old_string, newText:new_string}(裸替换片段)。UI 可以将其渲染为行内 diff,但这是一个无上下文的 diff:裸的 old_stringnew_string 没有周围行,而一次触及五个分散位置的 replace_all 仍然渲染为一对片段。

在对接 claude-agent-acp 自身的 ACP(Agent Client Protocol) bridge 时可以看到完整编辑器 diff 的样子:变更应用后,它发出第二个 tool_call_update,其 diff 是带 ±3 行上下文的 applied hunkreplace_all 的每个变更位置各一个 hunk),由工具的 structuredPatch 重建。这个结果时刻的 hunk 正是让 Zed 在文件中原位显示变更(而非浮动片段)的关键。我们的工具止步于调用时刻的片段;完成后的结果只携带纯文本「updated successfully」,没有 diff。

障碍在于一个 seam 边界:presentResult(args, result)args + 面向模型的 result{content, isError})的纯函数——它在实时流式输出和会话日志回放中都会运行,因此必须具备回放确定性且不能做 I/O。它看不到文件的前后内容,而 FsEditOutcome/FsWriteOutcome 只携带替换计数和版本号,没有文本。因此无法计算——甚至无法携带——applied hunk 给 presenter。

决策

添加一个持久化的、工具私有的展示通道,使工具的 execute 能附加一个结果时刻的渲染载荷并在回放中存活,并用它来携带 applied-hunk diff。

1. 规范工具输出上的可回放展示投影(core)

原始实现允许 execute 返回 { content, meta }规范工具输出契约取代了这种编写形态:每个工具如今返回一个由 schema 声明的 JSON 值,output.render(args, value) 从中派生面向模型的内容块,可选的 output.presentationMeta(args, value) 则派生可回放的 UI 数据。

presentationMeta 是工具自有的 JsonValue,core 会持久化它,但不解释其中的字段。Session.append 将它与事件的其余部分一并校验,回放再把存储的载荷传回 presentResult;因此展示无需 I/O 或重新计算即可复现。规范值本身只存在于执行期间,不会加入会话格式。

这仍是通用形态(「工具投影持久化的结果展示」),而非 fs 特有;任何工具都可以使用。

2. 工具计算 hunk;后端返回 before/after(fs)

按照 capability-seam 拆分,存储后端只返回存储事实,面向模型的工具拥有展示

  • dsh-fsFsEditOutcome 扩展为包含 { before: string; after: string },将 FsWriteOutcome 扩展为包含 { before: string | null; after: string }before: null 表示创建,或已存在但不可 diff 的二进制/非 UTF-8 文件)。本地后端在写入时已持有两份文本;它以原始 LF 规范化文本返回,不让任何 diff/UI 概念进入 seam
  • dsh-tool-fs 返回规范的变更前/后事实,并将上下文 hunk 投影为 meta: { diffs: FileDiff[] }。成功的变更以 diff 视图完成:创建或无变化的覆写回退到由参数推导的整文件 diff,而编辑使用 applied hunk。失败的变更不携带 diff 元数据,正常渲染其错误信息。

3. UI 传输层渲染 diff 结果视图

ToolResultView 包含 DiffResultView { card:'diff'; title?; diffs: FileDiff[] }。TUI 与 JSON-RPC/Web 消费方在同一个带标签的视图上做 switch,用 applied 结果 hunk 替换待定调用的无上下文片段。仅面向自动化的 ACP 桥接层不承载工具展示。

曾考虑的替代方案

手写或 vendor diff 算法。 上下文 hunk 有已知的边界情况,因此 dsh-tool-fs 使用带类型的 diff 包,并在一个模块中规范化 structuredPatch 输出。仓库的 vendor 策略适用于框架源码,而非每个叶子工具库。

后果

tool/result 事件携带工具私有的 meta 载荷;它属于磁盘格式词汇的一部分,由 Session.append 在运行时限制为 JSON。任何工具都可以投影持久化的结果展示,无需再改 core。diff 卡片在会话重载和快照回放时免费复现:它从日志中读回,从不重新计算。代价:覆写操作在内存中同时持有旧文本和新文本以计算仅用于 UI 的 hunk(TODO(overwrite-diff-bound)),且 dsh-tool-fs 引入了一个小型、知名的运行时依赖。

非目标

  • 实时增量 diff 流式输出。 hunk 在变更完成后一次性计算;没有逐键 diff。
  • 对二进制/非 UTF-8 覆写做 diff。 此类文件的 beforenull(没有文本 diff 基础);写入仍然成功,结果渲染整文件 diff(oldText: null)而非上下文 hunk。
  • 重命名/移动 diff。 仅限单个已解析路径的内容 diff。
  • 限制覆写 diff 基础的大小。 覆写操作将整个旧文件读入内存以计算上下文 hunk(加上已持有的新内容),因此非常大的文本覆写会为仅 UI 用途的 diff 分配两份文本。未来的改进可以设定预读上限,超过阈值时回退到整文件/无上下文 diff;在读取位置以 TODO(overwrite-diff-bound) 跟踪。

相关

  • 补全了带标签的 render-intent 联合类型中作为非目标列出的最后一项表示差异——该 Agent Note 的「非目标」一节已更新,记录 applied-hunk diff 在此处交付。
  • 基于文件系统 capability seam(before/after 是后端返回的存储事实)和事件溯源会话meta 载荷持久化在 tool/result 事件上,因此回放可复现卡片)。
  • meta 通道有意设计为通用的:未来的工具(结构化搜索、数据表结果)可以附加自己的持久化结果展示而无需再改 core。