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:
33 files changed
+544
-119
No files matched your search
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user