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.
This commit is contained in:
Hypatia May
2026-07-29 17:17:19 +08:00
parent d9a11dc91e
commit 035a99f922
19 changed files with 180 additions and 76 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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 都没有变化,也不需要迁移。

View File

@@ -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`

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'
}

View File

@@ -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

View File

@@ -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.

View File

@@ -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 页永不携带该块,未装注册表的组合则两个面都不提供。

View File

@@ -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[],

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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()

View File

@@ -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>

View File

@@ -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, () => {

View File

@@ -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)