Ver código fonte

fix(tui,host): pin replayed compaction and correct projection wording
Review follow-ups on the append-origin transcript projection.
The live/replay equivalence claim was stated unconditionally but does not
cover `tool/call`: only replay re-derives call pairing, because a call
event carries no `surfaceOp` of its own and inherits transcript
membership from the `assistant/message` that advertised it — which the
live listener has necessarily just rendered. Narrow the claim in the TUI
README and Agent Note, and record at `rebuildTranscript` why the filter
is replay-only rather than a missing live branch.
Add `surface-replayed-compaction`: the three existing fixtures all come
from the live path, leaving the resume case the bug report leads with
pinned only by a unit test. The new checkpoint mounts with the
replacement already stored and records byte-identical to
`surface-after-compaction-wide`, so the two fixtures now pin the
equivalence they assert. The shared fixture appends move into
`appendPreCompactionLog` / `appendCompactionCheckpoint`.
`MESSAGE_TYPES` is not "human message event types" — it includes
`assistant/message`. Say what the code distinguishes (append-origin
conversation messages vs. model-only replacement copies) at the const,
the `paginate` and `session.history` JSDoc, the apiproxy README, and the
Agent Note.
Also: spell the replace shape as `Extract<SurfaceOp, { op: 'replace' }>`
for symmetry with the module's two other uses; document why
`isCompactCheckpoint` keeps a replacement check that is redundant at both
call sites; say that Ctrl+R toggles reasoning, which rebuilds the
transcript; and qualify "the sole source of derived history" as derived
*model* history now that the transcript is the other projection.

Hypatia May 1 mês atrás
pai
commit
035a99f922

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.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 .agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md
-2026-07-29-human-transcript-append-origin.md: 51602148810b0c400ec7b0f7996b37dd666bf93f
-2026-07-29-human-transcript-append-origin.zh.md: 77e9a691f06978ac6875e8f4330d96ecca23a1b5
+2026-07-29-human-transcript-append-origin.md: c4b00dd7093ab1501a83011cf37c9841b15ce1a9
+2026-07-29-human-transcript-append-origin.zh.md: 783baaf47c137d1617c3a983c0a7bb2fa7bc50bb

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md

@@ -14,11 +14,11 @@ Nothing was lost from the log. `Session.events` still held every original messag
 
 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 the replay and live paths share one rule, so a compaction that arrives live and the same log replayed after resume produce the same transcript.
+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 `CompactService` 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 human messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compact/summary` provenance stays on the page of the replacement that cites it.
+`session.history` counts only append-origin messages toward `maxMessages`. Each page remains one contiguous raw event range, so a compaction's `compact/summary` provenance 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.
 

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md

@@ -14,11 +14,11 @@ Status: implemented
 
 模型投影与人类投影是分开的,而事件属于哪一种由事件自身的标记决定。`dsh-session` 在浏览器安全的 `surface` 模块中导出按两种 `SurfaceOp` 变体划分的谓词 `isAppendSurfaceEvent(event)` 与 `isReplacementSurfaceEvent(event)`。追加来源的事件是记录的持久来源,替换副本仅供模型使用。凡是必须准确发送模型所见内容的部分——`deriveMessages`、token 记账、压缩后端、工具配对、注入上下文的存活判断、跨会话引用投影——都继续读取 `session.surface`。
 
-终端从追加来源的 surface 事件回放记录,并通过 `transcriptToolCallIds` 让被遮蔽步骤的工具卡片保持配对:该函数读取追加来源的 `assistant/message`,而不是 surface 成员关系。已落地的压缩会在其自身日志位置贡献一行暗色 `… earlier context was compacted …`:这行标记报告模型从何处起不再看到那段历史,而不是把它抹掉。带框的检查点载荷从不渲染,且回放路径与实时路径共用同一条规则,因此实时到达的压缩与恢复后回放同一份日志会产生相同的记录。
+终端从追加来源的 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` 溯源信息会与引用它的替换留在同一页。
+`session.history` 只把追加来源的消息计入 `maxMessages`。每一页仍是一段连续的原始事件区间,因此压缩的 `compact/summary` 溯源信息会与引用它的替换留在同一页。
 
 持久事件、RPC 信封、压缩事务与模型可见的 surface 都没有变化,也不需要迁移。
 

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

@@ -1590,7 +1590,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId):
 
 Types: [CreateSessionOptions](../core-data-structures/persistence.md) · [Session](../core-data-structures/session.md) · [SessionId](../core-data-structures/core.md)
 
-Source: [`packages/core/session/src/index.ts:694`](../../packages/core/session/src/index.ts)
+Source: [`packages/core/session/src/index.ts:695`](../../packages/core/session/src/index.ts)
 
 ## `ctx.sessionTitle` — `SessionTitleService`
 

+ 2 - 2
docs/core-data-structures/session.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/session.md
-session.md: 6ae0ab79b5c7bc3bc1859bf819ce25679672a7f0
-session.zh.md: 79ed40f7eee7a8cae05a366d646f85580c73d5d2
+session.md: 0facaa3583a00e8065535832cf99ac4183694a71
+session.zh.md: 00168b0ebdd1ff9d9be77cba616ef81498c01f37

+ 3 - 2
docs/core-data-structures/session.md

@@ -246,7 +246,7 @@ interface SurfaceIntent {
 }
 ```
 
-Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived history. Non-surface types reject it at compile time.
+Required for `SurfaceEventType` events — every message-producing event must declare how it joins the surface, the sole source of derived model history. A human-facing transcript is the other projection and reads the log's append-origin events instead, because the surface deliberately shadows the ranges a replacement summarizes (`isAppendSurfaceEvent` in [dsh-session](../../packages/core/session/README.md)). Non-surface types reject it at compile time.
 
 The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs`; omission does not assert that its source stream was empty.
 
@@ -353,7 +353,8 @@ declare class Session {
    *   the ordered surface; `sourceEventSeqs` records provenance (the seq
    *   numbers of events this one derives from). REQUIRED for
    *   {@link SurfaceEventType} events (every message-producing event must
-   *   declare how it joins the surface, the sole source of derived history) and
+   *   declare how it joins the surface, the sole source of derived model
+   *   history) and
    *   rejected by the compiler for non-surface types like `turn/start` or
    *   `assistant/chunk`.
    * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of

+ 3 - 2
docs/core-data-structures/session.zh.md

@@ -248,7 +248,7 @@ interface SurfaceIntent {
 }
 ```
 
-对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生历史的唯一来源)。非 surface 类型在编译期拒绝此参数。
+对 `SurfaceEventType` 事件必填:每个产生消息的事件都必须声明它如何加入 surface(派生模型历史的唯一来源)。面向人类的记录(transcript)是另一个投影,读取的是日志中追加来源的事件,因为 surface 会有意遮蔽替换所概括的范围(见 [dsh-session](../../packages/core/session/README.md) 的 `isAppendSurfaceEvent`)。非 surface 类型在编译期拒绝此参数。
 
 此处适用相同的溯源区分:只有 `assistant/message` 可以携带存在但为空的 `sourceEventSeqs`;省略该字段并不表示其源流为空。
 
@@ -355,7 +355,8 @@ declare class Session {
    *   the ordered surface; `sourceEventSeqs` records provenance (the seq
    *   numbers of events this one derives from). REQUIRED for
    *   {@link SurfaceEventType} events (every message-producing event must
-   *   declare how it joins the surface, the sole source of derived history) and
+   *   declare how it joins the surface, the sole source of derived model
+   *   history) and
    *   rejected by the compiler for non-surface types like `turn/start` or
    *   `assistant/chunk`.
    * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of

+ 2 - 1
packages/core/session/src/index.ts

@@ -461,7 +461,8 @@ export class Session {
    *   the ordered surface; `sourceEventSeqs` records provenance (the seq
    *   numbers of events this one derives from). REQUIRED for
    *   {@link SurfaceEventType} events (every message-producing event must
-   *   declare how it joins the surface, the sole source of derived history) and
+   *   declare how it joins the surface, the sole source of derived model
+   *   history) and
    *   rejected by the compiler for non-surface types like `turn/start` or
    *   `assistant/chunk`.
    * @returns the logged event — its assigned `seq`/`time` plus the SNAPSHOT of

+ 1 - 1
packages/core/session/src/surface.ts

@@ -63,7 +63,7 @@ export function isAppendSurfaceEvent(
  */
 export function isReplacementSurfaceEvent(
   event: SessionEvent,
-): event is SurfaceEvent & { surfaceOp: { op: 'replace'; start: number; end: number } } {
+): event is SurfaceEvent & { surfaceOp: Extract<SurfaceOp, { op: 'replace' }> } {
   return isSurfaceEvent(event) && event.surfaceOp !== 'append'
 }
 

+ 2 - 2
packages/host/apiproxy/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/host/apiproxy/README.md
-README.md: 190e7f8d36f74bfd7232cc5b4dbb937f265ef1d5
-README.zh.md: 9fb9d24412e950b0648cd07c4b5c73aa0174405c
+README.md: 7f5a469b02853642649220e20a093ce98e7bb063
+README.zh.md: ff909cf66f5d3fdf7f10778b094f3203920abb8e

+ 1 - 1
packages/host/apiproxy/README.md

@@ -10,7 +10,7 @@ Wire messages form a four-quadrant discriminated union — who initiates × requ
 
 The layering/protocol decisions are recorded in the [GUI layering and RPC protocol RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md); the browser-side consumption architecture in the [web client architecture RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md).
 
-`session.history` pages on append-origin human-message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
+`session.history` pages on append-origin message boundaries: `maxMessages` counts `user/message`, `assistant/message`, and `steering/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only provenance on the same page as the replacement that cites it.
 
 `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
 

+ 1 - 1
packages/host/apiproxy/README.zh.md

@@ -10,7 +10,7 @@
 
 分层与协议决策记录在 [GUI 分层与 RPC 协议 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)中;浏览器侧消费架构记录在 [Web 客户端架构 RFC](../../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)中。
 
-`session.history` 按追加来源的人类消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
+`session.history` 按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message`、`assistant/message` 和 `steering/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志溯源信息与引用它的替换留在同一页。
 
 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元铸造一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
 

+ 9 - 8
packages/host/apiproxy/src/api-proxy.ts

@@ -58,17 +58,18 @@ import { openNativePath } from './native-path-opener.ts'
 /** Page size when history is called without maxMessages. */
 const DEFAULT_MAX_MESSAGES = 50
 
-/** Human message event types (the pagination counting unit). */
+/** Conversation message event types (the pagination counting unit). */
 const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
 
 /**
- * Message-boundary pagination: count maxMessages append-origin human messages
- * backwards from the window tail. Replacement copies are model-only, so they
- * consume no quota; the page stays one contiguous raw range, which keeps a
- * compaction's log-only provenance on the same page as its replacement. The cut
- * is the starting seq of the oldest message group (chunks group via
- * sourceEventSeqs — never cut mid-message). The tail page naturally includes the
- * in-progress partial.
+ * Message-boundary pagination: count maxMessages append-origin messages
+ * backwards from the window tail. Replacement copies never entered the
+ * conversation a reader sees — they restate a shadowed range for the model
+ * alone — so they consume no quota; the page stays one contiguous raw range,
+ * which keeps a compaction's log-only provenance on the same page as its
+ * replacement. The cut is the starting seq of the oldest message group (chunks
+ * group via sourceEventSeqs — never cut mid-message). The tail page naturally
+ * includes the in-progress partial.
  */
 function paginate(
   events: readonly SessionEvent[],

+ 1 - 1
packages/host/apiproxy/src/api/sessions.ts

@@ -176,7 +176,7 @@ export interface SessionsApi {
   Promise<RpcResponse<{ sessionId: SessionId }>>
 
   /**
-   * Reads a window of history events; page boundaries align to append-origin human-message
+   * Reads a window of history events; page boundaries align to append-origin message
    * boundaries: one page = all raw events owned by a whole number of such messages (including
    * their chunk / tool events), never cut mid-message. Model-only replacement copies consume no
    * `maxMessages`, so a compaction's provenance stays on the page of its replacement. The tail

+ 4 - 0
packages/ui/tui/src/chat/helpers.ts

@@ -109,6 +109,10 @@ export function transcriptToolCallIds(session: Session): Set<string> {
  * rather than the shape of the replacement. Other replacements (a pruned
  * `tool/result`, a regenerated `assistant/message`) rewrite one node for the
  * model and mark no boundary in the conversation.
+ *
+ * The replacement check is redundant at both current call sites, which already
+ * reached a replacement: it keeps the exported predicate true to its name for a
+ * third caller, rather than making that caller repeat the check.
  * @param event - event to test.
  * @returns true when the event compacted a surface range.
  */

+ 6 - 0
packages/ui/tui/src/index.ts

@@ -836,6 +836,12 @@ export function createTuiChat(
    * surface shadows compacted ranges, so it is not the source here: every
    * append-origin message stays rendered, and a replacement contributes at most
    * the compaction marker at its own log position.
+   *
+   * The `tool/call` pairing check has no live counterpart, because only replay
+   * can meet an orphan: `tool/call` carries no `surfaceOp` of its own, so it
+   * inherits transcript membership from the `assistant/message` that advertised
+   * it, which the live listener has necessarily just rendered. A loaded log is a
+   * replay boundary, so the pairing is re-derived here instead of assumed.
    */
   const rebuildTranscript = (populateHistory: boolean): void => {
     chat.clear()

+ 53 - 0
packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt

@@ -0,0 +1,53 @@
+terminal 104x30 buffer=normal length=30 base=0 viewport=0
+lifecycle started=1 stopped=0 progress=inactive
+title "DSH snapshot"
+cursor hidden column=7 viewportRow=22 bufferRow=22
+buffer
+0| " DEEPSEEK HARNESS"
+  style 1-8 fg=bright-magenta bold
+  style 10-16 bold
+1| " Snapshot agent ready."
+  style 1-21 dim
+2| " main-session"
+  style 1-12 dim
+3| <blank>
+4| "Assistant                                                                                               "
+  style 0-8 fg=bright-magenta bold underline
+5| <blank>
+6| "You                                                                                                     "
+  style 0-2 fg=bright-magenta bold underline
+7| "Old prompt with a long line that exercises wrapping and stays visible after compaction.                 "
+8| <blank>
+9| "● Tool / bash / Run the coverage gate"
+  style 0-36 fg=green
+10| "$ pnpm run test:coverage                                                                                "
+  style 0-23 dim
+11| "/workspace/project                                                                                      "
+  style 0-17 dim
+12| "packages/ui/tui 100%                                                                                    "
+  style 0-19 dim
+13| "… +1 lines (Ctrl+O to expand)                                                                           "
+  style 0-28 dim
+14| "1 test skipped                                                                                          "
+  style 0-13 dim
+15| "coverage complete                                                                                       "
+  style 0-16 dim
+16| "[exit 0]                                                                                                "
+  style 0-7 dim
+17| "Model wait 0.0s                                                                                         "
+  style 0-14 dim
+18| <blank>
+19| "… earlier context was compacted …                                                                       "
+  style 0-32 dim
+20| <blank>
+21| "/workspace/project (tui-staging)  deepseek-v4-flash  ↑0 ↓0  0% context"
+  style 0-17 fg=bright-magenta bold
+  style 18-31 dim
+  style 34-50 dim
+  style 53-57 dim
+  style 60-69 dim
+22| " dsh >                                                                                                  "
+  style 1-3 fg=bright-magenta bold
+  style 5-6 dim
+  style 7-7 inverse
+23-29| <blank>

+ 82 - 45
packages/ui/tui/tests/tui.snapshot.ts

@@ -51,6 +51,7 @@ const CHECKPOINTS = [
   'surface-before-compaction',
   'surface-after-compaction-narrow',
   'surface-after-compaction-wide',
+  'surface-replayed-compaction',
   'model-selector',
   'model-selector-filtered',
   'model-switching',
@@ -182,6 +183,64 @@ function appendToolResult(
   }, { surfaceOp: 'append' })
 }
 
+/** Frozen clock for the compaction fixtures; see the live scenario for why. */
+const COMPACTION_FIXTURE_TIME = new Date(2026, 6, 21, 14, 40, 0).getTime()
+
+/** The surface range a compaction checkpoint replaces, with its provenance. */
+interface CompactionRange {
+  start: number
+  end: number
+  sources: number[]
+}
+
+/**
+ * Append one prompt / tool-call / tool-result step, the history a compaction
+ * shadows on the model surface and the transcript must keep showing.
+ */
+function appendPreCompactionLog(session: Session): CompactionRange {
+  const user = session.append('user/message', createUserMessage({
+    content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping and stays visible after compaction.' }],
+    source: { kind: 'user' },
+  }), { surfaceOp: 'append' })
+  const assistant = session.append('assistant/message', {
+    turn: 1,
+    step: 1,
+    message: createMessage({
+      role: 'assistant',
+      content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
+      source: {
+        kind: 'model',
+        ...{ provider: 'mock', model: 'deepseek-v4-flash' },
+      },
+    }),
+  }, { surfaceOp: 'append' })
+  session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
+  const result = session.append('tool/result', {
+    turn: 1,
+    step: 1,
+    message: createToolResultMessage({
+      callId: CallId('old-tool'),
+      content: [{ type: 'text', text: 'tool output that stays readable after compaction' }],
+      isError: false,
+    }),
+  }, { surfaceOp: 'append' })
+  return { start: user.seq, end: result.seq, sources: [user.seq, assistant.seq, result.seq] }
+}
+
+/** Land a compaction: replace the range with the framed model-only checkpoint. */
+function appendCompactionCheckpoint(session: Session, range: CompactionRange): void {
+  session.append('user/message', createUserMessage({
+    content: [{
+      type: 'text',
+      text: '<context_checkpoint>\nModel-only summary payload that must never reach the transcript.\n</context_checkpoint>',
+    }],
+    source: COMPACT_CHECKPOINT_SOURCE,
+  }), {
+    surfaceOp: { op: 'replace', start: range.start, end: range.end },
+    sourceEventSeqs: range.sources,
+  })
+}
+
 function visualTool(
   name: string,
   call: NonNullable<ToolDefinition['presentCall']>,
@@ -689,57 +748,18 @@ describe('TUI terminal-state snapshots', () => {
     // Freeze the clock: the timing header hides zero-duration buckets, so a
     // real-clock millisecond tick between the fixture appends and the render
     // would flip `Tools 0.0s` in and out of the pinned header.
-    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(new Date(2026, 6, 21, 14, 40, 0).getTime())
-    let replacementStart = 0
-    let replacementEnd = 0
-    let replacementSources: number[] = []
+    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
+    // beforeMount runs synchronously inside setupSnapshot, so the range the
+    // checkpoint replaces is assigned before the first await below.
+    let compacted!: CompactionRange
     const harness = await setupSnapshot({
       tools: ADVANCED_CARD_TOOLS,
-      beforeMount(session) {
-        const user = session.append('user/message', createUserMessage({
-          content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping and stays visible after compaction.' }],
-          source: { kind: 'user' },
-        }), { surfaceOp: 'append' })
-        const assistant = session.append('assistant/message', {
-          turn: 1,
-          step: 1,
-          message: createMessage({
-            role: 'assistant',
-            content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }],
-            source: {
-              kind: 'model',
-              ...{ provider: 'mock', model: 'deepseek-v4-flash' },
-            },
-          }),
-        }, { surfaceOp: 'append' })
-        session.append('tool/call', { turn: 1, step: 1, callId: CallId('old-tool'), name: 'bash', arguments: '{}' })
-        const result = session.append('tool/result', {
-          turn: 1,
-          step: 1,
-          message: createToolResultMessage({
-            callId: CallId('old-tool'),
-            content: [{ type: 'text', text: 'tool output that stays readable after compaction' }],
-            isError: false,
-          }),
-        }, { surfaceOp: 'append' })
-        replacementStart = user.seq
-        replacementEnd = result.seq
-        replacementSources = [user.seq, assistant.seq, result.seq]
-      },
+      beforeMount(session) { compacted = appendPreCompactionLog(session) },
     }, { columns: 80, rows: 24 })
     await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true })
 
     await renderAfter(harness, () => {
-      harness.session.append('user/message', createUserMessage({
-        content: [{
-          type: 'text',
-          text: '<context_checkpoint>\nModel-only summary payload that must never reach the transcript.\n</context_checkpoint>',
-        }],
-        source: COMPACT_CHECKPOINT_SOURCE,
-      }), {
-        surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
-        sourceEventSeqs: replacementSources,
-      })
+      appendCompactionCheckpoint(harness.session, compacted)
       harness.terminal.resize(44, 18)
     })
     await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true })
@@ -750,6 +770,23 @@ describe('TUI terminal-state snapshots', () => {
     nowSpy.mockRestore()
   })
 
+  // The resume path, which is what regressed for real users: the replacement is
+  // already stored when the terminal mounts, so the transcript comes from replay
+  // rather than from live appends. Pinned against the same log the live scenario
+  // ends on, at its wide size, so the two fixtures are directly comparable.
+  it('pins a stored compaction replayed at mount', async () => {
+    const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(COMPACTION_FIXTURE_TIME)
+    const harness = await setupSnapshot({
+      tools: ADVANCED_CARD_TOOLS,
+      beforeMount(session) {
+        appendCompactionCheckpoint(session, appendPreCompactionLog(session))
+      },
+    }, { columns: 104, rows: 30 })
+    await checkpoint('surface-replayed-compaction', harness.terminal, { includeScrollback: true })
+    await disposeSnapshot(harness)
+    nowSpy.mockRestore()
+  })
+
   it('pins wrapped and explicit multiline shell-prompt input', async () => {
     const harness = await setupSnapshot({}, { columns: 44, rows: 18 })
     await renderAfter(harness, () => {

+ 3 - 3
packages/ui/tui/tests/tui.spec.ts

@@ -4449,9 +4449,9 @@ describe('tool cards and surface replay', () => {
     expect(liveRender).not.toContain('generic replacement copy')
     expect(liveRender).not.toContain('foreign plugin replacement copy')
 
-    // Ctrl+R rebuilds the transcript from the log; the replayed projection
-    // matches what the live appends produced, including the shadowed assistant
-    // message's tool card.
+    // Ctrl+R toggles reasoning, which rebuilds the transcript from the log; the
+    // replayed projection matches what the live appends produced, including the
+    // shadowed assistant message's tool card.
     result.terminal.send('\x12')
     await tick()
     result.terminal.resize(90)