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

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