DSH / Atlas
2026-07-29implementedbug-fix

The human transcript projects append-origin events

人类可读记录投影追加来源的事件

The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: `maxMessag

English

Problem

The terminal and the host history gateway both treated the model-visible surface as the human transcript. A successful compaction replaces a surface range with one checkpoint node, so the moment that replacement landed the terminal dropped every message it shadowed — conversation the user had already read — and re-ran that destructive rebuild on any later replacement. The same confusion reached pagination: maxMessages counted every user/message and assistant/message in the window, so a model-only replacement copy consumed a page slot the human never filled, and the cut could land between a compaction's log-only compaction/summary event and the replacement that cites it.

Nothing was lost from the log. Session.events still held every original message and full tool result; the surface only decides what the model is sent next. The defect was entirely in the projection.

Decision

Model and human projections are separate, and the event's own marker decides which one an event belongs to. dsh-session exports the marker split isAppendSurfaceEvent(event) and isReplacementSurfaceEvent(event) over the two SurfaceOp variants, from the browser-safe surface module. Append-origin events are the durable source for a transcript; replacement copies stay model-only. Everything that must send exactly what the model sees — deriveMessages, token accounting, the compaction backends, tool pairing, injected-context liveness, cross-session reference projection — keeps reading session.surface.

The terminal replays the transcript from append-origin surface events and keeps a shadowed step's tool cards paired through transcriptToolCallIds, which reads the append-origin assistant/message rather than surface membership. A landed compaction contributes one dim … earlier context was compacted … row at its own log position: the marker reports where the model stopped seeing that history instead of erasing it. The framed checkpoint payload never renders, and both paths classify a surface event by the same marker, so a compaction that arrives live and the same log replayed after resume produce the same transcript. Only replay re-derives tool/call pairing: a call event carries no marker of its own and inherits membership from the assistant/message that advertised it, which the live listener has necessarily just rendered.

A checkpoint is recognized through the compaction seam's own contract — isCompactCheckpointSource, the backend-independent marker CompactionEngine requires on the replacement user message — so the terminal depends on the declared vocabulary, not on the shape of the replacement. dsh-session-reference already consumes that predicate to project another session's log; this is the same question asked by a different reader. Other replacements are silent: a pruned tool/result and a regenerated assistant/message rewrite one node for the model and mark no boundary in the conversation.

session.history counts only append-origin messages toward maxMessages. Each page remains one contiguous raw event range, so a compaction's compaction/summary event stays on the page of the replacement that cites it.

No persisted event, RPC envelope, compaction transaction, or model-visible surface changed, and no migration is required.

Deferred

The browser client is fixed separately, in the web transcript projection note: it projects the same append-origin transcript in log order and renders a marker component, and it closes the pagination hole this change opened — because session.history no longer spends quota on the checkpoint, it never cuts on the checkpoint and its cited source events as a unit, so a page can carry a checkpoint citing a surfaceOp.start outside the window, which the browser's surface fold rejected. That hole predates this change (counting could already run past a checkpoint into the range it shadows), but when the checkpoint was the oldest counted message, the old pagination rule happened to include the whole shadowed range on the same page.

The terminal's archived live compaction progress decision uses standalone bracket events to drive the existing one-cell indicator. It does not change the completion marker owned here or add scale: the checkpoint's sourceEventSeqs remain available for a separately justified count or range. Progress therefore needs neither marker-content changes nor a prerequisite renderReplacement(event) extraction.

Alternatives considered

Recognize a checkpoint by shape (a replacement user/message). Rejected: it reads a coincidence of today's producers instead of a declared contract, and any future producer that replaces a range with a user message would silently inherit the compaction marker. The seam already publishes COMPACT_CHECKPOINT_SOURCE precisely so consumers can recognize a checkpoint independently of the backend.

Keep rendering the checkpoint as an injected-context card. Rejected: the framed checkpoint is an instruction envelope written for the model, not human conversation content. Showing it while hiding the history it replaced inverts what the reader needs.

Persist a second display transcript. Rejected: the append-only log already contains the authoritative source material, so a parallel record buys nothing and adds migration and consistency work.

Derive the marker from the compaction/* bracket instead of the checkpoint. Rejected for the transcript: the bracket is a pair of time-point markers around an operation, while the transcript needs the position where the surface actually changed. The bracket is the right source for progress and duration, which this change does not render.

Classify events by re-folding the log, as session-query does for search (current / shadowed / log-only). Rejected: a fold answers a whole-log question, while a projection asks a per-event one that the event's own marker already answers in constant time.

Consequences

Compaction no longer erases terminal history; a session compacted several times shows one marker per landed compaction, in log order. Pagination pages can carry more raw events than before, because quota is spent only on messages a human or model actually produced.

rebuildTranscript now materializes a component per append-origin event in the whole log, and it runs on mount, on a terminal color-scheme change, and on every reasoning toggle. Compaction used to bound that work for exactly the long sessions compaction serves, so the cost now grows with session length instead of with the surface. That is the trade the fix exists to make — preserved history is the point — but a windowing or reuse strategy belongs to whoever first measures a slow rebuild, not to a later profiler wondering why the work grew.

dsh-tui gains a dependency on the dsh-compaction seam for one pure predicate, mirroring dsh-session-reference's existing use. The terminal still needs no compaction backend at runtime.

Two behaviors changed with their tests. The surface-replacement terminal test previously pinned erasure ("hides shadowed tool calls") and now pins preservation plus exactly one marker, including a pruned result copy, a regenerated assistant message, and a foreign plugin's replacement all rendering nothing. The compaction snapshot scenario wrote a agent-instructions source while claiming to pin compaction; it now writes a real checkpoint source, and its three fixtures are re-recorded to show the preserved prompt, the full tool card, and the marker.

The live/replay equivalence above is fixture-pinned, not only asserted here: surface-replayed-compaction mounts with the replacement already stored and records byte-identical to the live path's surface-after-compaction-wide. Changing either path breaks that equality, which is the point — the resume projection is what regressed for users, and the two fixtures must move together.

中文

问题

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

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

决策

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

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

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

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

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

延后事项

浏览器客户端在Web transcript 投影笔记中单独修复:它按日志顺序投影同一份追加来源 transcript 并渲染一个标记组件,同时闭合本次变更打开的分页缺口——因为 session.history 不再为检查点消耗额度,它永远不会在检查点与检查点引用的来源事件这个整体内切分,于是一页可以携带一个引用了窗口之外 surfaceOp.start 的检查点,而浏览器的 surface fold 会拒绝该范围。这个缺口早于本次变更(此前计数就可能越过检查点进入它所遮蔽的范围),但当检查点是最旧的被计数消息时,旧分页规则会把整段被遮蔽的范围放在同一页。

终端的已归档实时压缩进度决策使用独立标记对中的事件驱动现有的单格指示器。它既不改变本文所负责的完成标记,也不添加规模信息:检查点的 sourceEventSeqs 仍可供经另行论证的计数或区间使用。因此,进度显示既不需要修改标记内容,也不以提取 renderReplacement(event) 为前置条件。

曾考虑的替代方案

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

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

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

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

session-query 为搜索所做的那样重新折叠日志来分类事件(currentshadowedlog-only)。 被否决:折叠回答的是整份日志的问题,而投影问的是逐事件的问题,事件自身的标记已能以常数时间给出答案。

后果

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

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

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

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

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