From 035a99f922019aa54325cc69956409fd8d80ebbf Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Wed, 29 Jul 2026 17:17:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(tui,host):=20pin=20replayed=20compaction=20?= =?UTF-8?q?and=20correct=20projection=20wording=20Review=20follow-ups=20on?= =?UTF-8?q?=20the=20append-origin=20transcript=20projection.=20The=20live/?= =?UTF-8?q?replay=20equivalence=20claim=20was=20stated=20unconditionally?= =?UTF-8?q?=20but=20does=20not=20cover=20`tool/call`:=20only=20replay=20re?= =?UTF-8?q?-derives=20call=20pairing,=20because=20a=20call=20event=20carri?= =?UTF-8?q?es=20no=20`surfaceOp`=20of=20its=20own=20and=20inherits=20trans?= =?UTF-8?q?cript=20membership=20from=20the=20`assistant/message`=20that=20?= =?UTF-8?q?advertised=20it=20=E2=80=94=20which=20the=20live=20listener=20h?= =?UTF-8?q?as=20necessarily=20just=20rendered.=20Narrow=20the=20claim=20in?= =?UTF-8?q?=20the=20TUI=20README=20and=20Agent=20Note,=20and=20record=20at?= =?UTF-8?q?=20`rebuildTranscript`=20why=20the=20filter=20is=20replay-only?= =?UTF-8?q?=20rather=20than=20a=20missing=20live=20branch.=20Add=20`surfac?= =?UTF-8?q?e-replayed-compaction`:=20the=20three=20existing=20fixtures=20a?= =?UTF-8?q?ll=20come=20from=20the=20live=20path,=20leaving=20the=20resume?= =?UTF-8?q?=20case=20the=20bug=20report=20leads=20with=20pinned=20only=20b?= =?UTF-8?q?y=20a=20unit=20test.=20The=20new=20checkpoint=20mounts=20with?= =?UTF-8?q?=20the=20replacement=20already=20stored=20and=20records=20byte-?= =?UTF-8?q?identical=20to=20`surface-after-compaction-wide`,=20so=20the=20?= =?UTF-8?q?two=20fixtures=20now=20pin=20the=20equivalence=20they=20assert.?= =?UTF-8?q?=20The=20shared=20fixture=20appends=20move=20into=20`appendPreC?= =?UTF-8?q?ompactionLog`=20/=20`appendCompactionCheckpoint`.=20`MESSAGE=5F?= =?UTF-8?q?TYPES`=20is=20not=20"human=20message=20event=20types"=20?= =?UTF-8?q?=E2=80=94=20it=20includes=20`assistant/message`.=20Say=20what?= =?UTF-8?q?=20the=20code=20distinguishes=20(append-origin=20conversation?= =?UTF-8?q?=20messages=20vs.=20model-only=20replacement=20copies)=20at=20t?= =?UTF-8?q?he=20const,=20the=20`paginate`=20and=20`session.history`=20JSDo?= =?UTF-8?q?c,=20the=20apiproxy=20README,=20and=20the=20Agent=20Note.=20Als?= =?UTF-8?q?o:=20spell=20the=20replace=20shape=20as=20`Extract`=20for=20symmetry=20with=20the=20mo?= =?UTF-8?q?dule's=20two=20other=20uses;=20document=20why=20`isCompactCheck?= =?UTF-8?q?point`=20keeps=20a=20replacement=20check=20that=20is=20redundan?= =?UTF-8?q?t=20at=20both=20call=20sites;=20say=20that=20Ctrl+R=20toggles?= =?UTF-8?q?=20reasoning,=20which=20rebuilds=20the=20transcript;=20and=20qu?= =?UTF-8?q?alify=20"the=20sole=20source=20of=20derived=20history"=20as=20d?= =?UTF-8?q?erived=20*model*=20history=20now=20that=20the=20transcript=20is?= =?UTF-8?q?=20the=20other=20projection.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...9-human-transcript-append-origin.i18n.yaml | 4 +- ...26-07-29-human-transcript-append-origin.md | 4 +- ...07-29-human-transcript-append-origin.zh.md | 4 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.i18n.yaml | 4 +- docs/core-data-structures/session.md | 5 +- docs/core-data-structures/session.zh.md | 5 +- packages/core/session/src/index.ts | 3 +- packages/core/session/src/surface.ts | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/host/apiproxy/src/api-proxy.ts | 17 +-- packages/host/apiproxy/src/api/sessions.ts | 2 +- packages/ui/tui/src/chat/helpers.ts | 4 + packages/ui/tui/src/index.ts | 6 + .../surface-replayed-compaction.expected.txt | 53 ++++++++ packages/ui/tui/tests/tui.snapshot.ts | 127 +++++++++++------- packages/ui/tui/tests/tui.spec.ts | 6 +- 19 files changed, 180 insertions(+), 76 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml index 530d982f0e..c7945bcf78 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.i18n.yaml +++ b/.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 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md index 5160214881..c4b00dd709 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.md +++ b/.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. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md index 77e9a691f0..783baaf47c 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-29-human-transcript-append-origin.zh.md +++ b/.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 都没有变化,也不需要迁移。 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 57b6a5aedf..87ab289fcd 100644 --- a/docs/cordis-catalog/services.md +++ b/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` diff --git a/docs/core-data-structures/session.i18n.yaml b/docs/core-data-structures/session.i18n.yaml index 4f6391518d..f1a8879202 100644 --- a/docs/core-data-structures/session.i18n.yaml +++ b/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 diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 6ae0ab79b5..0facaa3583 100644 --- a/docs/core-data-structures/session.md +++ b/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 diff --git a/docs/core-data-structures/session.zh.md b/docs/core-data-structures/session.zh.md index 79ed40f7ee..00168b0ebd 100644 --- a/docs/core-data-structures/session.zh.md +++ b/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 diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index b86bc0285a..ac7ae4963d 100644 --- a/packages/core/session/src/index.ts +++ b/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 diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index 4b2594d35a..d743387901 100644 --- a/packages/core/session/src/surface.ts +++ b/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 } { return isSurfaceEvent(event) && event.surfaceOp !== 'append' } diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 6fb44f9b57..c64479918c 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/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 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 190e7f8d36..7f5a469b02 100644 --- a/packages/host/apiproxy/README.md +++ b/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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 9fb9d24412..ff909cf66f 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/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 页永不携带该块,未装注册表的组合则两个面都不提供。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index df4d02dba8..689f66073f 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/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[], diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index 5917555b69..9bcd472911 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -176,7 +176,7 @@ export interface SessionsApi { Promise> /** - * 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 diff --git a/packages/ui/tui/src/chat/helpers.ts b/packages/ui/tui/src/chat/helpers.ts index c9c54b8aa6..3121cbc586 100644 --- a/packages/ui/tui/src/chat/helpers.ts +++ b/packages/ui/tui/src/chat/helpers.ts @@ -109,6 +109,10 @@ export function transcriptToolCallIds(session: Session): Set { * 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. */ diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 823d125b1c..e259fec7c2 100644 --- a/packages/ui/tui/src/index.ts +++ b/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() diff --git a/packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt b/packages/ui/tui/tests/snapshots/surface-replayed-compaction.expected.txt new file mode 100644 index 0000000000..81460f09b7 --- /dev/null +++ b/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| +4| "Assistant " + style 0-8 fg=bright-magenta bold underline +5| +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| +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| +19| "… earlier context was compacted … " + style 0-32 dim +20| +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| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index b0c63bda04..e4626b1436 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/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: '\nModel-only summary payload that must never reach the transcript.\n', + }], + source: COMPACT_CHECKPOINT_SOURCE, + }), { + surfaceOp: { op: 'replace', start: range.start, end: range.end }, + sourceEventSeqs: range.sources, + }) +} + function visualTool( name: string, call: NonNullable, @@ -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: '\nModel-only summary payload that must never reach the transcript.\n', - }], - 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, () => { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 8d88d005a5..4eebfdc01b 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/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)