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

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