fix(tui,host): project the human transcript from append-origin events

The terminal and history pagination both treated the model-visible surface as
the human transcript. A landed compaction replacement therefore erased the
conversation it summarized — messages the reader had already seen — and a
model-only replacement copy consumed a page's `maxMessages` quota, which could
also split a compaction's provenance from the replacement citing it.

`dsh-session` now exports the marker split `isAppendSurfaceEvent` /
`isReplacementSurfaceEvent`. The terminal replays append-origin surface events,
keeps a shadowed step's tool cards paired through its append-origin assistant
message, and renders one dim marker where a compaction landed; the checkpoint is
recognized through the compaction seam's `isCompactCheckpointSource` contract,
not the shape of the replacement. `session.history` counts only append-origin
human messages. Everything model-facing keeps reading `session.surface`.
This commit is contained in:
Hypatia May
2026-07-29 16:13:36 +08:00
parent 59ecfac776
commit d9a11dc91e
33 changed files with 544 additions and 119 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 packages/ui/tui/README.md
README.md: 0b358520b863f0b9ee7a128cf4807f582fc46d8d
README.zh.md: 7e89197bd82d16dfbabeb715e953275e2f6dd68b
README.md: c6542cf5383e46f43024782c89e364c92a4c518e
README.zh.md: 7fbdaace128f779abf27de9042cd13b85ceef37f

View File

@@ -12,7 +12,7 @@ This package owns interactive terminal presentation and input only. It injects `
After terminal startup succeeds, the package provides the terminal-local `ctx.tui` extension service. A plugin that injects it can call `openOverlay()` with a component factory and constrained layout options; the host exposes the viewport, semantic theme, display-text escaping, redraw, close, and a lifetime signal, but not the pi-tui tree, terminal, focus controller, or overlay handle. Plugin overlays, the model selector, and user questions share one FIFO modal queue. Each request is an effect of the calling plugin fiber, so unload removes queued work or closes visible work before cleanup settles; terminal shutdown unloads dependents before stopping pi-tui. Overlay state is not logged or replayed. Component code is trusted and may render ANSI styling, but must pass untrusted text through `host.display()`. The [interactive-extension Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md) owns the boundary and rejected alternatives.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
The TUI rebuilds resumed history from the append-origin session events, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the standing `todo/write` plan above the editor (cleared on the next `turn/start`), and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelInfo()` context for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode plus the current model and any explicitly selected reasoning effort; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. A surface replacement never rewrites the rendered transcript: the conversation it shadows stays readable, and a landed compaction checkpoint adds one dim `… earlier context was compacted …` marker at its log position, so the terminal reports where the model stopped seeing that history instead of erasing it. Model-only replacement copies — a pruned tool result, a regenerated assistant message — render nothing.
An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label differs from the session's host directory. The override changes only the footer label; tools continue to use the session `cwd`.

View File

@@ -12,7 +12,7 @@ DeepSeek Harness agent智能体的交互式终端入口基于 [`@earend
终端成功启动后,本包会提供终端本地的 `ctx.tui` 扩展服务。注入该服务的插件可以使用组件工厂和受限布局选项调用 `openOverlay()`;宿主会公开 viewport、语义化主题、显示文本转义、重绘、关闭和生命周期信号但不公开 pi-tui 树、终端、焦点控制器或 overlay 句柄。插件 overlay、模型选择器和用户问题共用一个 FIFO 模态队列。每个请求都是调用方插件 fiber 的 effect因此卸载会移除排队工作或在清理结算前关闭可见工作终端关闭会先卸载依赖项再停止 pi-tui。Overlay 状态不会记录或回放。组件代码受信任,可以渲染 ANSI 样式,但必须通过 `host.display()` 处理不受信任文本。[交互式扩展 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-22-tui-interactive-extension-service.md)持有该边界和未采用的替代方案。
TUI 从活跃会话表层重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换事件会重建 transcript使经过压缩compaction的历史不会再次出现
TUI 从追加来源的会话事件重建已恢复历史,渲染 Markdown 响应与 reasoning将每个工具的 `presentCall` / `presentResult` 意图应用到终端、diff 或通用卡片,把站立的 `todo/write` 计划保留在编辑器上方(下一个 `turn/start` 时清空),并在左下方宽键盘面板中展示 `ctx.userInteraction` 问题,包含进度、编号选项和对齐说明。最新记录的会话标题成为 header 副标题;标题不存在时使用 `welcome`,终端窗口标题则变为 `<session title> — <configured title>`。持久 `llm/retry` 事件会撤回失败步骤的实时 chunk并在 transcript文本记录中渲染计划重试次数、延迟和失败成功、耗尽与取消随后通过普通会话事件结算。Footer 会对每个已记录模型步骤的用量只计一次,包括失败尝试;对于没有用量 chunk 的日志,以已提交消息的用量回退。其空闲视图会将 token-meter 压力与 `ctx.llm.resolveModelInfo()` 为当前路由返回的上下文容量进行比较;适配器没有容量元数据时显示 `context unknown`并显示工具卡片模式、当前模型以及任何显式选择的推理强度。Agent 运行时,这些摘要会替换为已经过工作时间指示器和 `esc interrupt`。表层替换从不重写已渲染的 transcript被它遮蔽的对话仍可阅读而已落地的压缩compaction检查点会在其日志位置添加一行暗色 `… earlier context was compacted …` 标记,因此终端报告的是模型从何处起不再看到那段历史,而不是把它抹掉。仅供模型使用的替换副本——被裁剪的工具结果、重新生成的 assistant 消息——不渲染任何内容
如果逻辑工作区标签与会话宿主目录不同,嵌入方可以提供 `TuiRuntime.formatCwd`。该覆盖只改变 footer 标签;工具仍使用会话 `cwd`

View File

@@ -35,6 +35,7 @@
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
@@ -73,6 +74,7 @@
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-compact": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",

View File

@@ -1,6 +1,6 @@
/**
* Zero-state helpers for the interactive chat channel: prompt-directory and
* Git-branch formatting, surface/tool-call derivations over the session log,
* Git-branch formatting, transcript/tool-call derivations over the session log,
* session-reference context cards, the placeholder editor, and banner-reveal
* timing constants. None of these close over channel state.
* @module @deepseek-ai/dsh-tui/chat/helpers
@@ -15,7 +15,9 @@ import {
truncateToWidth,
visibleWidth,
} from '@earendil-works/pi-tui'
import type { Session } from '@deepseek-ai/dsh-session'
import { isCompactCheckpointSource } from '@deepseek-ai/dsh-compact'
import { isAppendSurfaceEvent, isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
/** Editor that shows a placeholder without making it editable content. */
export class HintEditor extends Editor {
@@ -83,24 +85,16 @@ export function gitBranch(cwd: string): string | undefined {
}
/**
* Sequence numbers currently visible on the session surface.
* @param session - session whose surface nodes to read.
* @returns the set of visible event sequence numbers.
*/
export function activeSurfaceSeqs(session: Session): Set<number> {
return new Set(session.surface.nodes)
}
/**
* Tool-call ids whose owning assistant message is on the active surface.
* Tool-call ids whose owning assistant message is append-origin, so its tool
* cards stay paired in the transcript after a replacement shadowed the message
* on the model surface.
* @param session - session whose events to scan.
* @param active - sequence numbers currently on the surface.
* @returns the set of active tool-call ids.
* @returns the set of transcript tool-call ids.
*/
export function activeToolCallIds(session: Session, active: ReadonlySet<number>): Set<string> {
export function transcriptToolCallIds(session: Session): Set<string> {
const ids = new Set<string>()
for (const event of session.events) {
if (event.type !== 'assistant/message' || !active.has(event.seq)) continue
if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) continue
for (const block of event.data.message.content) {
if (block.type === 'tool-call') ids.add(block.id)
}
@@ -108,6 +102,22 @@ export function activeToolCallIds(session: Session, active: ReadonlySet<number>)
return ids
}
/**
* Whether an event is a landed compaction checkpoint. Recognition goes through
* {@link isCompactCheckpointSource} — the compaction seam's backend-independent
* contract for the source every backend stamps on its replacement user message —
* 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.
* @param event - event to test.
* @returns true when the event compacted a surface range.
*/
export function isCompactCheckpoint(event: SessionEvent): boolean {
return event.type === 'user/message'
&& isCompactCheckpointSource(event.data.source)
&& isReplacementSurfaceEvent(event)
}
/**
* Read a session-reference context card's display labels from an event source.
* @param source - event source to inspect.

View File

@@ -35,6 +35,9 @@ import type { ContentBlock, MessageId } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
isAppendSurfaceEvent,
isReplacementSurfaceEvent,
isSurfaceEligibleType,
SessionId,
type SessionEvent,
type UserMessage,
@@ -118,14 +121,14 @@ import {
} from './chat/skill-invocation.ts'
import { ReferenceAutocompleteProvider } from './chat/autocomplete.ts'
import {
activeSurfaceSeqs,
activeToolCallIds,
BANNER_REVEAL_INTERVAL_MS,
BANNER_REVEAL_STEPS,
formatCwd,
gitBranch,
HintEditor,
isCompactCheckpoint,
sessionReferenceCard,
transcriptToolCallIds,
} from './chat/helpers.ts'
import {
createModelController,
@@ -268,6 +271,13 @@ export const inject = ['agents', 'sessions', 'commands', 'userInteraction', 'too
/** Model guidance for path-only file references selected through the TUI. */
export const FILE_REFERENCE_PROMPT = 'Paths prefixed with @ are files explicitly referenced by the user. Use the read tool when their contents are needed; do not claim to have inspected a file before reading it.'
/**
* Transcript row standing in for one compacted range. The conversation the
* compaction replaced stays rendered above it: the marker reports where the
* model stopped seeing that history, not that the history is gone.
*/
const COMPACTION_MARKER = '… earlier context was compacted …'
interface RunningStatus {
turn: number | undefined
timer: ReturnType<typeof setInterval>
@@ -816,6 +826,17 @@ export function createTuiChat(
}
}
const renderCompactionMarker = (): void => {
chat.addChild(new Spacer(1))
chat.addChild(new Text(palette.dim(COMPACTION_MARKER), 0, 0))
}
/**
* Replay the human transcript from the append-only log. The model-visible
* 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.
*/
const rebuildTranscript = (populateHistory: boolean): void => {
chat.clear()
toolCards.clear()
@@ -823,15 +844,13 @@ export function createTuiChat(
contextCards.clear()
streaming = undefined
todo.update([])
const active = activeSurfaceSeqs(agent.session)
const activeCalls = activeToolCallIds(agent.session, active)
const transcriptCalls = transcriptToolCallIds(agent.session)
for (const event of agent.session.events) {
const isSurface = event.type === 'user/message'
|| event.type === 'assistant/message'
|| event.type === 'tool/result'
|| event.type === 'steering/message'
if (isSurface && !active.has(event.seq)) continue
if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue
if (isSurfaceEligibleType(event.type) && !isAppendSurfaceEvent(event)) {
if (isCompactCheckpoint(event)) renderCompactionMarker()
continue
}
if (event.type === 'tool/call' && !transcriptCalls.has(event.data.callId)) continue
renderEvent(event, { addHistory: populateHistory, renderChunks: false })
}
requestRender()
@@ -1438,8 +1457,11 @@ export function createTuiChat(
recordEventUsage(tokens, event)
if (event.type === 'turn/start' && runningStatus !== undefined) runningStatus.turn = event.data.turn
if (event.type === 'assistant/message' && streaming?.isSettled()) streaming = undefined
if ('surfaceOp' in event && typeof event.surfaceOp === 'object') {
rebuildTranscript(false)
// A replacement mutates only the model surface, so the rendered transcript
// keeps what it already showed; a landed summary checkpoint adds its marker.
if (isReplacementSurfaceEvent(event)) {
if (isCompactCheckpoint(event)) renderCompactionMarker()
requestRender()
return
}
renderEvent(event, { addHistory: false, renderChunks: true })

View File

@@ -1,7 +1,7 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
terminal 44x18 buffer=normal length=24 base=6 viewport=6
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=7 viewportRow=14 bufferRow=14
cursor hidden column=7 viewportRow=17 bufferRow=23
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -13,25 +13,39 @@ buffer
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
5| <blank>
6| "You "
style 0-2 fg=bright-magenta bold underline
7| "Old prompt with a long line that exercises "
8| "wrapping and stays visible after compaction."
9| <blank>
10| "● Tool / bash / Run the coverage gate"
style 0-36 fg=green
11| "$ pnpm run test:coverage "
style 0-23 dim
12| "/workspace/project "
style 0-17 dim
13| "packages/ui/tui 100% "
style 0-19 dim
14| "… +1 lines (Ctrl+O to expand) "
style 0-28 dim
15| "1 test skipped "
style 0-13 dim
16| "coverage complete "
style 0-16 dim
17| "[exit 0] "
style 0-7 dim
18| "Model wait 0.0s "
style 0-14 dim
6| <blank>
7| "Context · workspace-context"
style 0-26 dim
8| "Additional instructions from: "
style 0-43 dim
9| "nested/AGENTS.md "
style 0-15 dim
10| " "
11| "Render workspace context XML clearly. "
style 0-36 dim
12| <blank>
13| "/workspace/project (tui-staging) deepseek-v"
19| <blank>
20| "… earlier context was compacted … "
style 0-32 dim
21| <blank>
22| "/workspace/project (tui-staging) deepseek-v"
style 0-17 fg=bright-magenta bold
style 18-31 dim
style 34-43 dim
14| " dsh > "
23| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
15-17| <blank>

View File

@@ -1,7 +1,7 @@
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=13 bufferRow=13
cursor hidden column=7 viewportRow=22 bufferRow=22
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-magenta bold
@@ -13,25 +13,41 @@ buffer
3| <blank>
4| "Assistant "
style 0-8 fg=bright-magenta bold underline
5| "Model wait 0.0s "
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
6| <blank>
7| "Context · workspace-context"
style 0-26 dim
8| "Additional instructions from: nested/AGENTS.md "
style 0-45 dim
9| " "
10| "Render workspace context XML clearly. "
style 0-36 dim
11| <blank>
12| "/workspace/project (tui-staging) deepseek-v4-flash ↑0 ↓0 0% context"
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
13| " dsh > "
22| " dsh > "
style 1-3 fg=bright-magenta bold
style 5-6 dim
style 7-7 inverse
14-29| <blank>
23-29| <blank>

View File

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

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
import { createUserMessage, CallId, type ContentBlock , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import { SessionId, type JsonValue, type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
@@ -684,7 +685,7 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('pins compaction surface replacement and narrow-to-wide reflow', async () => {
it('pins preserved history, the compaction marker, and narrow-to-wide reflow', async () => {
// 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.
@@ -696,7 +697,7 @@ describe('TUI terminal-state snapshots', () => {
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 before compaction.' }],
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', {
@@ -717,7 +718,7 @@ describe('TUI terminal-state snapshots', () => {
step: 1,
message: createToolResultMessage({
callId: CallId('old-tool'),
content: [{ type: 'text', text: 'obsolete output that must disappear' }],
content: [{ type: 'text', text: 'tool output that stays readable after compaction' }],
isError: false,
}),
}, { surfaceOp: 'append' })
@@ -732,9 +733,9 @@ describe('TUI terminal-state snapshots', () => {
harness.session.append('user/message', createUserMessage({
content: [{
type: 'text',
text: '<system-reminder>\nAdditional instructions from: nested/AGENTS.md\n\nRender workspace context XML clearly.\n</system-reminder>',
text: '<context_checkpoint>\nModel-only summary payload that must never reach the transcript.\n</context_checkpoint>',
}],
source: { kind: 'plugin', plugin: 'workspace-context' },
source: COMPACT_CHECKPOINT_SOURCE,
}), {
surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd },
sourceEventSeqs: replacementSources,

View File

@@ -16,6 +16,7 @@ import { createUserMessage,
} from '@deepseek-ai/dsh-llm'
import { GOAL_CHANGE_VERSION, GoalId, renderGoalChange, type GoalSnapshotChangeMeta } from '@deepseek-ai/dsh-goal'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import { COMPACT_CHECKPOINT_SOURCE } from '@deepseek-ai/dsh-compact'
import SessionStore, { SessionId, type JsonValue, type SessionEvent, type SessionHeader, type TurnEndReason } from '@deepseek-ai/dsh-session'
import type { SessionRecord } from '@deepseek-ai/dsh-session-query'
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
@@ -4365,10 +4366,10 @@ describe('tool cards and surface replay', () => {
await dispose(result)
})
it('rebuilds after a surface replacement and hides shadowed tool calls', async () => {
it('keeps append-origin history and marks a landed compaction, live and on rebuild', async () => {
const result = await setup({ tools })
appendUser(result.session, 'old prompt')
const assistant = result.session.append('assistant/message', {
result.session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
@@ -4391,21 +4392,116 @@ describe('tool cards and surface replay', () => {
isError: false,
}),
}, { surfaceOp: 'append' })
const start = result.session.surface.nodes[0] as number
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary replacement' }],
source: { kind: 'plugin', plugin: 'compact' },
}), {
surfaceOp: { op: 'replace', start, end: toolResult.seq },
sourceEventSeqs: [start, assistant.seq, toolResult.seq],
// Result pruning rewrites one node's content in place: model-only, and no
// boundary in the conversation, so the terminal keeps the full output.
const originalResult = toolResult.data.message.content[0]
result.session.append('tool/result', {
...toolResult.data,
message: freezeMessage({
...toolResult.data.message,
content: [{ ...originalResult, content: [{ type: 'text', text: 'pruned result copy' }] }] as [typeof originalResult],
}),
}, {
surfaceOp: { op: 'replace', start: toolResult.seq, end: toolResult.seq },
sourceEventSeqs: [toolResult.seq],
})
const nodes = [...result.session.surface.nodes]
const checkpoint = result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>model-only summary payload</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}), {
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
sourceEventSeqs: nodes,
})
// A regenerated assistant message replaces one node without summarizing
// anything, so it marks no boundary either.
const generic = result.session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'generic replacement copy' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: { op: 'replace', start: checkpoint.seq, end: checkpoint.seq }, sourceEventSeqs: [checkpoint.seq] })
// Only a checkpoint carrying the compaction seam's source marks a boundary:
// another plugin replacing a node is model-only.
result.session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'foreign plugin replacement copy' }],
source: { kind: 'plugin', plugin: 'other' },
}), { surfaceOp: { op: 'replace', start: generic.seq, end: generic.seq }, sourceEventSeqs: [generic.seq] })
await tick()
result.terminal.resize(89)
await tick()
const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(lastFullRender).toContain('summary replacement')
expect(lastFullRender).not.toContain('old output')
const liveRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(liveRender).toContain('old prompt')
// The shadowed step keeps its card: one call row, one full result, no
// second card from the pruned copy.
expect(liveRender.split('$ printf hello')).toHaveLength(2)
expect(liveRender).toContain('third')
expect(liveRender.split('[exit 0]')).toHaveLength(2)
expect(liveRender.split('… earlier context was compacted …')).toHaveLength(2)
expect(liveRender).not.toContain('model-only summary payload')
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.
result.terminal.send('\x12')
await tick()
result.terminal.resize(90)
await tick()
const replayRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(replayRender).toContain('old prompt')
expect(replayRender.split('$ printf hello')).toHaveLength(2)
expect(replayRender).toContain('third')
expect(replayRender.split('[exit 0]')).toHaveLength(2)
expect(replayRender.split('… earlier context was compacted …')).toHaveLength(2)
expect(replayRender).not.toContain('model-only summary payload')
expect(replayRender).not.toContain('generic replacement copy')
expect(replayRender).not.toContain('foreign plugin replacement copy')
await dispose(result)
})
it('replays a stored compaction as preserved history plus its marker', async () => {
const result = await setup({
beforeMount(session) {
appendUser(session, 'prompt before compaction')
session.append('assistant/message', {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'reply before compaction' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'deepseek-v4-flash' },
},
}),
}, { surfaceOp: 'append' })
const nodes = [...session.surface.nodes]
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: '<context_checkpoint>stored model-only payload</context_checkpoint>' }],
source: COMPACT_CHECKPOINT_SOURCE,
}), {
surfaceOp: { op: 'replace', start: nodes[0] as number, end: nodes.at(-1) as number },
sourceEventSeqs: nodes,
})
},
})
result.terminal.resize(89)
await tick()
const mounted = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J'))
expect(mounted).toContain('prompt before compaction')
expect(mounted).toContain('reply before compaction')
expect(mounted.split('… earlier context was compacted …')).toHaveLength(2)
expect(mounted).not.toContain('stored model-only payload')
await dispose(result)
})
})

View File

@@ -53,6 +53,9 @@
{
"path": "../commands"
},
{
"path": "../../compact/compact"
},
{
"path": "../../skill/skill"
},