fix(compact-basic): replay conversation prefix so summarization reuses KV cache

Automatic compaction fires mid-conversation, right after the loop warmed
the provider's KV cache with the last routed request. The default
summarizer then issued a separate request whose prefix shared nothing
with that warm request — a bespoke summarizer system prompt followed by
the older history flattened to one rendered transcript string — so a
differing first token invalidated the entire cached prefix and every
compaction re-processed the whole replayed history twice.

Move the compaction directive from the FRONT (a fresh system prompt) to
the END (a trailing user message), and replay the last routed request's
own system prompt, tools, message prefix, and shadowed-region messages
verbatim via session.requestHeader() + deriveEventMessage. The auxiliary
call is now a genuine prefix-extension of the warm request, so the
provider reuses the cached tokens up to the trailing instruction.

SummarizationInput carries the replayed prefix instead of a flat string;
the now-unused renderTranscript/renderContentBlocks path is removed with
its spec. Cache reuse is best-effort (head compaction guarantees a hit;
a mid-range compaction or a differently-routed summarizer forgoes it),
correctness is not.
This commit is contained in:
Turtle
2026-07-21 13:55:22 +08:00
parent 9a5c81f9e5
commit a0268d2509
15 changed files with 271 additions and 318 deletions
@@ -1,138 +0,0 @@
import { describe, expect, it } from 'vitest'
import { renderContentBlocks, renderTranscript } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { CallId } from '@deepseek-ai/dsh-llm'
function session(): Session {
return new Session(SessionId('render-spec'))
}
describe('renderContentBlocks', () => {
it('renders text blocks verbatim and skips empty ones', () => {
expect(renderContentBlocks([
{ type: 'text', text: 'hello' },
{ type: 'text', text: '' },
{ type: 'text', text: 'world' },
])).toBe('hello\nworld')
})
it('wraps reasoning, skipping empty reasoning', () => {
expect(renderContentBlocks([
{ type: 'reasoning', text: 'think' },
{ type: 'reasoning', text: '' },
])).toBe('[reasoning: think]')
})
it('renders tool-call as a name(args) placeholder', () => {
expect(renderContentBlocks([
{ type: 'tool-call', id: CallId('c1'), name: 'read', arguments: '{"filePath":"a"}' },
])).toBe('[tool-call: read({"filePath":"a"})]')
})
it('renders tool-result with nested content, and bare when empty', () => {
expect(renderContentBlocks([
{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'ok' }] },
{ type: 'tool-result', toolCallId: CallId('c2'), content: [] },
])).toBe('[tool-result: ok]\n[tool-result]')
})
it('renders an unknown (merge-extended) block type as a bare type tag', () => {
const unknown = { type: 'image', data: 'zzz' } as unknown as ContentBlock
expect(renderContentBlocks([unknown])).toBe('[image]')
})
it('returns the empty string for no blocks', () => {
expect(renderContentBlocks([])).toBe('')
})
})
describe('renderTranscript', () => {
it('renders each surface event type with its label, in the seq order given', () => {
const s = session()
const user = s.append('user/message', {
content: [{ type: 'text', text: 'fix the bug' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const assistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
turn: 0, step: 0,
content: [{ type: 'text', text: 'looking' }],
}, { surfaceOp: 'append' })
const result = s.append('tool/result', {
turn: 0, step: 0, callId: CallId('c1'),
content: [{ type: 'text', text: 'exit 0' }],
isError: false,
}, { surfaceOp: 'append' })
const context = s.append('context/message', {
content: [{ type: 'text', text: 'file changed' }],
source: { kind: 'plugin', plugin: 'fs' },
}, { surfaceOp: 'append' })
const steering = s.append('steering/message', {
turn: 0,
content: [{ type: 'text', text: 'stop that' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(renderTranscript(s.events, [user.seq, assistant.seq, result.seq, context.seq, steering.seq])).toBe([
'User: fix the bug',
'Assistant: looking',
'Tool result (call c1): exit 0',
'[Context: file changed]',
'[Steering: stop that]',
].join('\n\n'))
})
it('labels an error tool result "Tool error"', () => {
const s = session()
const result = s.append('tool/result', {
turn: 0, step: 0, callId: CallId('c9'),
content: [{ type: 'text', text: 'boom' }],
isError: true,
}, { surfaceOp: 'append' })
expect(renderTranscript(s.events, [result.seq])).toBe('Tool error (call c9): boom')
})
it('renders NON-log-order seqs in the order given (surface order after a replace)', () => {
const s = session()
const first = s.append('user/message', {
content: [{ type: 'text', text: 'first' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const second = s.append('user/message', {
content: [{ type: 'text', text: 'second' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
expect(renderTranscript(s.events, [second.seq, first.seq])).toBe('User: second\n\nUser: first')
})
it('skips events that render to nothing, non-message events, and seqs with no event', () => {
const s = session()
const empty = s.append('user/message', {
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const emptyAssistant = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
turn: 0, step: 0,
content: [{ type: 'text', text: '' }],
}, { surfaceOp: 'append' })
const emptyResult = s.append('tool/result', {
turn: 0, step: 0, callId: CallId('c3'),
content: [{ type: 'text', text: '' }],
isError: false,
}, { surfaceOp: 'append' })
const emptyContext = s.append('context/message', {
content: [{ type: 'text', text: '' }],
source: { kind: 'plugin', plugin: 'fs' },
}, { surfaceOp: 'append' })
const emptySteering = s.append('steering/message', {
turn: 0,
content: [{ type: 'text', text: '' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
// A log-only (non-surface) event type: contributes nothing to a transcript.
const lock = s.append('compact/start', { turn: 0 })
expect(renderTranscript(s.events, [
empty.seq, emptyAssistant.seq, emptyResult.seq, emptyContext.seq, emptySteering.seq, lock.seq, 9999,
])).toBe('')
})
})