mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Reform the compaction blueprint so a runaway turn survives and the design
stops drifting across review rounds:
- Drop in-flight-turn protection ("layer 2"). Retention is a uniform tail→head
whole-unit walk; the only structural guard is step-alignment. A single turn
that alone exceeds the window now compacts its own early closed steps instead
of being retained verbatim (the failure mode that motivated this).
- Move auto-compaction off the agent/request waterfall onto a new awaited
agent/pre-request loop seam, fired before history derivation. Compaction
mutates the surface; the loop derives once from the result — no double-derive,
and a listener structurally cannot act on not-yet-derived messages.
- Tighten compactIfNeeded to required (session, system, model, signal).
- Enforce a single-pass convergence invariant in resolveConfig: reject configs
where summarizationMaxTokens + retainTokens exceeds the threshold, so a
compaction can never immediately re-trigger.
- Document the crash vs recoverable failure taxonomy; core session repair stays
compaction-agnostic (a log-only orphaned compact/start is inert).
- Wire dsh-compact-basic into examples/coding-agent and add a with-key
compaction e2e (compaction's first real-world exercise + runaway net).
- Rewrite the RFC to encode the blueprint and move it to implemented/.
The runaway-turn snapshot is a named deferred follow-up: dsh-llm-replay cannot
yet serve the interleaved summarization model call.
299 lines
16 KiB
TypeScript
299 lines
16 KiB
TypeScript
import { describe, expect, it } from 'vitest'
|
|
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
|
import { Session, SessionId, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
|
import { CallId } from '@deepseek-ai/dsh-llm'
|
|
|
|
/** Build a minimal session with turn boundaries and a single user message. */
|
|
function surfaceSession(): Session {
|
|
const s = new Session(SessionId('ss'))
|
|
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
|
|
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
return s
|
|
}
|
|
|
|
describe('SurfaceManager', () => {
|
|
it('rebuilds a linked list from surfaceOp: append markers', () => {
|
|
const s = surfaceSession()
|
|
const nodes = s.surface.nodes
|
|
// Only the user/message and assistant/message carry surfaceOp: 'append'.
|
|
// The turn boundaries do not have surface markers.
|
|
expect(nodes.length).toBe(2)
|
|
expect(nodes[0]!.seq).toBe(1) // user/message (turn/start is seq 0)
|
|
expect(nodes[0]!.prev).toBeNull()
|
|
expect(nodes[0]!.next).toBe(2) // assistant/message (seq 2)
|
|
expect(nodes[1]!.seq).toBe(2)
|
|
expect(nodes[1]!.prev).toBe(1)
|
|
expect(nodes[1]!.next).toBeNull()
|
|
})
|
|
|
|
it('invalidate resets to full rebuild', () => {
|
|
const s = surfaceSession()
|
|
expect(s.surface.nodes.length).toBe(2)
|
|
// After invalidate, the surface should rebuild from scratch on next access.
|
|
;(s.surface).invalidate()
|
|
expect(s.surface.nodes.length).toBe(2) // same result, but rebuilt
|
|
})
|
|
|
|
it('empty surface yields empty nodes', () => {
|
|
const s = new Session(SessionId('empty'))
|
|
// Only turn boundaries, no surface nodes.
|
|
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
s.append('step/start', { turn: 1, step: 1 })
|
|
s.append('step/end', { turn: 1, step: 1 })
|
|
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
expect(s.surface.nodes.length).toBe(0)
|
|
// deriveMessages returns empty array
|
|
expect(s.deriveMessages()).toEqual([])
|
|
})
|
|
|
|
it('picks up new events incrementally (delta processing)', () => {
|
|
const s = surfaceSession()
|
|
expect(s.surface.nodes.length).toBe(2)
|
|
// Append another surface node
|
|
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
|
expect(s.surface.nodes.length).toBe(3)
|
|
expect(s.surface.nodes[2]!.seq).toBe(4) // seq 4: after turn/end at seq 3
|
|
expect(s.surface.nodes[2]!.prev).toBe(2)
|
|
expect(s.surface.nodes[1]!.next).toBe(4)
|
|
})
|
|
|
|
it('replays identically from a seeded log with surface markers', () => {
|
|
const original = surfaceSession()
|
|
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
|
const replayed = new Session(SessionId('replay'), [...original.events])
|
|
// Surface rebuilds from the seeded log's markers.
|
|
expect(replayed.surface.nodes.map(n => n.seq)).toEqual([1, 2, 4])
|
|
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
|
})
|
|
|
|
it('rebuild with replace operation splices out shadowed nodes', () => {
|
|
const s = surfaceSession()
|
|
// seq: 0=turn/start, 1=user, 2=assistant, 3=turn/end
|
|
// Surface nodes: seq 1 (user), seq 2 (assistant).
|
|
// Replace both with a compaction marker. Both 1 and 2 are valid surface seqs.
|
|
s.append('assistant/message',
|
|
{ turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
|
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
|
|
)
|
|
// Now the surface should have just the compaction node.
|
|
expect(s.surface.nodes.length).toBe(1)
|
|
expect(s.surface.nodes[0]!.seq).toBe(4) // seq of the compaction marker
|
|
expect(s.surface.nodes[0]!.prev).toBeNull()
|
|
expect(s.surface.nodes[0]!.next).toBeNull()
|
|
})
|
|
|
|
it('replace with both ends at real nodes splices only the range', () => {
|
|
const s = new Session(SessionId('range'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
|
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
|
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
// Replace seq 0 through 1 inclusive: shadow a and b, keep c.
|
|
s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
|
|
{ surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
|
|
) // seq 3
|
|
expect(s.surface.nodes.map(n => n.seq)).toEqual([3, 2])
|
|
// Links: 3 ↔ 2
|
|
expect(s.surface.nodes[0]!.prev).toBeNull()
|
|
expect(s.surface.nodes[0]!.next).toBe(2)
|
|
expect(s.surface.nodes[1]!.prev).toBe(3)
|
|
expect(s.surface.nodes[1]!.next).toBeNull()
|
|
})
|
|
|
|
it('single-node replacement (start === end)', () => {
|
|
const s = new Session(SessionId('single'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
|
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
|
// Replace only seq 1 (single node).
|
|
s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
|
|
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
|
|
) // seq 2
|
|
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 2])
|
|
expect(s.surface.nodes[0]!.next).toBe(2)
|
|
expect(s.surface.nodes[1]!.prev).toBe(0)
|
|
})
|
|
|
|
it('throws when replace start is not found', () => {
|
|
const s = new Session(SessionId('bad-start'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
|
s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
|
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
|
|
)
|
|
expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
|
|
})
|
|
|
|
it('throws when replace end is not found', () => {
|
|
const s = new Session(SessionId('bad-end'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
|
s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
|
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
|
|
)
|
|
expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
|
|
})
|
|
|
|
it('throws when start is after end', () => {
|
|
const s = new Session(SessionId('reversed'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
|
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
|
// start=1, end=0 would be reversed order.
|
|
s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
|
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
|
|
)
|
|
expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
|
|
})
|
|
|
|
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
|
|
const s = new Session(SessionId('immutable'))
|
|
const sources = [10, 20]
|
|
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
|
|
// Mutate caller's array after append.
|
|
sources.push(30)
|
|
sources[0] = 99
|
|
const logged = s.events[0]! as SurfaceEvent
|
|
expect(logged.sourceEventSeqs).toEqual([10, 20])
|
|
})
|
|
|
|
it('replace starting at non-head position links to previous node correctly', () => {
|
|
const s = new Session(SessionId('mid-replace'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
|
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
|
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
|
|
// Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
|
|
s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
|
|
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
|
|
) // seq 3
|
|
expect(s.surface.nodes.map(n => n.seq)).toEqual([0, 3, 2])
|
|
// Links: 0 → 3 → 2
|
|
expect(s.surface.nodes[0]!.prev).toBeNull()
|
|
expect(s.surface.nodes[0]!.next).toBe(3)
|
|
expect(s.surface.nodes[1]!.prev).toBe(0)
|
|
expect(s.surface.nodes[1]!.next).toBe(2)
|
|
expect(s.surface.nodes[2]!.prev).toBe(3)
|
|
expect(s.surface.nodes[2]!.next).toBeNull()
|
|
})
|
|
|
|
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
|
|
const s = new Session(SessionId('immutable-op'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
const op = { op: 'replace' as const, start: 0, end: 0 }
|
|
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
|
|
// Mutate caller's object after append.
|
|
op.start = 99
|
|
const logged = s.events[1]! as SurfaceEvent
|
|
expect(logged.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
|
})
|
|
})
|
|
|
|
describe('deriveMessages with surface', () => {
|
|
it('uses the surface path when surface markers are present', () => {
|
|
const s = surfaceSession()
|
|
const messages = s.deriveMessages()
|
|
expect(messages).toHaveLength(2)
|
|
expect(messages[0]!.role).toBe('user')
|
|
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'hello' })
|
|
expect(messages[1]!.role).toBe('assistant')
|
|
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: 'hi' })
|
|
})
|
|
|
|
it('surface path skips non-surface events (chunks, boundaries)', () => {
|
|
const s = new Session(SessionId('filter'))
|
|
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
|
|
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
|
|
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
|
|
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
// Chunks and boundaries are NOT in the surface, so only 2 messages.
|
|
expect(s.deriveMessages()).toHaveLength(2)
|
|
})
|
|
|
|
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
|
|
const s = new Session(SessionId('compacted'))
|
|
s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
|
|
// Only the compaction node is visible.
|
|
const messages = s.deriveMessages()
|
|
expect(messages).toHaveLength(1)
|
|
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: 'compacted' })
|
|
})
|
|
|
|
it('context/message and steering/message appear on surface', () => {
|
|
const s = new Session(SessionId('ctx'))
|
|
s.append('context/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
|
|
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
const messages = s.deriveMessages()
|
|
expect(messages).toHaveLength(2)
|
|
expect(messages[0]!.content[0]).toMatchObject({ type: 'text', text: '<context source="plugin">' })
|
|
expect(messages[1]!.content[0]).toMatchObject({ type: 'text', text: '<steering source="user">' })
|
|
})
|
|
})
|
|
|
|
describe('Session.append surface opts', () => {
|
|
it('records sourceEventSeqs and surfaceOp on the event', () => {
|
|
const s = new Session(SessionId('opts'))
|
|
const event = s.append('assistant/message',
|
|
{ turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
|
|
{ surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
|
|
)
|
|
expect(event.sourceEventSeqs).toEqual([3, 5, 7])
|
|
expect(event.surfaceOp).toBe('append')
|
|
// The logged event matches the returned event.
|
|
expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
|
|
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
|
|
})
|
|
|
|
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
|
|
// An empty-content assistant/message is surface-eligible (it can host usage)
|
|
// but _deriveOneMessage returns null for it, so the surface derivation path's
|
|
// null-check is exercised — the node is on the surface yet produces no message.
|
|
const seed: SessionEvent[] = [
|
|
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
|
|
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [] }, surfaceOp: 'append' },
|
|
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
|
|
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
const s = new Session(SessionId('nomessage'), seed)
|
|
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
|
|
expect(s.deriveMessages()).toHaveLength(0)
|
|
})
|
|
|
|
it('a non-surface event carries no surface fields', () => {
|
|
const s = new Session(SessionId('noopts'))
|
|
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
expect((s.events[0] as SessionEvent<SurfaceEventType>).sourceEventSeqs).toBeUndefined()
|
|
expect((s.events[0] as SessionEvent<SurfaceEventType>).surfaceOp).toBeUndefined()
|
|
})
|
|
|
|
it('surfaceOp primitives are not cloned (they are immutable)', () => {
|
|
const s = new Session(SessionId('prim'))
|
|
const event = s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
|
|
// The string 'append' is a primitive — identity-preserving is fine.
|
|
expect(event.surfaceOp).toBe('append')
|
|
})
|
|
|
|
it('isSurfaceEvent rejects a surface-eligible type missing its surfaceOp marker', () => {
|
|
// A raw event (not built via append, which mandates the marker) of a
|
|
// surface-eligible type but with no surfaceOp must NOT narrow to a
|
|
// SurfaceEvent — it would otherwise be silently dropped from the surface.
|
|
const noMarker: SessionEvent = {
|
|
type: 'user/message', seq: 0, time: 1,
|
|
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
|
|
}
|
|
expect(isSurfaceEvent(noMarker)).toBe(false)
|
|
// A non-surface type is rejected too (the type gate).
|
|
const boundary: SessionEvent = { type: 'turn/start', seq: 1, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }
|
|
expect(isSurfaceEvent(boundary)).toBe(false)
|
|
// A properly-marked surface event narrows.
|
|
const marked = { ...noMarker, surfaceOp: 'append' } as SurfaceEvent
|
|
expect(isSurfaceEvent(marked)).toBe(true)
|
|
})
|
|
})
|