# Agent Note: 人类可读记录投影追加来源的事件

Status: implemented

[English](2026-07-29-human-transcript-append-origin.md) | 中文

## Problem

终端与宿主历史网关都把模型可见的 surface 当作人类可读记录（transcript）。一次成功的压缩（compaction）会用一个检查点节点替换一段 surface 范围，因此该替换一落地，终端就丢弃了它所遮蔽的每条消息——那些是用户已经读过的对话——并在此后任何替换到来时重新执行这次破坏性重建。同样的混淆也波及分页：`maxMessages` 统计窗口内的每个 `user/message`、`assistant/message` 和 `steering/message`，于是仅供模型使用的替换副本占用了一个人类从未填充的页面额度，而切分点还可能落在压缩的仅日志溯源信息与引用它的替换之间。

日志本身没有丢失任何内容。`Session.events` 仍保存着每条原始消息和完整的工具结果；surface 只决定接下来发送给模型的内容。缺陷完全在投影层。

## Decision

模型投影与人类投影是分开的，而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是记录的持久来源，替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。

终端从追加来源的 surface 事件回放记录，并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对：该函数读取追加来源的 `assistant/message`，而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`：这行标记报告模型从何处起不再看到那段历史，而不是把它抹掉。带框的检查点载荷从不渲染，且两条路径都按同一个标记对 surface 事件分类，因此实时到达的压缩与恢复后回放同一份日志会产生相同的记录。只有回放会重新推导 `tool/call` 的配对关系：调用事件自身不携带标记，其归属继承自公布它的 `assistant/message`，而实时监听器必然刚刚渲染过后者。

检查点通过压缩接缝自身的契约来识别——`isCompactCheckpointSource`，即 `CompactService` 要求替换用户消息携带的、与后端无关的标记——因此终端依赖的是已声明的词汇，而不是替换的形态。`dsh-session-reference` 已经在用该谓词投影另一个会话的日志；这里只是另一个读者提出同样的问题。其他替换保持静默：被裁剪的 `tool/result` 与重新生成的 `assistant/message` 只是为模型重写一个节点，并不在对话中标出边界。

`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间，因此压缩的 `compact/summary` 溯源信息会与引用它的替换留在同一页。

持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化，也不需要迁移。

## Deferred

浏览器客户端仍通过 `FoldAdapter` 从模型 surface 构建会话，因此压缩在 Web 端仍会把历史折叠成一行上下文。那里的修复用的是同一个谓词，另需按追加顺序的记录投影与一个标记组件；该工作是针对 `packages/client/runtime` 与 `packages/client/ui-conversation` 的独立变更。

该工作必须处理这样一页：其检查点引用的 `surfaceOp.start` 落在窗口之外。分页不再为检查点消耗额度，因此永远不会按检查点的溯源分组切分；而 `FoldAdapter` 会用一个非 surface 的哨兵事件填补缺失事件——于是 `SurfaceManager` 拒绝该范围，`nodes()` 退化为 `degradedSeqs()` 并记录一条错误。这个缺口早于本次变更（此前计数就可能越过检查点进入它所遮蔽的范围），但旧规则恰好覆盖了这样一种情形：检查点是最旧的被计数消息，其溯源分组把整段被遮蔽的范围一起拉到该页。`degradedSeqs()`——按追加顺序的每个 surface 可入事件——已经很接近 A2 所需的记录投影，因此那正是应当刻意构建的形态，而不是作为退化路径被动落到的结果。渲染压缩*进度*——压缩运行期间的终端指示——需要排队式手动 `/compact` 工作引入的“先开括号”顺序，同样不在本次范围内。标记同样不携带规模信息：检查点的 `sourceEventSeqs` 已经包含被遮蔽的数量，因此一个计数或区间可以告诉读者每一行折叠了多少内容。这件事属于进度那一侧，读者正是在那里遇到同一份信息的另一半。接手者应当先把终端里两处替换分支——回放与实时监听器，文本完全相同却相隔 600 行——合并为一个 `renderReplacement(event)`，让标记的内容只有一个归处。

## Alternatives considered

**按形态识别检查点（一个替换型 `user/message`）。** 被否决：那读取的是当前生产者的巧合而非已声明的契约，而未来任何用用户消息替换一段范围的生产者都会静默地继承压缩标记。接缝已经发布 `COMPACT_CHECKPOINT_SOURCE`，正是为了让消费方与后端无关地识别检查点。

**继续把检查点渲染为注入上下文卡片。** 被否决：带框的检查点是为模型撰写的指令信封，不是人类对话内容。展示它却隐藏它替换掉的历史，正好颠倒了读者的需要。

**持久化第二份展示用记录。** 被否决：仅追加的日志已经包含权威源材料，平行记录换不来任何东西，反而增加迁移与一致性工作。

**用 `compact/*` 括号而不是检查点来推导标记。** 就记录而言被否决：括号是围绕一次操作的一对时间点标记，而记录需要的是 surface 真正发生变化的位置。括号适合作为进度与耗时的来源，而本次变更并不渲染这些。

**像 `session-query` 为搜索所做的那样重新折叠日志来分类事件（`current`／`shadowed`／`log-only`）。** 被否决：折叠回答的是整份日志的问题，而投影问的是逐事件的问题，事件自身的标记已能以常数时间给出答案。

## Consequences

压缩不再抹掉终端历史；被压缩多次的会话会按日志顺序显示每次落地压缩对应的一行标记。分页的每一页可以携带比以前更多的原始事件，因为额度只花在人类或模型真正产生的消息上。

`rebuildTranscript` 现在会为整份日志中的每个追加来源事件物化一个组件，并在挂载时、终端配色方案变化时以及每次切换 reasoning 时运行。压缩此前正好为压缩所服务的那些长会话限制了这项工作量，因此这份开销现在随会话长度增长，而不再随 surface 增长。这正是本次修复要做的取舍——保留历史才是目的——但窗口化或复用策略属于第一个真正测到重建变慢的人，而不属于日后某个疑惑工作量为何增长的性能分析者。

`dsh-tui` 为一个纯谓词新增了对 `dsh-compact` 接缝的依赖，与 `dsh-session-reference` 现有用法一致。终端在运行时仍然不需要任何压缩后端。

两项行为随其测试一起改变。表层替换的终端测试此前钉住的是抹除（“隐藏被遮蔽的工具调用”），现在钉住的是保留加恰好一行标记，其中被裁剪的结果副本、重新生成的 assistant 消息以及来自其他插件的替换都不渲染任何内容。压缩快照场景此前声称钉住压缩，却写入了 `workspace-context` 来源；现在它写入真实的检查点来源，并重新录制三份 fixture，以显示被保留的提示、完整的工具卡片和那行标记。

上文的实时／回放等价性由 fixture 钉住，而不只是在此断言：`surface-replayed-compaction` 在挂载时替换已经存在，其录制结果与实时路径的 `surface-after-compaction-wide` 逐字节一致。改动任一路径都会破坏这项相等——这正是要点：回放投影才是当初对用户造成回归的部分，两份 fixture 必须一起变动。
