refactor: simplify session log representation

This commit is contained in:
Tianyi Cui
2026-07-13 23:56:10 +08:00
parent d060c0dd4f
commit 1123e946c0
64 changed files with 522 additions and 1032 deletions

View File

@@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session' {
* NOT a surface event, carries no `surfaceOp`): durable and replayable,
* never in the model transcript. The LAST such event is the session's
* override ({@link effectiveSandboxMode}); who asked for it is derivable
* from position (an event after the log's last `request/header*` was a
* from position (an event after the log's last `request/header` was a
* runtime switch by the user; see the tool layer's narrator).
*/
'bash/sandbox-mode': { mode: SandboxMode }

View File

@@ -455,11 +455,11 @@ export class BasicCompactService extends CompactService {
// position, so the surface order (head→tail) no longer tracks seq order —
// `[newSummarySeq, olderRetainedSeq, …]` is normal. Indexing into the
// ordered node list and slicing it is the only correct way to read a range;
// a `node.seq >= start && node.seq <= end` interval test would mis-collect
// a `seq >= start && seq <= end` interval test would mis-collect
// nodes (and `start > end` would falsely reject) once that happens.
const nodes = session.surface.nodes
const startIdx = nodes.findIndex(n => n.seq === start)
const endIdx = nodes.findIndex(n => n.seq === end)
const startIdx = nodes.indexOf(start)
const endIdx = nodes.indexOf(end)
if (startIdx === -1) throw new Error(`compactRegion: start seq ${start} not found in surface`)
if (endIdx === -1) throw new Error(`compactRegion: end seq ${end} not found in surface`)
if (startIdx > endIdx) {
@@ -480,8 +480,7 @@ export class BasicCompactService extends CompactService {
}
// The cut after `end` is named by `end`'s surface successor, or `null` when
// `end` is the tail.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const afterEnd: number | null = nodes[endIdx]!.next
const afterEnd = nodes[endIdx + 1] ?? null
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
@@ -503,7 +502,7 @@ export class BasicCompactService extends CompactService {
}
// Slice the ordered surface nodes [startIdx, endIdx] inclusive — the
// shadowed range is positional, so this is the set the replace op covers.
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1).map(n => n.seq)
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
// --- Acquire lock ---
const startEvent = session.append('compact/start', { turn: openTurn })
@@ -639,9 +638,9 @@ export class BasicCompactService extends CompactService {
let keepFromIdx = nodes.length // nothing retained yet
for (let i = nodes.length - 1; i >= 0; i--) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes[i]!
const event = events[node.seq]
/* v8 ignore next -- node.seq is a surface-node seq, always a valid log index by construction */
const seq = nodes[i]!
const event = events[seq]
/* v8 ignore next -- seq is a surface event sequence, always a valid log index by construction */
if (event) accumulated += this.estimateEventTokens(event)
keepFromIdx = i
if (accumulated >= retainBudget) break
@@ -660,16 +659,16 @@ export class BasicCompactService extends CompactService {
// step — retry once it closes).
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx === 0) return null
// The compacted range is [head … keepFromIdx - 1], anchored at the head.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const firstSeq = nodes[0]!.seq
const firstSeq = nodes[0]!
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const cutoffSeq = nodes[keepFromIdx - 1]!.seq
const cutoffSeq = nodes[keepFromIdx - 1]!
return { start: firstSeq, end: cutoffSeq }
}

View File

@@ -259,8 +259,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const svc = createTestService()
const session = toolTurnSession(1)
const nodes = session.surface.nodes // [user, asst(tool-call), result]
const userSeq = nodes[0]!.seq
const resultSeq = nodes[2]!.seq
const userSeq = nodes[0]!
const resultSeq = nodes[2]!
// start = the tool/result: its issuing assistant precedes it IN THE SAME STEP,
// so starting here would orphan that assistant's tool-call. end is fine (user).
await expect(compactRegion(svc, session, resultSeq, resultSeq, 'm'))
@@ -272,8 +272,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const svc = createTestService()
const session = toolTurnSession(1)
const nodes = session.surface.nodes
const userSeq = nodes[0]!.seq
const asstSeq = nodes[1]!.seq
const userSeq = nodes[0]!
const asstSeq = nodes[1]!
// end = the assistant/message: its tool/result follows IN THE SAME STEP, so
// ending here would strand that result. start is fine (the pre-step user).
await expect(compactRegion(svc, session, userSeq, asstSeq, 'm'))
@@ -291,8 +291,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, { surfaceOp: 'append' })
const nodes = s.surface.nodes // [user, asst]
const userSeq = nodes[0]!.seq
const asstSeq = nodes[1]!.seq
const userSeq = nodes[0]!
const asstSeq = nodes[1]!
await expect(compactRegion(svc, s, userSeq, asstSeq, 'm'))
.rejects.toThrow(/end seq .* is not a balanced boundary/)
})
@@ -301,8 +301,8 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const svc = createTestService()
const session = toolTurnSession(2)
const nodes = session.surface.nodes // [user1, asst1, res1, user2, asst2, res2]
const startSeq = nodes[0]!.seq // pre-step user1 (free boundary)
const endSeq = nodes[2]!.seq // res1 = last node of turn 1's closed step
const startSeq = nodes[0]! // pre-step user1 (free boundary)
const endSeq = nodes[2]! // res1 = last node of turn 1's closed step
const result = await compactRegion(svc, session, startSeq, endSeq, 'm')
expect(result.shadowedRange).toEqual({ start: startSeq, end: endSeq })
expectNoOrphanToolResults(session.deriveMessages())
@@ -312,7 +312,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
const svc = createTestService()
const session = toolTurnSession(1)
const nodes = session.surface.nodes
const userSeq = nodes[0]!.seq // pre-step user: free boundary both ways
const userSeq = nodes[0]! // pre-step user: free boundary both ways
const result = await compactRegion(svc, session, userSeq, userSeq, 'm')
expect(result.shadowedRange).toEqual({ start: userSeq, end: userSeq })
})
@@ -327,7 +327,7 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
const ctxSeq = nodes[0]!.seq
const ctxSeq = nodes[0]!
const result = await compactRegion(svc, s, ctxSeq, ctxSeq, 'm')
expect(result.shadowedRange).toEqual({ start: ctxSeq, end: ctxSeq })
})
@@ -386,8 +386,8 @@ describe('BasicCompactService.compactRegion', () => {
const nodes = session.surface.nodes
expect(nodes.length).toBe(6)
const firstSeq = nodes[0]!.seq
const secondSeq = nodes[1]!.seq
const firstSeq = nodes[0]!
const secondSeq = nodes[1]!
const result = await compactRegion(svc, session, firstSeq, secondSeq, 'test-model')
expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq])
@@ -427,7 +427,7 @@ describe('BasicCompactService.compactRegion', () => {
// Surface now has: summary user/message + retained 4 nodes = 5 nodes.
const newNodes = session.surface.nodes
expect(newNodes.length).toBe(5)
expect(newNodes[0]!.seq).toBe(userMsg.seq)
expect(newNodes[0]!).toBe(userMsg.seq)
// deriveMessages() produces the framed summary as a user-role message:
// a checkpoint preamble + tag-wrapped summary blocks.
@@ -452,7 +452,7 @@ describe('BasicCompactService.compactRegion', () => {
const svc = createTestService()
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
await expect(compactRegion(svc, session, nodes[1]!.seq, nodes[0]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[1]!, nodes[0]!, 'm'))
.rejects.toThrow(/is after end seq .* on the surface/)
})
@@ -461,7 +461,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
session.append('compact/start', { turn: 2 })
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm'))
.rejects.toThrow(/compaction already in progress/)
})
@@ -471,7 +471,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm'))
.rejects.toThrow('model unavailable')
const endEvent = session.events.findLast(e => e.type === 'compact/end')
@@ -494,7 +494,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(1, 2)
const nodes = session.surface.nodes
await compactRegion(svc, session, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, session, nodes[0]!, nodes[nodes.length - 1]!, 'm')
expect(svc.summarizeCalls.length).toBe(1)
const { text, model } = svc.summarizeCalls[0]!
@@ -509,7 +509,7 @@ describe('BasicCompactService.compactRegion', () => {
const session = multiTurnSession(3, 1)
const nodes = session.surface.nodes
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')
const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')
// Provenance (compact/summary) carries the RAW, unframed summary.
expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }])
@@ -529,8 +529,8 @@ describe('BasicCompactService.compactRegion', () => {
const session = sessionWithTools()
const nodes = session.surface.nodes
const firstSeq = nodes[0]!.seq
const lastSeq = nodes[nodes.length - 1]!.seq
const firstSeq = nodes[0]!
const lastSeq = nodes[nodes.length - 1]!
await compactRegion(svc, session, firstSeq, lastSeq, 'm')
expect(svc.summarizeCalls.length).toBe(1)
@@ -601,7 +601,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
expect(result).not.toBeNull()
const nodes = session.surface.nodes
expect(result!.shadowedSeqs.length).toBeGreaterThan(0)
expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!.seq)
expect(result!.shadowedSeqs).not.toContain(nodes[nodes.length - 1]!)
})
it('returns null when the whole surface fits the retain budget (over threshold by role/system overhead)', async () => {
@@ -653,7 +653,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
// The most-recent step's tool result is retained verbatim (still on surface).
const lastResultSeq = s.events.findLast(e => e.type === 'tool/result')!.seq
expect(result!.shadowedSeqs).not.toContain(lastResultSeq)
expect(s.surface.nodes.some(n => n.seq === lastResultSeq)).toBe(true)
expect(s.surface.nodes).toContain(lastResultSeq)
// No orphaned tool-result survives (whole-step boundaries respected).
expectNoOrphanToolResults(s.deriveMessages())
})
@@ -677,7 +677,7 @@ describe('BasicCompactService.compactIfNeeded', () => {
const first = await compactIfNeeded(svc, s, '', 'm', SIGNAL)
expect(first).not.toBeNull()
// The summary node now heads the surface with a fresh high seq.
const summaryHeadSeq = s.surface.nodes[0]!.seq
const summaryHeadSeq = s.surface.nodes[0]!
const turn5StartSeq = s.events.filter(e => e.type === 'turn/start').at(-1)!.seq
expect(summaryHeadSeq).toBeGreaterThan(turn5StartSeq)
@@ -745,7 +745,7 @@ describe('BasicCompactService replay equivalence', () => {
const session = multiTurnSession(3, 1)
const nodes = session.surface.nodes
await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')
await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')
const derived = session.deriveMessages()
const replayed = new Session(SessionId('replay'), [...session.events])
@@ -761,7 +761,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
const nodes = session.surface.nodes
// Whole step (user → assistant) is a step-aligned region, so the call reaches
// the in-progress check rather than being rejected for splitting a step.
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm'))
.rejects.toThrow(/compaction already in progress/)
})
@@ -771,7 +771,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
const nodes = session.surface.nodes
session.append('compact/start', { turn: 1 })
session.append('compact/end', { turn: 1 })
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')
const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')
expect(result).toBeDefined()
})
@@ -794,7 +794,7 @@ describe('BasicCompactService blocking (compaction in progress)', () => {
const nodes = s.surface.nodes
// The stale start is before the turn/end, so it is NOT seen as in-progress.
const result = await compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm')
const result = await compactRegion(svc, s, nodes[0]!, nodes[1]!, 'm')
expect(result).toBeDefined()
})
})
@@ -1112,7 +1112,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
const before = [...session.surface.nodes]
const nodes = session.surface.nodes
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model'))
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model'))
.rejects.toMatchObject({ code: 'MAX_TOKENS' })
// No replacement landed — the surface is byte-identical, and the lock was
@@ -1129,7 +1129,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model')
expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }])
// The raw summary is wrapped in the checkpoint framing on the surface.
expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' })
@@ -1141,7 +1141,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
const nodes = session.surface.nodes
svc.mockSummary = Array.from({ length: 20 }, (_, index) => ({ type: 'text', text: `large ${index}` }))
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm'))
.rejects.toThrow(/summary is not smaller than the shadowed content/)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
})
@@ -1160,7 +1160,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => {
const before = [...session.surface.nodes]
const nodes = session.surface.nodes
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm'))
.rejects.toThrow(/summary is not smaller than the shadowed content/)
expect(session.events.some(e => e.type === 'compact/summary')).toBe(false)
expect(session.surface.nodes).toEqual(before)
@@ -1321,7 +1321,7 @@ describe('BasicCompactService transcript rendering (delegated to dsh-compact)',
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm')
const { text } = svc.summarizeCalls[0]!
expect(text).toContain('[Context: project context here]')
@@ -1350,7 +1350,7 @@ describe('BasicCompactService transcript rendering (delegated to dsh-compact)',
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm')
expect(svc.summarizeCalls[0]!.text).toContain('Tool error (call c9): boom failure')
})
})
@@ -1384,7 +1384,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm')
const { text } = svc.summarizeCalls[0]!
expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content
expect(text).toContain('[custom-widget]') // unknown block placeholder
@@ -1432,7 +1432,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const nodes = s.surface.nodes
await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[1]!.seq, 'm'))
await expect(compactRegion(svc, s, nodes[0]!, nodes[1]!, 'm'))
.rejects.toThrow(/no open turn/)
// The lock was never acquired — no compact/start landed.
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
@@ -1447,7 +1447,7 @@ describe('BasicCompactService edge cases', () => {
s.append('user/message', { content: [{ type: 'text', text: 'orphan' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const nodes = s.surface.nodes
await expect(compactRegion(svc, s, nodes[0]!.seq, nodes[0]!.seq, 'm'))
await expect(compactRegion(svc, s, nodes[0]!, nodes[0]!, 'm'))
.rejects.toThrow(/no open turn/)
expect(s.events.some(e => e.type === 'compact/start')).toBe(false)
})
@@ -1464,7 +1464,7 @@ describe('BasicCompactService edge cases', () => {
const svc = createTestService()
const session = multiTurnSession(1, 1)
const nodes = session.surface.nodes
await expect(compactRegion(svc, session, nodes[0]!.seq, 9999, 'm'))
await expect(compactRegion(svc, session, nodes[0]!, 9999, 'm'))
.rejects.toThrow(/end seq 9999 not found in surface/)
})
@@ -1476,7 +1476,7 @@ describe('BasicCompactService edge cases', () => {
const nodes = session.surface.nodes
// Whole step (user → assistant): a step-aligned region that reaches summarize.
await expect(compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'm')).rejects.toBe('plain string failure')
await expect(compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm')).rejects.toBe('plain string failure')
const endEvent = session.events.findLast(e => e.type === 'compact/end')!
expect(endEvent.data).toMatchObject({ error: 'plain string failure' })
})
@@ -1542,7 +1542,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm')
// Every empty-content message (user text, empty reasoning, empty-content
// tool/result, empty context, empty steering) extracted to nothing and was
// skipped — the only surviving line is the assistant's tool-call (which a
@@ -1580,7 +1580,7 @@ describe('BasicCompactService edge cases', () => {
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
await compactRegion(svc, s, nodes[0]!, nodes[nodes.length - 1]!, 'm')
const { text } = svc.summarizeCalls[0]!
// Every non-text block surfaces as a placeholder rather than being dropped.
expect(text).toContain('User: [chart]')
@@ -1604,32 +1604,32 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
// First compaction: shadow the two oldest surface nodes.
const nodes0 = session.surface.nodes
const first = await compactRegion(svc, session, nodes0[0]!.seq, nodes0[1]!.seq, 'm')
const first = await compactRegion(svc, session, nodes0[0]!, nodes0[1]!, 'm')
// The summary node now sits at the head with a seq HIGHER than the
// retained older nodes that follow it — the non-monotonic surface. (The
// head is the user/message replace node, appended after the compact/summary
// provenance event, so its seq is at least first.summarySeq.)
const nodes1 = session.surface.nodes
expect(nodes1[0]!.seq).toBeGreaterThanOrEqual(first.summarySeq)
expect(nodes1[0]!.seq).toBeGreaterThan(nodes1[1]!.seq)
expect(nodes1[0]!).toBeGreaterThanOrEqual(first.summarySeq)
expect(nodes1[0]!).toBeGreaterThan(nodes1[1]!)
// Second compaction: shadow [summary(head) … turn-2's step end]. The start
// seq (the head summary node) is GREATER than the end seq (an older retained
// node), so the range is a SURFACE-POSITION span, not a numeric seq interval.
// The end must land on a step boundary (turn-2's assistant message closes
// its step).
const startSeq = nodes1[0]!.seq
const endSeq = nodes1[2]!.seq
const startSeq = nodes1[0]!
const endSeq = nodes1[2]!
expect(startSeq).toBeGreaterThan(endSeq)
const second = await compactRegion(svc, session, startSeq, endSeq, 'm')
// Exactly the three nodes at surface positions [0..2] are shadowed, in
// surface order — the positional slice, regardless of their seq values.
expect(second.shadowedSeqs).toEqual([nodes1[0]!.seq, nodes1[1]!.seq, nodes1[2]!.seq])
expect(second.shadowedSeqs).toEqual([nodes1[0]!, nodes1[1]!, nodes1[2]!])
// The surface still derives cleanly: a new head replace node + the rest.
const finalNodes = session.surface.nodes
expect(finalNodes[0]!.seq).toBeGreaterThanOrEqual(second.summarySeq)
expect(finalNodes[0]!).toBeGreaterThanOrEqual(second.summarySeq)
expect(session.deriveMessages().length).toBe(finalNodes.length)
})
@@ -1640,14 +1640,14 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a
// First compaction shadows the oldest two surface nodes, landing a high-seq
// summary node at the head.
const n0 = session.surface.nodes
await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'm')
await compactRegion(svc, session, n0[0]!, n0[1]!, 'm')
// Second compaction spans [head summary … turn-2's step end]. The head's seq
// is higher than the older retained nodes' seqs, so a log-seq-order walk
// would emit the older messages BEFORE the checkpoint.
const n1 = session.surface.nodes
svc.summarizeCalls = []
await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'm')
await compactRegion(svc, session, n1[0]!, n1[2]!, 'm')
// The extracted transcript follows surface order: the checkpoint (head)
// first, then the older retained messages — matching deriveMessages().
@@ -1679,7 +1679,7 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => {
const svc = ctx.compact as BasicCompactService
const session = multiTurnSession(2, 1)
const nodes = session.surface.nodes
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model')
expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }])
// Tear the fiber down so this test owns no leaked registration; the
@@ -1727,9 +1727,9 @@ describe('BasicCompactService under the real invariants plugin', () => {
const nodes = session.surface.nodes
// No invariant throws here: compact/* + the replacement are all in turn 3.
const result = await compactRegion(svc, session, nodes[0]!.seq, nodes[1]!.seq, 'test-model')
const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model')
expect(result.shadowedSeqs.length).toBe(2)
expect(session.surface.nodes[0]!.seq).toBeGreaterThan(session.surface.nodes[1]!.seq)
expect(session.surface.nodes[0]!).toBeGreaterThan(session.surface.nodes[1]!)
})
it('accepts a second compaction over the non-monotonic surface left by the first', async () => {
@@ -1740,14 +1740,14 @@ describe('BasicCompactService under the real invariants plugin', () => {
session.append('turn/start', { turn: 4, trigger: { kind: 'message', source: { kind: 'user' } } })
const n0 = session.surface.nodes
await compactRegion(svc, session, n0[0]!.seq, n0[1]!.seq, 'test-model')
await compactRegion(svc, session, n0[0]!, n0[1]!, 'test-model')
// Surface head now carries a higher seq than the older retained nodes. A
// second compaction spanning [head … a later closed-step end] must pass the
// invariants' positional replace check even though startSeq > endSeq.
const n1 = session.surface.nodes
expect(n1[0]!.seq).toBeGreaterThan(n1[2]!.seq)
const second = await compactRegion(svc, session, n1[0]!.seq, n1[2]!.seq, 'test-model')
expect(second.shadowedSeqs).toEqual([n1[0]!.seq, n1[1]!.seq, n1[2]!.seq])
expect(n1[0]!).toBeGreaterThan(n1[2]!)
const second = await compactRegion(svc, session, n1[0]!, n1[2]!, 'test-model')
expect(second.shadowedSeqs).toEqual([n1[0]!, n1[1]!, n1[2]!])
})
})

View File

@@ -142,12 +142,12 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
// scan reached the neighbouring step's assistant/message.
const nodes = agent.session.surface.nodes
for (const cp of checkpoints) {
const node = nodes.find(n => n.seq === cp.seq)
if (!node) continue // shadowed by a later checkpoint — no longer an edge.
expect(isToolPairingBalanced(nodes, events, node.seq),
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
expect(isToolPairingBalanced(nodes, events, node.next),
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
const index = nodes.indexOf(cp.seq)
if (index === -1) continue // shadowed by a later checkpoint — no longer an edge.
expect(isToolPairingBalanced(nodes, events, cp.seq),
`checkpoint seq ${cp.seq} must be a balanced region START`).toBe(true)
expect(isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null),
`checkpoint seq ${cp.seq} must be a balanced region END`).toBe(true)
}
} finally {
await ctx.fiber.dispose()

View File

@@ -170,8 +170,8 @@ export interface LoopHandle {
* boundary = session.deriveMessages() ⟵ the reconstruction boundary: snapshot in the
* session('step/start') same sync frame, strictly before step/start
* config = waterfall agent/request(config) ⟵ frozen seed; a returned replacement switches
* session('request/header'|'request/header-delta') ⟵ the header event this request owes the
* log (initial/resume anchor, delta, fallback)
* session('request/header') ⟵ the header event this request owes the
* log (initial/resume anchor or changed snapshot)
* req = freeze({header..., messages: prefix+boundary, sessionId, signal})
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks, frozen req)
* session('assistant/chunk')
@@ -774,7 +774,7 @@ async function runStep(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- runTurn composes the prefix before every runStep call
const sessionPrefix = transmission.sessionPrefix!
// The request header (the log's request/header* vocabulary): canonical form,
// The request header (the log's request/header snapshots): canonical form,
// recorded before dispatch so the log always explains the request —
// including the session prefix, which no other event carries.
const header = canonicalHeader({

View File

@@ -5,12 +5,12 @@
* otherwise transmission-stateless — the comparison baseline is the log's own
* folded header (`Session.requestHeader()`), so resume and fork need no
* special path: a fresh loop instance simply logs a `'resume'` snapshot on
* its first request and deltas from there.
* its first request and full changed-header snapshots from there.
*
* @module dsh-agent-loop/request-log
*/
import { diffHeader, headerEquals, applyHeaderDelta } from '@deepseek-ai/dsh-session'
import { headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, Session } from '@deepseek-ai/dsh-session'
import type { Message } from '@deepseek-ai/dsh-llm'
@@ -38,7 +38,7 @@ export function createTransmissionLog(): TransmissionLog {
/**
* Append whatever header event this request owes the log, so folding the log
* reproduces the header the request was built under. Exactly one of four
* reproduces the header the request was built under. Exactly one of three
* things happens:
*
* 1. This loop instance has not logged a header yet → a full `request/header`
@@ -48,11 +48,7 @@ export function createTransmissionLog(): TransmissionLog {
* snapshot is appended even when nothing changed).
* 2. The header equals the folded baseline → nothing; the log already
* explains this request.
* 3. It differs and the delta round-trips (`applyHeaderDelta` on the baseline
* reproduces the header exactly) → a `request/header-delta`.
* 4. It differs and the delta encoding cannot express the change (a pure tool
* reordering) → a full snapshot with reason `'fallback'`; deltas are an
* encoding optimization, never a correctness dependency.
* 3. It differs → a full snapshot with reason `'change'`.
*
* @param session - the session whose log explains the request.
* @param state - this loop instance's bookkeeping (mutated on first log).
@@ -69,12 +65,5 @@ export function recordRequestHeader(session: Session, state: TransmissionLog, he
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const baseline = session.requestHeader()!
if (headerEquals(baseline, header)) return
const delta = diffHeader(baseline, header)
/* v8 ignore next -- headerEquals false ⟹ diffHeader defined: both compare the same four parts */
if (delta === undefined) return
if (headerEquals(applyHeaderDelta(baseline, delta), header)) {
session.append('request/header-delta', delta)
} else {
session.append('request/header', { header, reason: 'fallback' })
}
session.append('request/header', { header, reason: 'change' })
}

View File

@@ -374,8 +374,8 @@ describe('agent/session-prefix', () => {
expect(request.messages[0]).toEqual(reminder)
}
// The anchoring snapshot is the prefix's durable record — and the ONLY
// header event: reuse means no request/header-delta ever.
const headerEvents = events(agent).filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
// header event: reuse means no changed snapshot ever.
const headerEvents = events(agent).filter(e => e.type === 'request/header')
expect(headerEvents).toHaveLength(1)
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.header.messagePrefix).toEqual([reminder])
// Never session history: the derivation starts at the real user prompt.
@@ -491,7 +491,7 @@ describe('agent/session-prefix', () => {
// cached prefix is a deep-frozen clone, so step 2's request is unchanged.
held.content = [{ type: 'text', text: 'v2' }]
expect(adapter.requests[1]!.messages[0]).toEqual({ role: 'user', content: [{ type: 'text', text: 'v1' }] })
expect(events(agent).filter(e => e.type === 'request/header-delta')).toHaveLength(0)
expect(events(agent).filter(e => e.type === 'request/header')).toHaveLength(1)
})
})

View File

@@ -1,9 +1,8 @@
/**
* recordRequestHeader unit tests: exactly one of four things per request —
* recordRequestHeader unit tests: exactly one of three things per request —
* an 'initial' snapshot (log has no header yet), a 'resume' snapshot (fresh
* loop instance over a log that has one), nothing (header unchanged), a
* round-tripping delta, or a 'fallback' snapshot when the delta encoding
* cannot express the change (pure tool reordering).
* loop instance over a log that has one), nothing (header unchanged), or a
* full 'change' snapshot.
*/
import { describe, expect, it } from 'vitest'
@@ -23,7 +22,7 @@ function openSession(id: string): Session {
}
function headerEvents(session: Session): SessionEvent[] {
return session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
return session.events.filter(e => e.type === 'request/header')
}
describe('recordRequestHeader', () => {
@@ -53,8 +52,8 @@ describe('recordRequestHeader', () => {
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('resume')
})
it('logs a round-tripping delta for a mid-run change, and the fold reproduces the header', () => {
const session = openSession('rl-delta')
it("logs a full 'change' snapshot for a mid-run change, and the fold reproduces the header", () => {
const session = openSession('rl-change')
const state = createTransmissionLog()
const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] })
recordRequestHeader(session, state, first)
@@ -63,12 +62,12 @@ describe('recordRequestHeader', () => {
recordRequestHeader(session, state, second)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type).toBe('request/header-delta')
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change')
expect(session.requestHeader()).toEqual(second)
})
it("records a change the delta cannot express (pure reordering) as a 'fallback' snapshot", () => {
const session = openSession('rl-fallback')
it("records a pure tool reordering as a 'change' snapshot", () => {
const session = openSession('rl-reorder')
const state = createTransmissionLog()
const first = canonicalHeader({ config: { model: 'm' }, tools: [tool('a'), tool('b')] })
recordRequestHeader(session, state, first)
@@ -77,9 +76,7 @@ describe('recordRequestHeader', () => {
recordRequestHeader(session, state, reordered)
const events = headerEvents(session)
expect(events).toHaveLength(2)
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('fallback')
// The fold still lands on the exact header — deltas are an encoding
// optimization, never a correctness dependency.
expect(events[1]?.type === 'request/header' && events[1].data.reason).toBe('change')
expect(session.requestHeader()).toEqual(reordered)
})
})

View File

@@ -1,7 +1,7 @@
/**
* Loop-level reconstructability: every request the loop sends is a pure
* function of the session log — messages are the derivation at the step/start
* boundary, the header is the fold of request/header* events — and every
* boundary, the header is the latest request/header snapshot — and every
* request is an append-extension of its predecessor unless a logged event
* (compaction replace, header change) explains the difference. The requests
* recorded by the mock adapter are the observable; the offline-rebuild test
@@ -87,7 +87,7 @@ describe('request stability across the loop', () => {
expect(Object.isFrozen(request.messages)).toBe(true)
}
// One anchoring header snapshot; no further header events (nothing changed).
const headerEvents = agent.session.events.filter(e => e.type === 'request/header' || e.type === 'request/header-delta')
const headerEvents = agent.session.events.filter(e => e.type === 'request/header')
expect(headerEvents).toHaveLength(1)
expect(headerEvents[0]?.type === 'request/header' && headerEvents[0].data.reason).toBe('initial')
})
@@ -124,8 +124,8 @@ describe('request stability across the loop', () => {
content: [{ type: 'text', text: '[summary of turn 1]' }],
source: { kind: 'plugin', plugin: 'test-compact' },
}, {
surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq },
sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq],
surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! },
sourceEventSeqs: [nodes[0]!, nodes[1]!],
})
})
@@ -139,7 +139,7 @@ describe('request stability across the loop', () => {
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
})
it('a real system-prompt change is a logged header delta; a stable prompt logs nothing', async () => {
it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -149,14 +149,15 @@ describe('request stability across the loop', () => {
send(agent, 'second')
await waitForIdle(ctx, agent)
// Identical assembly re-rendered per step is NOT a change.
expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0)
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
ctx.systemPrompt.section({ name: 'extra', order: 2, text: 'new guidance' })
send(agent, 'third')
await waitForIdle(ctx, agent)
const deltas = agent.session.events.filter(e => e.type === 'request/header-delta')
expect(deltas).toHaveLength(1)
const snapshots = agent.session.events.filter(e => e.type === 'request/header')
expect(snapshots).toHaveLength(2)
expect(snapshots[1]?.data.reason).toBe('change')
expect(adapter.requests[2]!.system).toContain('new guidance')
// History is preserved across the change — only the header moved.
expect(adapter.requests[2]!.messages.length).toBeGreaterThan(adapter.requests[1]!.messages.length)
@@ -262,9 +263,9 @@ describe('request stability across the loop', () => {
send(agent, 'second')
await waitForIdle(ctx, agent)
// No delta was logged (nothing really changed), and the session's own
// No changed snapshot was logged (nothing really changed), and the session's own
// fold is immutable state.
expect(agent.session.events.filter(e => e.type === 'request/header-delta')).toHaveLength(0)
expect(agent.session.events.filter(e => e.type === 'request/header')).toHaveLength(1)
expect(Object.isFrozen(agent.session.requestHeader())).toBe(true)
expect(adapter.requests[1]!.temperature).toBeUndefined()
})
@@ -298,7 +299,7 @@ describe('request stability across the loop', () => {
const rebuilt = new Session(SessionId(`rebuild-${index}`), structuredClone(events.slice(0, stepStart.seq)))
expect(structuredClone(request.messages)).toEqual(rebuilt.deriveMessages())
// Header: the fold of request/header* events up to this step's dispatch
// Header: the latest request/header snapshot up to this step's dispatch
// (its header event sits between step/start and the first chunk).
const firstChunk = events.find(e => e.type === 'assistant/chunk' && e.seq > stepStart.seq)!
const header = foldRequestHeader(events.slice(0, firstChunk.seq))!

View File

@@ -464,7 +464,7 @@ declare module 'cordis' {
* `additionalContext`, prompt sections via `system-prompt/assemble`, or
* the header-logged session prefix via {@link agent/session-prefix}
* — never through request mutation, and the loop records whatever config
* the request actually uses as a `request/header*` event before dispatch.
* the request actually uses as a `request/header` event before dispatch.
* The step's messages are already snapshotted when this fires (the
* `step/start` boundary): an `inject()` from a listener here lands in the
* log but joins the NEXT request. For surface mutation that must precede

View File

@@ -1,6 +1,6 @@
# dsh-session
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (a linked list of message-producing events) is maintained on top of the raw log for efficient derivation and compaction.
Event-sourced session log and in-memory store. A `Session` is the append-only source of truth for an agent's whole interaction history — the LLM message history is *derived* from it. A **surface** layer (an ordered sequence of message-producing event seqs) is maintained on top of the raw log for efficient derivation and compaction.
## Service: `SessionStore` (ctx key: `sessions`)
@@ -33,7 +33,7 @@ The store pairs announced creation with disposal, publishes each append, and pro
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.append(type, data, opts?): SessionEvent` — synchronous, never blocks on I/O. At this durable boundary, data and surface metadata are lossless-JSON snapshotted and deep-frozen. For an attached session, a reentrant append during dispatch/observer publication rejects, and detach waits for that publication to unwind. Callbacks resolve before the log push; the push is the commit point, after which each observer failure is contained independently. Runtime surface validation covers widened unions and raw seed/load logs.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface node is projected exactly once, when first seen (O(new nodes) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveMessages(): Message[]` — the LLM message history, CACHED: each surface entry is projected exactly once, when first seen (O(new entries) per call; a surface rewrite rebuilds via `surface.replaceGeneration`). Returns a fresh array per call over shared, deep-frozen `Message` objects. Each projection reuses the already deep-frozen content in its durable log event, so no second deep clone is needed and a consumer still cannot mutate logged data. The surface is the single source of derived history — there is no raw-log fallback.
- `session.deriveEventMessage(event): Message | null` — the per-event projection `deriveMessages()` folds: a fresh message wrapper that reuses the event's already frozen content, or `null` when the event produces none (a non-surface event, or an empty-content `assistant/message` hosting only usage). External reconstructors and the dev invariant fold the same function over a log prefix's surface, so no two paths can disagree about what a request's messages were (the reconstructability RFC).
- `session.surface: SurfaceManager` — the derived surface, lazily folded from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change. `surface.replaceGeneration` is the rewrite signal: bumped by every folded `replace` and never reset, so an incremental consumer comparing generations cannot be fooled.
- `session.events` — a cached, frozen array snapshot over deep-frozen events. Repeated reads without an append return the same array; an append invalidates the cache and the next read returns a new snapshot, while earlier snapshots stay unchanged. Neither a cast nor a retained reference can push into the live log or rewrite an accepted event.
@@ -46,14 +46,13 @@ Durable values need one accepted representation, not a check followed by a secon
### Surface types
- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
- `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them.
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
- `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
### Request-header reconstruction (`request-header.ts`)
The `request/header` (full `EpochHeader` snapshot with a `RequestHeaderReason`) and `request/header-delta` (system line-trim / name-keyed tools delta / whole config / whole session prefix) events make the request envelope logged session state, so every conversation request is a pure function of the log. The pure trio reconstructs it: `foldRequestHeader(events)` folds a log (or any prefix) into the header in force; `diffHeader(prev, next)` encodes a change (undefined when equal); `applyHeaderDelta(prev, delta)` replays one. Writer contract: every logged delta is round-trip-verified (`apply(prev, delta)` deep-equals the new header) with a `'fallback'` snapshot when the encoding cannot express the change (a pure tool reordering), so folding never needs error recovery on a well-formed log. `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields; a delta's EMPTY prefix array encodes the transition back to absence). `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it.
The `request/header` event records a full canonical `EpochHeader` snapshot with reason `initial`, `resume`, or `change`, making the request envelope logged session state and every conversation request a pure function of the log. `foldRequestHeader(events)` selects the latest snapshot from a log or prefix; `canonicalHeader` pins the one representation of absence (empty system/tools/messagePrefix ≡ absent fields), and `headerEquals` compares canonical headers. `EpochHeader.messagePrefix` is the durable record of the `agent/session-prefix` waterfall's product — composed once per loop instance, the request is `messagePrefix + derived history`, and `deriveMessages()` never returns it. Legacy v0 seeds containing the removed `request/header-delta` format are rejected rather than partially replayed.
### Session event vocabulary (`types.ts`)

View File

@@ -22,10 +22,9 @@ export * from './types.ts'
export { isJsonValue, snapshotJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isToolPairingBalanced } from './tool-pairing.ts'
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
declare module 'cordis' {
interface Context {
@@ -197,6 +196,9 @@ function assertSurfaceMetadataShape(
/** Validate the fixed event envelope after one-pass JSON materialization. */
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
const event = value
if (event['type'] === 'request/header-delta') {
throw new Error(`seed event at index ${index} uses unsupported legacy request/header-delta format`)
}
const allowed = new Set(['type', 'seq', 'time', 'data', 'surfaceOp', 'sourceEventSeqs'])
if (Object.keys(event).some(key => !allowed.has(key))
|| !Object.hasOwn(event, 'type') || typeof event['type'] !== 'string'
@@ -262,7 +264,7 @@ export class Session {
private log: SessionEvent[] = []
/**
* Derived surface — a cached linked list of message-producing events.
* Derived surface — a cached order of message-producing event sequences.
* Lazily rebuilt from `surfaceOp` markers in the log; processes only new
* events (delta) on each access — the log is append-only, so prior events
* never change.
@@ -270,7 +272,7 @@ export class Session {
*/
private _surface: SurfaceManager | undefined
/** The surface linked list over this session's event log. */
/** The ordered surface over this session's event log. */
get surface(): SurfaceManager {
if (!this._surface) this._surface = new SurfaceManager(this.log)
return this._surface
@@ -354,7 +356,7 @@ export class Session {
* @param type - The event type (key of {@link SessionEventMap}).
* @param data - The event payload; must be JSON-serializable.
* @param opts - Surface metadata: `surfaceOp` controls how the event enters
* the surface linked list; `sourceEventSeqs` records provenance (the seq
* the ordered surface; `sourceEventSeqs` records provenance (the seq
* numbers of events this one derives from). REQUIRED for
* {@link SurfaceEventType} events (every message-producing event must
* declare how it joins the surface, the sole source of derived history) and
@@ -463,8 +465,8 @@ export class Session {
private derivedGeneration = 0
/**
* Derive the LLM message history by walking the session surface — the linked
* list of message-producing events maintained by `surfaceOp` markers. The
* Derive the LLM message history by walking the ordered sequences of
* message-producing events maintained by `surfaceOp` markers. The
* surface is the single source of derived history: every message-producing
* append records its `surfaceOp`, so a raw event with no marker (a chunk, a
* turn boundary) is correctly absent, and a compaction `replace` deletes the
@@ -488,11 +490,11 @@ export class Session {
this.derivedNodes = 0
this.derivedGeneration = generation
}
for (const node of nodes.slice(this.derivedNodes)) {
// Surface nodes are built from this.log — node.seq is always a valid
for (const seq of nodes.slice(this.derivedNodes)) {
// Surface sequences are built from this.log — seq is always a valid
// index by construction. The non-null assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const msg = this.deriveEventMessage(this.log[node.seq]!)
const msg = this.deriveEventMessage(this.log[seq]!)
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
// usage) derives to null and must not enter the transcript.

View File

@@ -1,35 +1,20 @@
/**
* Request-header reconstruction utilities: the pure fold/diff/apply trio over
* the `request/header` / `request/header-delta` session events. Anyone
* holding a session log reconstructs the {@link EpochHeader} any request was
* built under by folding these events in log order; the loop uses the same
* functions to decide whether a step's header changed and to encode the
* change. Deltas are an encoding optimization with a safety valve — the
* writer round-trip-verifies every delta before appending and falls back to
* a full snapshot when the encoding cannot express the change — so folding
* never needs error recovery on a well-formed log.
* Request-header reconstruction utilities over full `request/header` session
* events. Anyone holding a session log reconstructs the {@link EpochHeader}
* any request was built under by taking the latest canonical snapshot; the
* loop uses the same equality helper to avoid logging unchanged headers.
*
* @module dsh-session/request-header
*/
import { callConfigEquals } from '@deepseek-ai/dsh-llm'
import type { LlmCallConfig, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, SessionEvent, SystemDelta, ToolsDelta } from './types.ts'
/** The `request/header-delta` payload shape: each present field amends the folded header. */
type HeaderDelta = {
system?: SystemDelta
tools?: ToolsDelta
config?: LlmCallConfig
messagePrefix?: Message[]
}
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { EpochHeader, SessionEvent } from './types.ts'
/**
* Normalize a header to canonical form: an empty system prompt, an empty
* tool list, and an empty session prefix become ABSENT fields, matching how
* requests are built (the request-build spreads skip empty values). Diff,
* fold, and comparison all operate on canonical headers, so "no system
* prompt" (and "no session prefix") has exactly one representation.
* Normalize a header to canonical form: an empty system prompt, an empty tool
* list, and an empty session prefix become absent fields, matching how requests
* are built. Logging, folding, and comparison use this one representation.
* @param header - the header to normalize (not mutated).
* @returns the canonical header.
*/
@@ -42,88 +27,22 @@ export function canonicalHeader(header: EpochHeader): EpochHeader {
}
}
/** Split a canonical (possibly absent) system prompt into lines; absence is zero lines. */
function systemLines(system: string | undefined): string[] {
return system === undefined ? [] : system.split('\n')
}
/** Join lines back into a canonical system value; zero lines is absence. */
function joinSystem(lines: string[]): string | undefined {
return lines.length === 0 ? undefined : lines.join('\n')
}
/**
* Compute the line-level {@link SystemDelta} between two canonical system
* prompts: trim the common prefix and (non-overlapping) common suffix, and
* carry the replacement lines between them. Deterministic and library-free;
* with nothing shared it degenerates to a full replacement.
*/
function diffSystem(prev: string | undefined, next: string | undefined): SystemDelta {
const a = systemLines(prev)
const b = systemLines(next)
let keepStart = 0
while (keepStart < a.length && keepStart < b.length && a[keepStart] === b[keepStart]) keepStart += 1
let keepEnd = 0
while (
keepEnd < a.length - keepStart &&
keepEnd < b.length - keepStart &&
a[a.length - 1 - keepEnd] === b[b.length - 1 - keepEnd]
) keepEnd += 1
return { keepStart, keepEnd, insert: b.slice(keepStart, b.length - keepEnd) }
}
/** Apply a {@link SystemDelta} to a canonical system prompt. */
function applySystem(prev: string | undefined, delta: SystemDelta): string | undefined {
const a = systemLines(prev)
return joinSystem([...a.slice(0, delta.keepStart), ...delta.insert, ...a.slice(a.length - delta.keepEnd)])
}
/** Canonical JSON equality for tool schemas — sound because schemas are
* JSON-serializable by construction and both sides come from the same
* assembly path, so key insertion order matches when the values do. */
/** Canonical JSON equality for tool schemas assembled through the same path. */
function sameSchema(a: ToolSchema, b: ToolSchema): boolean {
return JSON.stringify(a) === JSON.stringify(b)
}
/**
* Compute the name-keyed {@link ToolsDelta} between two canonical tool lists.
* A pure reordering produces an empty delta — the writer's round-trip guard
* catches that case and records a snapshot instead.
*/
function diffTools(prev: readonly ToolSchema[], next: readonly ToolSchema[]): ToolsDelta {
const prevByName = new Map(prev.map(tool => [tool.name, tool]))
const nextNames = new Set(next.map(tool => tool.name))
return {
added: next.filter(tool => !prevByName.has(tool.name)),
removed: prev.filter(tool => !nextNames.has(tool.name)).map(tool => tool.name),
changed: next.filter((tool) => {
const before = prevByName.get(tool.name)
return before !== undefined && !sameSchema(before, tool)
}),
}
}
/** Apply a {@link ToolsDelta} to a canonical tool list: drop removed, replace changed in place, append added. */
function applyTools(prev: readonly ToolSchema[], delta: ToolsDelta): ToolSchema[] {
const removed = new Set(delta.removed)
const changedByName = new Map(delta.changed.map(tool => [tool.name, tool]))
const kept = prev
.filter(tool => !removed.has(tool.name))
.map(tool => changedByName.get(tool.name) ?? tool)
return [...kept, ...delta.added]
/** Canonical JSON equality over session-prefix arrays; absence equals empty. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
/**
* Field-wise equality over canonical headers — the cheap comparison the
* writer's round-trip guard runs (`applyHeaderDelta(prev, delta)` must equal
* the intended header) and the loop runs to skip logging an unchanged header.
* Tools compare per-schema IN ORDER (canonical JSON), so a pure reordering is
* correctly unequal; the session prefix compares as canonical JSON (both
* sides come from the same build path, so key order matches when the values
* do).
* Field-wise equality over canonical headers. Tool schemas compare in order;
* the session prefix compares as canonical JSON.
* @param a - one canonical header.
* @param b - the other.
* @returns whether config, system, tools (in order), and the session prefix all match.
* @returns whether config, system, tools, and session prefix all match.
*/
export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
if (!callConfigEquals(a.config, b.config) || a.system !== b.system) return false
@@ -133,77 +52,19 @@ export function headerEquals(a: EpochHeader, b: EpochHeader): boolean {
return at.length === bt.length && at.every((tool, i) => sameSchema(tool, bt[i] as ToolSchema))
}
/** Canonical JSON equality over session-prefix arrays; absence equals the empty array. */
function sameMessages(a: readonly Message[] | undefined, b: readonly Message[] | undefined): boolean {
return JSON.stringify(a ?? []) === JSON.stringify(b ?? [])
}
/**
* Compute the `request/header-delta` payload between two canonical headers,
* or undefined when they are equal. The caller MUST round-trip the result
* ({@link applyHeaderDelta} on `prev` deep-equals `next`) before logging it —
* the encoding cannot express every change (a pure tool reordering) — and
* fall back to a full `request/header` snapshot when the check fails.
* The session prefix is replaced whole (small advisory content, not worth
* diffing); an empty replacement array encodes the transition to "none".
* @param prev - the folded header the log currently implies.
* @param next - the header the next request will actually use.
* @returns the delta payload, or undefined when nothing changed.
*/
export function diffHeader(prev: EpochHeader, next: EpochHeader): HeaderDelta | undefined {
const delta: HeaderDelta = {}
if (prev.system !== next.system) delta.system = diffSystem(prev.system, next.system)
const prevTools = prev.tools ?? []
const nextTools = next.tools ?? []
if (JSON.stringify(prevTools) !== JSON.stringify(nextTools)) delta.tools = diffTools(prevTools, nextTools)
if (!callConfigEquals(prev.config, next.config)) delta.config = next.config
if (!sameMessages(prev.messagePrefix, next.messagePrefix)) delta.messagePrefix = next.messagePrefix ?? []
return Object.keys(delta).length > 0 ? delta : undefined
}
/**
* Apply a `request/header-delta` payload to a canonical header, producing the
* canonical header it encodes. Total for well-formed logs (the writer only
* appends round-trip-verified deltas).
* @param prev - the folded header before the delta.
* @param delta - the logged delta payload.
* @returns the canonical header after the delta.
*/
export function applyHeaderDelta(prev: EpochHeader, delta: HeaderDelta): EpochHeader {
const system = delta.system !== undefined ? applySystem(prev.system, delta.system) : prev.system
const tools = delta.tools !== undefined ? applyTools(prev.tools ?? [], delta.tools) : prev.tools
const messagePrefix = delta.messagePrefix ?? prev.messagePrefix
return canonicalHeader({
config: delta.config ?? prev.config,
...system !== undefined ? { system } : {},
...tools !== undefined ? { tools } : {},
...messagePrefix !== undefined ? { messagePrefix } : {},
})
}
/**
* Fold the header events of a log (or any prefix of one) into the
* {@link EpochHeader} in force after the last of them: each
* `request/header` snapshot replaces the state, each `request/header-delta`
* amends it. The pure, offline form of reconstruction — external tooling and
* the dev invariant both use it; the live session tracks the same fold
* incrementally.
* @param events - session events in log order (non-header events are skipped).
* @param from - a previously folded state to continue from (the live session's
* incremental cursor); omit to fold from nothing.
* @returns the folded header, or undefined when no header event exists yet.
* Fold the header events of a log (or any prefix) into the
* {@link EpochHeader} in force after the last snapshot. Non-header events are
* skipped. This is the pure offline reconstruction path; the live session
* tracks the same fold incrementally.
* @param events - session events in log order.
* @param from - a previously folded state to continue from.
* @returns the latest canonical header, or undefined when none exists yet.
*/
export function foldRequestHeader(events: readonly SessionEvent[], from?: EpochHeader): EpochHeader | undefined {
let state: EpochHeader | undefined = from
let state = from
for (const event of events) {
if (event.type === 'request/header') {
state = canonicalHeader(event.data.header)
} else if (event.type === 'request/header-delta') {
if (state === undefined) {
throw new Error(`request/header-delta at seq ${event.seq} before any request/header snapshot: corrupt log`)
}
state = applyHeaderDelta(state, event.data)
}
if (event.type === 'request/header') state = canonicalHeader(event.data.header)
}
return state
}

View File

@@ -1,6 +1,6 @@
/**
* Surface layer on top of the session event log: a derived, cached linked list
* of events that produce LLM messages. Rebuilt deterministically from
* Surface layer on top of the session event log: a derived, cached sequence
* list of events that produce LLM messages. Folded deterministically from
* `surfaceOp` markers in the log — the log is the source of truth; the surface
* is a view.
*
@@ -10,7 +10,7 @@
import type { SessionEvent, SurfaceEvent, SurfaceEventType, SurfaceOp } from './types.ts'
/**
* The set of event type strings that are eligible for the surface linked list.
* The set of event type strings that are eligible for the surface sequence.
* Mirrors the {@link SurfaceEventType} union; kept as a runtime set so the
* type guard can check membership without a chain of string comparisons.
*/
@@ -51,28 +51,16 @@ export function isSurfaceEvent(event: SessionEvent): event is SurfaceEvent {
return true
}
/** One node in the surface linked list. */
export interface SurfaceNode {
/** The event seq of this surface node. */
seq: number
/** The previous surface node's seq, or null if this is the head. */
prev: number | null
/** The next surface node's seq, or null if this is the tail. */
next: number | null
}
/**
* Maintains a cached linked list of surface nodes, rebuilt lazily from
* Maintains a cached ordered list of surface event sequences, folded lazily from
* `surfaceOp` markers in the event log. Because the log is append-only, it
* processes only the delta since the last rebuild — new events are folded
* into the existing surface in O(new events) rather than rescanning the
* whole log.
*/
export class SurfaceManager {
/** Surface nodes in linked-list order (head to tail). Empty until first access. */
private _nodes: SurfaceNode[] = []
/** Map from event seq → node. */
private _nodeBySeq = new Map<number, SurfaceNode>()
/** Surface event sequences in head-to-tail order. Empty until first access. */
private _nodes: number[] = []
/** The last processed seq. -1 folds the seeded log on first access. */
private _lastProcessedSeq = -1
@@ -95,15 +83,15 @@ export class SurfaceManager {
return this._replaceGeneration
}
/** The surface nodes in linked-list order (head to tail). */
get nodes(): readonly SurfaceNode[] {
/** Surface event sequences in head-to-tail order. */
get nodes(): readonly number[] {
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
return this._nodes
}
/**
* Process events from `_lastProcessedSeq + 1` through the end of the log,
* folding new surface markers into the existing linked list.
* folding new surface markers into the existing sequence list.
*/
private _processDelta(): void {
for (let i = this._lastProcessedSeq + 1; i < this.log.length; i++) {
@@ -116,11 +104,7 @@ export class SurfaceManager {
if (!isSurfaceEvent(event)) continue
if (event.surfaceOp === 'append') {
const tail = this._nodes.length > 0 ? this._nodes[this._nodes.length - 1] : undefined
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
if (tail) tail.next = event.seq
this._nodes.push(node)
this._nodeBySeq.set(event.seq, node)
this._nodes.push(event.seq)
} else {
this._replace(event.seq, event.surfaceOp)
}
@@ -133,38 +117,21 @@ export class SurfaceManager {
newSeq: number,
op: Extract<SurfaceOp, { op: 'replace' }>,
): void {
const startNode = this._nodeBySeq.get(op.start)
if (!startNode) {
const startIdx = this._nodes.indexOf(op.start)
if (startIdx === -1) {
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
}
const endNode = this._nodeBySeq.get(op.end)
if (!endNode) {
const endIdx = this._nodes.indexOf(op.end)
if (endIdx === -1) {
throw new Error(`surface replace: end seq ${op.end} not found in surface`)
}
const startIdx = this._nodes.indexOf(startNode)
const endIdx = this._nodes.indexOf(endNode)
if (startIdx > endIdx) {
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
}
// Remove shadowed nodes from `[startIdx, endIdx]` inclusive.
const count = endIdx - startIdx + 1
const removed = this._nodes.splice(startIdx, count)
for (const r of removed) this._nodeBySeq.delete(r.seq)
// Insert the new node where the removed range was.
const prevNode = startIdx > 0 ? this._nodes[startIdx - 1] : undefined
const nextNode = startIdx < this._nodes.length ? this._nodes[startIdx] : undefined
const newNode: SurfaceNode = {
seq: newSeq,
prev: prevNode?.seq ?? null,
next: nextNode?.seq ?? null,
}
if (prevNode) prevNode.next = newSeq
if (nextNode) nextNode.prev = newSeq
this._nodes.splice(startIdx, 0, newNode)
this._nodeBySeq.set(newSeq, newNode)
this._nodes.splice(startIdx, count, newSeq)
this._replaceGeneration += 1
}
}

View File

@@ -35,7 +35,6 @@
*/
import type { SessionEvent } from './types.ts'
import type { SurfaceNode } from './surface.ts'
/**
* The tool-pairing delta of a surface node: how it shifts the count of
@@ -62,20 +61,20 @@ function nodeDelta(event: SessionEvent): number {
* cut has its answering `tool/result` before the cut too, so the cut is a safe
* edge for a collapsed region (it cannot split an assistant↔result pair).
*
* `nodes` is the surface linked list in head→tail order (e.g.
* `nodes` is the surface sequence list in head→tail order (e.g.
* `session.surface.nodes`); `events` is the session log, used to look each
* node's event up by `seq`. `beforeSeq` names the cut by the surface node it
* event up by sequence. `beforeSeq` names the cut by the surface event it
* sits immediately before; the after-tail cut (the whole surface) is `null`,
* as is any `beforeSeq` not present on the surface.
*
* A region `[start..end]` is collapsible iff both edges are balanced cuts: call
* `isToolPairingBalanced(nodes, events, start)` for the cut before `start`, and
* `isToolPairingBalanced(nodes, events, after)` — where `after` is `end`'s
* surface successor (`SurfaceNode.next`), or `null` when `end` is the tail —
* surface successor (`nodes[index + 1]`), or `null` when `end` is the tail —
* for the cut after `end`.
*
* @param nodes - the surface linked list in head→tail order.
* @param events - the session log each node's `seq` indexes into.
* @param nodes - surface event sequences in head→tail order.
* @param events - the session log each sequence indexes into.
* @param beforeSeq - names the cut (the node it sits immediately before);
* `null` — or any seq not on the surface — means the after-tail cut.
* @returns true when every `tool-call` before the cut is answered before it
@@ -86,18 +85,18 @@ function nodeDelta(event: SessionEvent): number {
* rather than silently mis-classifying a boundary.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
nodes: readonly number[],
events: readonly SessionEvent[],
beforeSeq: number | null,
): boolean {
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// node.seq is a surface-node seq, always a valid log index by construction.
for (const seq of nodes) {
if (seq === beforeSeq) return depth === 0
// seq is a surface event sequence, always a valid log index by construction.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
depth += nodeDelta(events[seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
throw new Error(`tool-pairing balance: tool/result at surface seq ${seq} has no matching tool-call (corrupt surface)`)
}
}
// Reached the after-tail cut (beforeSeq === null, or a seq not on the

View File

@@ -195,10 +195,9 @@ export interface TodoItem {
* The request header: everything about an LLM request besides its derived
* message history — the call configuration plus the rendered system prompt,
* tool schemas, and the session prefix. Logged session state (the
* reconstructability RFC): a
* {@link SessionEventMap} `request/header` snapshot installs one, a
* `request/header-delta` amends it, and folding those events over the log
* (`foldRequestHeader`) reconstructs the header any request was built under.
* reconstructability RFC): each changed header is logged as a full
* {@link SessionEventMap} `request/header` snapshot, and taking the latest
* snapshot (`foldRequestHeader`) reconstructs the header any request used.
* Canonical form: an empty system prompt, an empty tool list, and an empty
* prefix are ABSENT fields, matching how requests are built.
*/
@@ -223,43 +222,9 @@ export interface EpochHeader {
* Why a `request/header` snapshot was appended: `'initial'` — the log's first
* header (a new conversation); `'resume'` — a loop instance's first request
* over a log that already has header events (process restart, fork seed);
* `'fallback'` — a mid-run change the delta encoding could not round-trip
* (e.g. a pure tool reordering), recorded whole instead.
* `'change'` — a later request used a different header.
*/
export type RequestHeaderReason = 'initial' | 'resume' | 'fallback'
/**
* Line-level edit of the system prompt: keep the first `keepStart` and last
* `keepEnd` lines of the previous text, with `insert` replacing everything
* between. Computed as a common-prefix/common-suffix trim — deterministic,
* library-free, degenerating to a full replacement when nothing is shared.
* Absence is encoded as zero lines (the canonical form has no empty-string
* system), so a transition to or from "no system prompt" round-trips.
*/
export interface SystemDelta {
/** Lines kept from the start of the previous system prompt. */
keepStart: number
/** Lines kept from the end of the previous system prompt. */
keepEnd: number
/** Lines replacing everything between the kept edges. */
insert: string[]
}
/**
* Tool-set edit keyed by tool name (names are unique — the registry rejects
* duplicates): `removed` names drop, `changed` schemas replace their
* predecessor in place, `added` schemas append at the end. A change this
* encoding cannot express (a pure reordering) fails the writer's round-trip
* guard and is recorded as a `'fallback'` snapshot instead.
*/
export interface ToolsDelta {
/** Schemas appended to the end of the tool list. */
added: ToolSchema[]
/** Names of schemas dropped from the tool list. */
removed: string[]
/** Schemas replacing the same-named predecessor in place. */
changed: ToolSchema[]
}
export type RequestHeaderReason = 'initial' | 'resume' | 'change'
/**
* The session event vocabulary — the append-only source of truth for an
@@ -363,32 +328,14 @@ export interface SessionEventMap {
* Full snapshot of the {@link EpochHeader} the NEXT request is built under,
* with the {@link RequestHeaderReason} it was recorded whole. Appended by
* the loop inside the step, before dispatch, on a loop instance's first
* request-building step (`'initial'`/`'resume'`) or when a delta failed its
* round-trip guard (`'fallback'`); always records what the request actually
* used, post-`agent/request`. Anchors the header fold: reconstruction reads
* the latest snapshot and applies the deltas after it. NOT a
* request-building step (`'initial'`/`'resume'`) or when a later request's
* header changes (`'change'`); always records what the request actually used,
* post-`agent/request`. Reconstruction reads the latest snapshot. NOT a
* {@link SurfaceEventType}: it produces no LLM message — it is the request
* envelope, logged so every request is a pure function of the session log
* (the reconstructability RFC).
*/
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
/**
* Amendment to the folded {@link EpochHeader}: at least one of a
* {@link SystemDelta}, a {@link ToolsDelta}, a whole replacement
* {@link LlmCallConfig} (four scalars — not worth diffing), or a whole
* replacement session prefix (`messagePrefix` — small advisory content,
* replaced whole; an EMPTY array encodes the transition to "none",
* mirroring the canonical form's absent field — the loop never produces
* one in practice: the prefix is composed once per instance and anchored
* by that instance's snapshot, so this arm exists for codec totality).
* Appended by the
* loop inside the step, before dispatch, when the header for this request
* differs from the fold of the log so far; the writer verifies
* `applyHeaderDelta(previous, delta)` reproduces the new header exactly and
* falls back to a `'fallback'` `request/header` snapshot when it cannot, so
* a logged delta ALWAYS round-trips. NOT a {@link SurfaceEventType}.
*/
'request/header-delta': { system?: SystemDelta; tools?: ToolsDelta; config?: LlmCallConfig; messagePrefix?: Message[] }
}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
@@ -396,7 +343,7 @@ export type SessionEventType = keyof SessionEventMap
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the surface linked list. Only these
* messages and are eligible to appear on the ordered surface. Only these
* event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}.
*/
export type SurfaceEventType =
@@ -407,7 +354,7 @@ export type SurfaceEventType =
| 'steering/message'
/**
* A {@link SessionEvent} that is **on** the surface linked list — its
* A {@link SessionEvent} that is **on** the ordered surface — its
* `surfaceOp` is guaranteed present (mandatory), narrowed from a
* surface-eligible {@link SessionEvent} by checking both `type` and
* `surfaceOp` at runtime.
@@ -418,7 +365,7 @@ export type SurfaceEventType =
export type SurfaceEvent = SessionEvent<SurfaceEventType> & { surfaceOp: SurfaceOp }
/**
* How a session event entered the surface linked list. Only valid on
* How a session event entered the ordered surface. Only valid on
* {@link SurfaceEventType} events.
*
* - `'append'`: added to the tail — normal path for user/assistant/tool/context
@@ -435,7 +382,7 @@ export type SurfaceOp =
/**
* Surface metadata passed to {@link Session.append}.
* `surfaceOp` controls how the event enters the surface linked list;
* `surfaceOp` controls how the event enters the ordered surface;
* `sourceEventSeqs` records the seq numbers of events that are provenance
* sources of this one (e.g. the `assistant/chunk` seqs behind an
* `assistant/message`, or the shadowed nodes behind a compaction replacement).

View File

@@ -44,7 +44,7 @@ describe('derived-message cache', () => {
const nodes = session.surface.nodes
session.append('context/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(session.deriveMessages()).toHaveLength(1)
expect(session.deriveMessages()).toEqual(scratch(session))

View File

@@ -1,14 +1,7 @@
/**
* Request-header utility tests: canonical form, the system line-diff
* (prefix/suffix trim), the name-keyed tools delta, config replacement, the
* round-trip contract (including the reorder case the encoding cannot
* express), and the log fold. These pin the reconstruction algebra: for every
* logged delta, apply(prev, delta) === next, and folding a log prefix yields
* the header its next request was built under.
*/
/** Request-header canonicalization, equality, snapshot folding, and format rejection. */
import { describe, expect, it } from 'vitest'
import { Session, SessionId, applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
@@ -22,165 +15,57 @@ function msg(text: string): Message {
return { role: 'user', content: [{ type: 'text', text }] }
}
/** Round-trip helper: diff must reproduce `next` from `prev` exactly. */
function roundTrip(prev: EpochHeader, next: EpochHeader): ReturnType<typeof diffHeader> {
const delta = diffHeader(prev, next)
if (delta !== undefined) {
expect(applyHeaderDelta(prev, delta)).toEqual(canonicalHeader(next))
}
return delta
}
describe('canonicalHeader', () => {
it('normalizes empty system and empty tools to absent fields', () => {
expect(canonicalHeader({ config: CONFIG, system: '', tools: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')] })
expect(full.system).toBe('s')
expect(full.tools).toHaveLength(1)
it('normalizes empty optional fields to absence and preserves populated fields', () => {
expect(canonicalHeader({ config: CONFIG, system: '', tools: [], messagePrefix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
expect(full).toEqual({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
})
})
describe('diffHeader / applyHeaderDelta', () => {
it('returns undefined for equal headers', () => {
const header = canonicalHeader({ config: CONFIG, system: 'a\nb', tools: [tool('t')] })
expect(diffHeader(header, header)).toBeUndefined()
describe('headerEquals', () => {
const base = canonicalHeader({ config: CONFIG, system: 's', tools: [tool('a')], messagePrefix: [msg('p')] })
it('compares every canonical field and preserves tool order', () => {
expect(headerEquals(base, structuredClone(base))).toBe(true)
expect(headerEquals(base, { ...base, config: { model: 'other' } })).toBe(false)
expect(headerEquals(base, { ...base, system: 'other' })).toBe(false)
expect(headerEquals(base, { ...base, messagePrefix: [msg('other')] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [] })).toBe(false)
expect(headerEquals(base, { ...base, tools: [tool('a', 'changed')] })).toBe(false)
expect(headerEquals({ config: CONFIG, tools: [tool('a'), tool('b')] }, { config: CONFIG, tools: [tool('b'), tool('a')] })).toBe(false)
})
it('encodes a mid-prompt line change as a prefix/suffix trim', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'keep1\nold\nkeep2\nkeep3' })
const next = canonicalHeader({ config: CONFIG, system: 'keep1\nnew A\nnew B\nkeep2\nkeep3' })
const delta = roundTrip(prev, next)
expect(delta?.system).toEqual({ keepStart: 1, keepEnd: 2, insert: ['new A', 'new B'] })
expect(delta?.tools).toBeUndefined()
expect(delta?.config).toBeUndefined()
})
it('degenerates to a full replacement when nothing is shared, and round-trips absence transitions', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, system: 'x\ny' })
const gained = roundTrip(none, some)
expect(gained?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: ['x', 'y'] })
const lost = roundTrip(some, none)
expect(lost?.system).toEqual({ keepStart: 0, keepEnd: 0, insert: [] })
})
it('does not double-count overlapping prefix and suffix (repeated lines)', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'a\na' })
const next = canonicalHeader({ config: CONFIG, system: 'a\na\na' })
roundTrip(prev, next)
})
it('encodes tool addition, removal, and in-place schema change by name', () => {
const prev = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('drop'), tool('edit', 'before')] })
const next = canonicalHeader({ config: CONFIG, tools: [tool('keep'), tool('edit', 'after'), tool('new')] })
const delta = roundTrip(prev, next)
expect(delta?.tools?.added.map(t => t.name)).toEqual(['new'])
expect(delta?.tools?.removed).toEqual(['drop'])
expect(delta?.tools?.changed.map(t => t.name)).toEqual(['edit'])
})
it('round-trips a tool set gained from a tool-less header and lost back to one', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, tools: [tool('t')] })
const gained = roundTrip(none, some)
expect(gained?.tools?.added.map(t => t.name)).toEqual(['t'])
const lost = roundTrip(some, none)
expect(lost?.tools?.removed).toEqual(['t'])
})
it('cannot express a pure reordering — the writer detects it via the round-trip check', () => {
const prev = canonicalHeader({ config: CONFIG, tools: [tool('a'), tool('b')] })
const next = canonicalHeader({ config: CONFIG, tools: [tool('b'), tool('a')] })
const delta = diffHeader(prev, next)
// A delta IS produced (the lists differ)…
expect(delta).toBeDefined()
// …but applying it cannot reproduce the new order — exactly the case the
// writer's guard turns into a 'fallback' snapshot.
expect(applyHeaderDelta(prev, delta!)).not.toEqual(next)
})
it('replaces the config whole and leaves untouched parts alone', () => {
const prev = canonicalHeader({ config: { model: 'm' }, system: 's', tools: [tool('t')] })
const next = canonicalHeader({ config: { model: 'm2', temperature: 0.1 }, system: 's', tools: [tool('t')] })
const delta = roundTrip(prev, next)
expect(delta).toEqual({ config: { model: 'm2', temperature: 0.1 } })
})
})
describe('the session prefix (messagePrefix)', () => {
it('canonicalHeader normalizes an empty prefix to an absent field', () => {
expect(canonicalHeader({ config: CONFIG, messagePrefix: [] })).toEqual({ config: CONFIG })
const full = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
expect(full.messagePrefix).toEqual([msg('p')])
})
it('headerEquals treats absence and empty as one representation, content differences as unequal', () => {
expect(headerEquals(canonicalHeader({ config: CONFIG }), { config: CONFIG, messagePrefix: [] })).toBe(true)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG, messagePrefix: [msg('b')] })).toBe(false)
expect(headerEquals({ config: CONFIG, messagePrefix: [msg('a')] }, { config: CONFIG })).toBe(false)
})
it('replaces a changed prefix whole and leaves untouched parts alone', () => {
const prev = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('old')] })
const next = canonicalHeader({ config: CONFIG, system: 'keep', messagePrefix: [msg('new'), msg('more')] })
const delta = roundTrip(prev, next)
expect(delta).toEqual({ messagePrefix: [msg('new'), msg('more')] })
})
it('round-trips a prefix gained from a bare header and lost back to one (empty array encodes absence)', () => {
const none = canonicalHeader({ config: CONFIG })
const some = canonicalHeader({ config: CONFIG, messagePrefix: [msg('p')] })
const gained = roundTrip(none, some)
expect(gained).toEqual({ messagePrefix: [msg('p')] })
const lost = roundTrip(some, none)
expect(lost).toEqual({ messagePrefix: [] })
})
it('folds prefix deltas over the log like any other header amendment', () => {
const session = new Session(SessionId('fold-prefix'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v1')] })
session.append('request/header', { header: first, reason: 'initial' })
const second = canonicalHeader({ config: CONFIG, messagePrefix: [msg('catalog v2')] })
session.append('request/header-delta', diffHeader(first, second)!)
expect(foldRequestHeader(session.events)).toEqual(second)
session.append('request/header-delta', diffHeader(second, canonicalHeader({ config: CONFIG }))!)
expect(foldRequestHeader(session.events)).toEqual({ config: CONFIG })
it('treats absent and empty prefix/tool arrays as equivalent canonical absence', () => {
expect(headerEquals({ config: CONFIG }, { config: CONFIG, tools: [], messagePrefix: [] })).toBe(true)
})
})
describe('foldRequestHeader', () => {
function headerEvents(session: Session): readonly SessionEvent[] {
return session.events
}
it('returns undefined on a log with no header events', () => {
const session = new Session(SessionId('fold-none'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(foldRequestHeader(headerEvents(session))).toBeUndefined()
it('returns the supplied baseline when no snapshot follows', () => {
const from: EpochHeader = { config: CONFIG, system: 'baseline' }
const unrelated: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
]
expect(foldRequestHeader(unrelated)).toBeUndefined()
expect(foldRequestHeader(unrelated, from)).toBe(from)
})
it('folds snapshot then deltas into the header in force, skipping unrelated events', () => {
it('takes the latest full snapshot and skips unrelated events', () => {
const session = new Session(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = canonicalHeader({ config: { model: 'm' }, system: 'a\nb', tools: [tool('t')] })
session.append('request/header', { header: first, reason: 'initial' })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const second = canonicalHeader({ config: { model: 'm' }, system: 'a\nc', tools: [tool('t')] })
session.append('request/header-delta', diffHeader(first, second)!)
expect(foldRequestHeader(headerEvents(session))).toEqual(second)
// A later snapshot replaces the state wholesale (the 'resume'/'fallback' anchor).
const third = canonicalHeader({ config: { model: 'other' } })
session.append('request/header', { header: third, reason: 'resume' })
expect(foldRequestHeader(headerEvents(session))).toEqual(third)
})
it('throws on a delta before any snapshot (corrupt log)', () => {
const session = new Session(SessionId('fold-corrupt'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('request/header-delta', { config: { model: 'x' } })
expect(() => foldRequestHeader(headerEvents(session))).toThrow(/before any request\/header snapshot/)
session.append('request/header', { header: { config: { model: 'other' }, tools: [] }, reason: 'change' })
expect(foldRequestHeader(session.events)).toEqual({ config: { model: 'other' } })
})
})
describe('legacy request-header format', () => {
it('rejects a v0 seed containing request/header-delta', () => {
const legacy = [{
type: 'request/header-delta', seq: 0, time: 1, data: { config: CONFIG },
}] as unknown as SessionEvent[]
expect(() => new Session(SessionId('legacy'), legacy)).toThrow(/unsupported legacy request\/header-delta/)
})
})

View File

@@ -1171,8 +1171,8 @@ describe('todo/write event', () => {
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
// The todo event must not add a message to the derived history…
expect(session.deriveMessages()).toHaveLength(before)
// …and must not appear on the surface linked list.
expect(session.surface.nodes.some(node => node.seq === session.seq - 1)).toBe(false)
// …and must not appear on the ordered surface.
expect(session.surface.nodes).not.toContain(session.seq - 1)
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {

View File

@@ -14,18 +14,12 @@ function surfaceSession(): Session {
}
describe('SurfaceManager', () => {
it('rebuilds a linked list from surfaceOp: append markers', () => {
it('folds an ordered sequence 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()
expect(nodes).toEqual([1, 2])
})
it('empty surface yields empty nodes', () => {
@@ -46,9 +40,7 @@ describe('SurfaceManager', () => {
// 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)
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
})
it('replays identically from a seeded log with surface markers', () => {
@@ -56,7 +48,7 @@ describe('SurfaceManager', () => {
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.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
})
@@ -70,10 +62,7 @@ describe('SurfaceManager', () => {
{ 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()
expect(s.surface.nodes).toEqual([4])
})
it('replace with both ends at real nodes splices only the range', () => {
@@ -86,12 +75,7 @@ describe('SurfaceManager', () => {
{ 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()
expect(s.surface.nodes).toEqual([3, 2])
})
it('single-node replacement (start === end)', () => {
@@ -103,9 +87,7 @@ describe('SurfaceManager', () => {
{ 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)
expect(s.surface.nodes).toEqual([0, 2])
})
it('throws when replace start is not found', () => {
@@ -151,7 +133,7 @@ describe('SurfaceManager', () => {
expect(logged.sourceEventSeqs).toEqual([10, 20])
})
it('replace starting at non-head position links to previous node correctly', () => {
it('replace starting at non-head position preserves surrounding order', () => {
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
@@ -161,14 +143,7 @@ describe('SurfaceManager', () => {
{ 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()
expect(s.surface.nodes).toEqual([0, 3, 2])
})
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
@@ -340,7 +315,7 @@ describe('SurfaceManager.replaceGeneration', () => {
const nodes = s.surface.nodes
s.append('context/message', {
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes[1]!.seq }, sourceEventSeqs: [nodes[0]!.seq, nodes[1]!.seq] })
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)
})
})

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { Session, SessionId, isToolPairingBalanced } from '../src/index.ts'
import type { SessionEvent, SurfaceNode } from '../src/index.ts'
import type { SessionEvent } from '../src/index.ts'
/**
* Unit coverage for the tool-pairing balance check. It decides whether a CUT in
@@ -12,8 +12,8 @@ import type { SessionEvent, SurfaceNode } from '../src/index.ts'
* no step (pre-step user message, inter-step steering, injection context) are
* pairing-neutral, so their cuts are free boundaries.
*
* The fixtures are built through a real {@link Session} so the surface linked
* list is derived exactly as production does — including the non-monotonic
* The fixtures are built through a real {@link Session} so the ordered surface
* sequence list is derived exactly as production does — including the non-monotonic
* surface a `replace` op leaves (a compaction checkpoint at a high log seq
* sitting at the surface head), which is the case the abandoned log-position
* scan mis-classified.
@@ -27,7 +27,7 @@ import type { SessionEvent, SurfaceNode } from '../src/index.ts'
const SURFACE = { surfaceOp: 'append' as const }
/** Surface nodes + log for a session, the two args the balance check takes. */
function surfaceOf(session: Session): { nodes: readonly SurfaceNode[]; events: readonly SessionEvent[] } {
function surfaceOf(session: Session): { nodes: readonly number[]; events: readonly SessionEvent[] } {
return { nodes: session.surface.nodes, events: session.events }
}
@@ -40,9 +40,9 @@ function startBalanced(session: Session, seq: number): boolean {
/** The cut AFTER the surface node at `seq` is balanced (safe region end). */
function endBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
const node = nodes.find(n => n.seq === seq)
if (!node) throw new Error(`seq ${seq} is not a surface node`)
return isToolPairingBalanced(nodes, events, node.next)
const index = nodes.indexOf(seq)
if (index === -1) throw new Error(`seq ${seq} is not a surface node`)
return isToolPairingBalanced(nodes, events, nodes[index + 1] ?? null)
}
/** Surface seq of the nth (0-based) event of a given type. */
@@ -274,20 +274,20 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
it('the head checkpoint sits at the surface head while a later surface node follows it in the log', () => {
const s = checkpointHeadedSession()
const nodes = s.surface.nodes
const checkpointSeq = nodes[0]!.seq
const checkpointSeq = nodes[0]!
// The checkpoint heads the surface, yet a surface node (the open step's
// assistant) follows it in LOG order — the exact split between surface
// position and log position that the log-position scan tripped on.
const laterSurfaceInLog = s.events.find(
e => e.seq > checkpointSeq && nodes.some(n => n.seq === e.seq),
e => e.seq > checkpointSeq && nodes.includes(e.seq),
)
expect(laterSurfaceInLog).toBeDefined()
expect(nodes[0]!.seq).toBe(checkpointSeq)
expect(nodes[0]!).toBe(checkpointSeq)
})
it('start cut before the head checkpoint is balanced (it is the head)', () => {
const s = checkpointHeadedSession()
expect(startBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
expect(startBalanced(s, s.surface.nodes[0]!)).toBe(true)
})
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
@@ -296,7 +296,7 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
// wrongly reported mid-step. The surface balance sees a neutral node whose
// following cut closes no open call.
const s = checkpointHeadedSession()
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
expect(endBalanced(s, s.surface.nodes[0]!)).toBe(true)
})
})

View File

@@ -7,7 +7,7 @@
* the same way out of caution. It is per-conversation state recorded in the
* session log (the reconstructability RFC), never a silently-drifting
* per-call knob: the `agent/request` waterfall proposes a replacement, and
* the loop logs a real change as a `request/header-delta` event.
* the loop logs a real change as a `request/header` snapshot.
*
* @module dsh-llm/call-config
*/
@@ -27,7 +27,7 @@ export interface LlmCallConfig {
/**
* Field-wise equality over {@link LlmCallConfig} — the comparison a caller
* runs to decide whether a proposed configuration is a real change (worth a
* logged header delta) or the held one restated.
* logged header snapshot) or the held one restated.
* @param a - one configuration.
* @param b - the other.
* @returns whether every field (including the `stop` list, element-wise) matches.

View File

@@ -1,6 +1,6 @@
/**
* call-config unit tests: field-wise LlmCallConfig equality (the real-change
* detector behind logged header deltas) and the deepFreeze ownership helper
* detector behind logged changed headers) and the deepFreeze ownership helper
* the loop applies to every built request.
*/

View File

@@ -6,7 +6,7 @@ import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts'
import { encodeSegment, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
@@ -147,6 +147,21 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
})
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const m = meta('legacy-header-delta', '/legacy')
const path = logPath(root, m.cwd, m.id)
await mkdir(sessionDir(root, m.cwd), { recursive: true })
await writeFile(path, [
JSON.stringify(toHeaderLine(m)),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
'',
].join('\n'))
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
})
it('persists a forked child seed through the existing session write path', async () => {
const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
appendClosedTurn(source)

View File

@@ -144,6 +144,23 @@ describe('scanRows', () => {
})
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const path = await freshDbPath()
const m = meta('legacy-header-delta', '/legacy')
const db = openDatabase(path, 'wal')
db.prepare('INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) VALUES (?, ?, ?, ?, NULL, NULL)')
.run(m.id, m.version, m.createdAt, m.cwd ?? null)
const insert = db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
insert.run(m.id, 0, 'turn/start', 1, JSON.stringify({ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }))
insert.run(m.id, 1, 'request/header-delta', 2, JSON.stringify({ config: { model: 'legacy' } }))
insert.run(m.id, 2, 'turn/end', 3, JSON.stringify({ turn: 1, reason: { kind: 'completed' } }))
db.close()
const mounted = await backend(path)
await expect(mounted.ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
await mounted.dispose()
})
it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
const path = await freshDbPath()
const m = meta('crash')

View File

@@ -151,6 +151,15 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
})
}
/** Reject events from an obsolete v0 vocabulary that this build cannot replay. */
function assertSupportedEvents(events: readonly SessionEvent[], id: SessionId): void {
const legacyType: string = 'request/header-delta'
const legacy = events.find(event => event.type === legacyType)
if (legacy !== undefined) {
throw new Error(`session "${id}" contains unsupported legacy request/header-delta event at seq ${legacy.seq}`)
}
}
/**
* Owns the backend-agnostic session write-path orchestration. A backend
* constructs one (`new PersistenceCoordinator(ctx, this)`), implements
@@ -242,6 +251,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (batch === undefined) {
throw new TypeError('session event batch is not losslessly JSON-serializable because it contains non-JSON-serializable data')
}
assertSupportedEvents(batch, id)
return this.serialize(id, () => this.appendCore(id, batch))
}
@@ -280,6 +290,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
if (stored === undefined) throw new Error(`session "${id}" not found`)
const { meta, events, tornMarker } = stored
this.assertVersion(meta)
assertSupportedEvents(events, id)
// Crash-recovery: if the log ended mid-turn (real, preserved events but no
// closing turn/end), close it durably DURING load so disk, the returned log,

View File

@@ -35,7 +35,7 @@ defineAcpSnapshotSuite({
})
```
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list.
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. A pin whose scenario legitimately changes its header mid-run declares `expectedHeaderChanges`; the Markdown snapshot then records each later full prompt under a `request/header change` marker.
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's Markdown prompt snapshot from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).

View File

@@ -135,8 +135,8 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
}
/**
* Replace system-prompt content in request headers and header deltas with
* `{{system}}` tokens while retaining field presence and delta structure.
* Replace system-prompt content in request headers with `{{system}}` tokens
* while retaining field presence.
* Other header content stays verbatim, so a header-pinning fixture can keep
* its complete tool schemas while every JSONL fixture omits the prompt text.
* Lines without a system payload pass through byte-for-byte; the transform is
@@ -153,9 +153,9 @@ export function scrubSystemPrompts(rawLog: string): string {
* Replace all bulky request-header content in a session JSONL with stable
* tokens. This includes the system-prompt fields handled by
* {@link scrubSystemPrompts}, tool schemas, and session-prefix messages. It
* keeps system-delta line positions and arity, tool-delta names, prefix
* message counts, field presence, config, and reason. Lines without content
* to scrub pass through byte-for-byte, and the transform is idempotent.
* keeps prefix message counts, field presence, config, and reason. Lines
* without content to scrub pass through byte-for-byte, and the transform is
* idempotent.
*
* @param rawLog The raw session `.jsonl` content.
* @returns The JSONL with all header bulk tokenized, other lines byte-identical.
@@ -184,33 +184,7 @@ function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): strin
}
return touched ? JSON.stringify(record) : line
}
if (record.type === 'request/header-delta') {
let touched = false
const system = data.system as Record<string, unknown> | null | undefined
if (system !== null && typeof system === 'object' && Array.isArray(system.insert)) {
system.insert = system.insert.map(() => SYSTEM)
touched = true
}
const tools = data.tools as Record<string, unknown> | null | undefined
if (scrubToolsAndPrefix && tools !== null && typeof tools === 'object') {
if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true }
if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true }
}
if (scrubToolsAndPrefix && Array.isArray(data.messagePrefix)) {
data.messagePrefix = data.messagePrefix.map(() => MESSAGE_PREFIX)
touched = true
}
return touched ? JSON.stringify(record) : line
}
return line
})
return out.join('\n')
}
/** Tokenize one tool schema's bulk (description, parameters, anything else), keeping its identifying `name`. */
function scrubToolSchema(tool: unknown): unknown {
if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) return tool
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(tool)) out[k] = k === 'name' ? v : TOOLS
return out
}

View File

@@ -16,7 +16,7 @@
* scenario stores the readable prompt in `system-prompt.golden.md` and keeps its full
* tool schemas in `session.jsonl`, while every other fixture also scrubs tools
* to `{{tools}}`. A per-run uniformity guard compares both artifacts against
* every live header and forbids unrepresented header deltas (see the
* every live header and forbids unrepresented changed headers (see the
* pinned-header RFC,
* docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md).
*
@@ -106,14 +106,11 @@ export interface Scenario {
*/
pinsHeader?: boolean
/**
* How many `request/header-delta` events this PINNING scenario's fixture
* legitimately carries (default 0). A recorded mid-run header change — a
* config-option switch rewriting a prompt section — is part of the pinned
* surface, with readable prompt text in Markdown; any OTHER count
* still fails, so fixture rot stays caught. Meaningless off the pin (the
* live uniformity guard keeps non-pinning scenarios delta-free).
* How many changed `request/header` snapshots this PINNING scenario's primary
* fixture legitimately carries (default 0). Their full prompt text is kept in
* the readable Markdown pin; any other count fails. Meaningless off the pin.
*/
expectedHeaderDeltas?: number
expectedHeaderChanges?: number
/**
* Which header-composition class this scenario belongs to. Scenarios that
* boot the same config compose the same header; each class has exactly one
@@ -225,79 +222,46 @@ export function normalizedSystemPrompts(rawLog: string, ctx: NormalizeContext):
})
}
/** One normalized system-prompt edit carried by a `request/header-delta`. */
export interface SystemPromptDeltaSnapshot {
/** How many leading lines remain from the prior prompt. */
keepStart: number
/** How many trailing lines remain from the prior prompt. */
keepEnd: number
/** The normalized replacement lines inserted between the retained ranges. */
insert: string[]
}
/**
* Extract normalized system-prompt edits from request-header deltas in log
* order. Deltas without a well-formed system edit are omitted; their non-prompt
* structure remains pinned in JSONL.
*
* @param rawLog The session `.jsonl` content to inspect.
* @param ctx The volatile values of the run that produced it.
* @returns The normalized system-prompt edits, in event order.
*/
export function normalizedSystemPromptDeltas(rawLog: string, ctx: NormalizeContext): SystemPromptDeltaSnapshot[] {
return normalizeSessionLog(rawLog, ctx)
.split('\n')
.filter(line => line.trim().length > 0)
.map(line => JSON.parse(line) as { type?: unknown; data?: { system?: unknown } })
.filter(record => record.type === 'request/header-delta')
.flatMap((record) => {
const system = record.data?.system
if (system === null || typeof system !== 'object') return []
const { keepStart, keepEnd, insert } = system as { keepStart?: unknown; keepEnd?: unknown; insert?: unknown }
if (typeof keepStart !== 'number' || typeof keepEnd !== 'number' || !Array.isArray(insert)) return []
if (!insert.every(line => typeof line === 'string')) return []
return [{ keepStart, keepEnd, insert: insert }]
})
}
/**
* Render a normalized prompt as a repository-friendly Markdown snapshot.
* Prompt text is unchanged except that a missing terminal newline is added so
* the committed file follows the repository newline contract.
*
* @param prompt The normalized system prompt.
* @param deltas Normalized prompt edits to append as readable sections.
* @param changes Full normalized prompts from later changed-header snapshots.
* @returns Markdown snapshot text ending in a newline.
*/
export function formatSystemPromptSnapshot(
prompt: string,
deltas: readonly SystemPromptDeltaSnapshot[] = [],
changes: readonly string[] = [],
): string {
let snapshot = prompt.endsWith('\n') ? prompt : `${prompt}\n`
for (const [index, delta] of deltas.entries()) {
snapshot += `\n<!-- request/header-delta ${index + 1}: keepStart=${delta.keepStart}, keepEnd=${delta.keepEnd} -->\n\n`
const insert = delta.insert.join('\n')
snapshot += insert.endsWith('\n') ? insert : `${insert}\n`
for (const [index, change] of changes.entries()) {
snapshot += `\n<!-- request/header change ${index + 1} -->\n\n`
snapshot += change.endsWith('\n') ? change : `${change}\n`
}
return snapshot
}
/** Return the initial-prompt portion of a possibly delta-bearing snapshot. */
/** Return the initial-prompt portion of a possibly multi-header snapshot. */
function initialSystemPromptSnapshot(snapshot: string): string {
const marker = snapshot.indexOf('\n<!-- request/header-delta ')
const marker = snapshot.indexOf('\n<!-- request/header change ')
return marker < 0 ? snapshot : snapshot.slice(0, marker)
}
/**
* Count the `request/header-delta` events in a session JSONL.
* Count changed `request/header` snapshots in a session JSONL.
*
* @param rawLog The session `.jsonl` content.
* @returns How many `request/header-delta` events the log carries.
* @returns How many headers carry reason `change`.
*/
export function headerDeltaCount(rawLog: string): number {
export function headerChangeCount(rawLog: string): number {
return rawLog.split('\n')
.filter(line => line.trim().length > 0)
.filter(line => (JSON.parse(line) as { type?: unknown }).type === 'request/header-delta')
.filter((line) => {
const record = JSON.parse(line) as { type?: unknown; data?: { reason?: unknown } }
return record.type === 'request/header' && record.data?.reason === 'change'
})
.length
}
@@ -490,18 +454,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
))
}
if (scenario.pinsHeader === true) {
const prompts = result.sessionLogs.flatMap(log => normalizedSystemPrompts(log.content, ctx))
expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0)
const initialSnapshot = formatSystemPromptSnapshot(prompts[0] as string)
for (const prompt of prompts) {
expect(formatSystemPromptSnapshot(prompt), 'the pinning run produced divergent system prompts')
.toEqual(initialSnapshot)
}
const primary = result.sessionLogs[0] as HarvestedLog
const snapshot = formatSystemPromptSnapshot(
prompts[0] as string,
normalizedSystemPromptDeltas(primary.content, ctx),
)
const prompts = normalizedSystemPrompts(primary.content, ctx)
expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0)
const snapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1))
await writeFile(join(dir, SYSTEM_PROMPT_SNAPSHOT), snapshot)
}
}
@@ -534,9 +490,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
// Header-uniformity guard: every live header in a class must equal the
// class pin split across its JSONL header (system token + real tools)
// and readable Markdown prompt. A pinning scenario may carry its
// declared header deltas; their prompt edits live in the Markdown
// golden while JSONL retains the tokenized edit structure.
// and readable Markdown prompt. A pinning scenario may carry declared
// changed-header snapshots; each full prompt lives in that Markdown.
/* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario
const pinningDir = join(snapshotsDir, pinningScenario.name)
@@ -544,29 +499,32 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture))
const promptSnapshot = await readFile(join(pinningDir, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot)
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`)
.toBe(1)
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) has an unexpected request/header count`)
.toBe(1 + (pinningScenario.expectedHeaderChanges ?? 0))
for (const [logIndex, log] of result.sessionLogs.entries()) {
const expectedDeltas = scenario.pinsHeader === true && logIndex === 0
? scenario.expectedHeaderDeltas ?? 0
const expectedChanges = scenario.pinsHeader === true && logIndex === 0
? scenario.expectedHeaderChanges ?? 0
: 0
expect(headerDeltaCount(log.content), `session ${log.id}: request/header-delta count`)
.toBe(expectedDeltas)
expect(headerChangeCount(log.content), `session ${log.id}: changed request/header count`)
.toBe(expectedChanges)
const headers = normalizedHeaders(scrubSystemPrompts(log.content), ctx)
const prompts = normalizedSystemPrompts(log.content, ctx)
expect(prompts.length, `session ${log.id}: every request/header must carry a string system prompt`)
.toBe(headers.length)
for (const [k, header] of headers.entries()) {
const expected = expectedChanges > 0 ? pinned[k] : pinned[0]
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(pinned[0])
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(initialPromptSnapshot)
.toEqual(expected)
if (expectedChanges === 0) {
expect(formatSystemPromptSnapshot(prompts[k] as string), `session ${log.id}: initial system prompt #${k + 1} diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(initialPromptSnapshot)
}
}
if (scenario.pinsHeader === true && logIndex === 0) {
expect(formatSystemPromptSnapshot(
prompts[0] as string,
normalizedSystemPromptDeltas(log.content, ctx),
), `session ${log.id}: system-prompt deltas diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
prompts.slice(1),
), `session ${log.id}: changed system prompts diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(promptSnapshot)
}
}
@@ -632,22 +590,22 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
})
it('every pinning fixture carries one request/header, one readable prompt, and its declared deltas', async () => {
it('every pinning fixture carries one anchor, one readable prompt artifact, and its declared changes', async () => {
// The live uniformity guard runs only in NON-pinning scenarios, so a
// class made of just its pinning scenario would otherwise accept a
// re-recorded pin with several headers or an undeclared mid-run
// header-delta — shapes the pin design cannot represent. Assert the
// re-recorded pin with undeclared mid-run header changes. Assert the
// committed pins directly; a scenario whose arc legitimately rewrites
// a prompt section declares the exact count via expectedHeaderDeltas.
// a prompt section declares the exact count via expectedHeaderChanges.
for (const scenario of pinningByClass.values()) {
const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8')
const headers = normalizedHeaders(fixture, fixtureContext(fixture))
const promptSnapshot = await readFile(join(snapshotsDir, scenario.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
expect(headers.length, `${scenario.name}: a pinning fixture must carry exactly one request/header`).toBe(1)
expect(headers.length, `${scenario.name}: unexpected request/header count`)
.toBe(1 + (scenario.expectedHeaderChanges ?? 0))
expect(promptSnapshot.length, `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0)
expect(promptSnapshot.endsWith('\n'), `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true)
expect(headerDeltaCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared request/header-deltas`)
.toBe(scenario.expectedHeaderDeltas ?? 0)
expect(headerChangeCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared changed headers`)
.toBe(scenario.expectedHeaderChanges ?? 0)
}
})

View File

@@ -5,7 +5,7 @@
"lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}" },
{ "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
{ "type": "request/header-delta", "seq": 1, "time": 100, "data": { "system": { "keepStart": 1, "keepEnd": 0, "insert": ["NEW PROMPT LINE"] } } },
{ "type": "request/header", "seq": 1, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT\n\nNEW PROMPT LINE", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "change" } },
{ "type": "turn/start", "seq": 2, "time": 100, "data": { "turn": 1 } }
]
}]

View File

@@ -1,4 +1,4 @@
{"type":"session","id":"12121212-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/pin-cwd"}
{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"initial"}}
{"type":"request/header-delta","seq":1,"time":7,"data":{"system":{"keepStart":1,"keepEnd":0,"insert":["{{system}}"]}}}
{"type":"request/header","seq":1,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"change"}}
{"type":"turn/start","seq":2,"time":7,"data":{"turn":1}}

View File

@@ -1,5 +1,7 @@
SYS PROMPT
<!-- request/header-delta 1: keepStart=1, keepEnd=0 -->
<!-- request/header change 1 -->
SYS PROMPT
NEW PROMPT LINE

View File

@@ -180,89 +180,19 @@ describe('scrubRequestHeaders', () => {
expect(scrubRequestHeaders(`${headerLine}\n${odd}\n`)).toContain('"messagePrefix":"weird"')
})
it('scrubs a header-delta prefix replacement to one token per message', () => {
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'leaked opener' }] }] },
})
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
expect(out).toContain('"messagePrefix":["{{messagePrefix}}"]')
expect(out).not.toContain('leaked opener')
// The empty-array transition-to-absence stays a structural fact.
const toNone = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { messagePrefix: [] } })
expect(scrubRequestHeaders(`${headerLine}\n${toNone}\n`)).toContain('"messagePrefix":[]')
})
it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => {
const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } })
const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } })
it('leaves malformed headers with no scrubbable payload byte-identical', () => {
const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
const raw = `${headerLine}\n${configOnly}\n${oddShapes}\n${headerless}\n${nullData}\n`
const raw = `${headerLine}\n${headerless}\n${nullData}\n`
expect(scrubRequestHeaders(raw)).toBe(raw)
})
it('scrubs a one-sided tools delta and passes non-object schema entries through', () => {
const addedOnly = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { tools: { added: [null, 'weird', { name: 'x', description: 'D' }] } },
})
const out = scrubRequestHeaders(`${headerLine}\n${addedOnly}\n`)
// Non-object entries survive untouched; the object entry keeps only name.
expect(out).toContain('"added":[null,"weird",{"name":"x","description":"{{tools}}"}]')
const changedOnly = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { tools: { changed: [{ name: 'y', parameters: {} }] } },
})
expect(scrubRequestHeaders(`${headerLine}\n${changedOnly}\n`))
.toContain('"changed":[{"name":"y","parameters":"{{tools}}"}]')
})
it('scrubs a header-delta system payload but keeps its line positions and arity', () => {
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { system: { keepStart: 1, keepEnd: 4, insert: ['leaked prompt line', 'second line'] }, config: { model: 'm2' } },
})
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
// One token PER inserted line: the edit's position AND extent survive.
expect(out).toContain('"insert":["{{system}}","{{system}}"]')
expect(out).toContain('"keepStart":1')
expect(out).toContain('"keepEnd":4')
expect(out).toContain('"config":{"model":"m2"}')
expect(out).not.toContain('leaked prompt line')
expect(out).not.toContain('{{tools}}') // no tools delta → none invented
})
it('scrubs a header-delta tools payload but keeps the added/removed/changed names', () => {
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: {
tools: {
added: [{ name: 'grep', description: 'Search files.', parameters: { type: 'object' } }],
removed: ['bash_kill'],
changed: [{ name: 'read', description: 'Read v2.', parameters: { type: 'object' } }],
},
},
})
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
// WHICH tools changed is behavior and survives; their bulk does not.
expect(out).toContain('"added":[{"name":"grep","description":"{{tools}}","parameters":"{{tools}}"}]')
expect(out).toContain('"removed":["bash_kill"]')
expect(out).toContain('"changed":[{"name":"read","description":"{{tools}}","parameters":"{{tools}}"}]')
expect(out).not.toContain('Search files')
expect(out).not.toContain('Read v2')
})
it('passes every other line through byte-for-byte and is idempotent', () => {
const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } })
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { system: { keepStart: 0, keepEnd: 0, insert: ['x'] }, tools: { added: [{ name: 't', description: 'd', parameters: {} }], removed: [], changed: [] } },
})
const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${delta}\n${other}\n`
const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${other}\n`
const once = scrubRequestHeaders(raw)
expect(once.split('\n')[0]).toBe(headerLine)
expect(once.split('\n')[3]).toBe(other)
expect(once.split('\n')[2]).toBe(other)
expect(scrubRequestHeaders(once)).toBe(once)
})
})
@@ -280,12 +210,15 @@ describe('scrubSystemPrompts', () => {
reason: 'initial',
},
})
const delta = JSON.stringify({
type: 'request/header-delta', seq: 2, time: 3,
const changed = JSON.stringify({
type: 'request/header', seq: 2, time: 3,
data: {
system: { keepStart: 1, keepEnd: 2, insert: ['new prompt line'] },
tools: { changed: [{ name: 'read', description: 'changed schema' }] },
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
header: {
system: 'new prompt',
tools: [{ name: 'read', description: 'changed schema' }],
messagePrefix: [{ role: 'user', content: [{ type: 'text', text: 'changed prefix' }] }],
},
reason: 'change',
},
})
const toolsOnly = JSON.stringify({
@@ -293,11 +226,10 @@ describe('scrubSystemPrompts', () => {
data: { header: { tools: [{ name: 'read', description: 'schema only' }] }, reason: 'resume' },
})
const out = scrubSystemPrompts(`${header}\n${delta}\n${toolsOnly}\n`)
const out = scrubSystemPrompts(`${header}\n${changed}\n${toolsOnly}\n`)
expect(out).toContain('"system":"{{system}}"')
expect(out).toContain('"insert":["{{system}}"]')
expect(out).not.toContain('full prompt')
expect(out).not.toContain('new prompt line')
expect(out).not.toContain('new prompt')
expect(out).toContain('full schema')
expect(out).toContain('full prefix')
expect(out).toContain('changed schema')

View File

@@ -9,9 +9,8 @@ import {
childFixturePaths,
fixtureContext,
formatSystemPromptSnapshot,
headerDeltaCount,
headerChangeCount,
normalizedHeaders,
normalizedSystemPromptDeltas,
normalizedSystemPrompts,
refreshFixtureReplacements,
stabilizeRefreshLog,
@@ -51,7 +50,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.
// is what this suite can exercise; the real overlay boot is the acp-agent
// example's code-mode scenarios).
const REPLAY_SCENARIOS: Scenario[] = [
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderDeltas: 1, headerClass: 'main' },
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' },
{ name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath },
{ name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' },
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' },
@@ -132,7 +131,9 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => {
expect(readFileSync(join(refreshDir, 'pin-turn', 'system-prompt.golden.md'), 'utf8')).toBe([
'SYS PROMPT',
'',
'<!-- request/header-delta 1: keepStart=1, keepEnd=0 -->',
'<!-- request/header change 1 -->',
'',
'SYS PROMPT',
'',
'NEW PROMPT LINE',
'',
@@ -247,46 +248,30 @@ describe('normalizedSystemPrompts', () => {
})
})
describe('normalizedSystemPromptDeltas', () => {
it('extracts and normalizes well-formed system edits', () => {
const log = [
'{"type":"request/header-delta","data":{"system":{"keepStart":1,"keepEnd":0,"insert":["work in /w"]}}}',
'{"type":"request/header-delta","data":{"tools":{"replace":[]}}}',
'{"type":"request/header-delta","data":{"system":{"keepStart":"1","keepEnd":0,"insert":[]}}}',
'{"type":"request/header-delta","data":{"system":{"keepStart":1,"keepEnd":0,"insert":[null]}}}',
'',
].join('\n')
expect(normalizedSystemPromptDeltas(log, { sessionIds: [], cwd: '/w' })).toEqual([
{ keepStart: 1, keepEnd: 0, insert: ['work in {{cwd}}'] },
])
})
})
describe('formatSystemPromptSnapshot', () => {
it('adds a missing terminal newline without changing an existing one', () => {
expect(formatSystemPromptSnapshot('prompt')).toBe('prompt\n')
expect(formatSystemPromptSnapshot('prompt\n')).toBe('prompt\n')
})
it('renders readable system-prompt delta sections', () => {
expect(formatSystemPromptSnapshot('prompt', [
{ keepStart: 1, keepEnd: 0, insert: ['new', 'lines'] },
])).toBe('prompt\n\n<!-- request/header-delta 1: keepStart=1, keepEnd=0 -->\n\nnew\nlines\n')
it('renders readable changed-prompt sections', () => {
expect(formatSystemPromptSnapshot('prompt', ['new\nlines']))
.toBe('prompt\n\n<!-- request/header change 1 -->\n\nnew\nlines\n')
})
it('does not double the newline of a delta insert with a trailing blank line', () => {
expect(formatSystemPromptSnapshot('prompt\n', [
{ keepStart: 2, keepEnd: 1, insert: ['tail', ''] },
])).toBe('prompt\n\n<!-- request/header-delta 1: keepStart=2, keepEnd=1 -->\n\ntail\n')
it('does not double the newline of a changed prompt', () => {
expect(formatSystemPromptSnapshot('prompt\n', ['changed\n']))
.toBe('prompt\n\n<!-- request/header change 1 -->\n\nchanged\n')
})
})
describe('headerDeltaCount', () => {
it('counts request/header-delta events, ignoring blanks and other lines', () => {
const delta = JSON.stringify({ type: 'request/header-delta', seq: 2, time: 9, data: {} })
const other = JSON.stringify({ type: 'request/header', seq: 0, time: 9, data: {} })
expect(headerDeltaCount(`${other}\n\n${delta}\n${delta}\n`)).toBe(2)
expect(headerDeltaCount(`${other}\n`)).toBe(0)
describe('headerChangeCount', () => {
it('counts changed request headers, ignoring anchors, blanks, and other lines', () => {
const change = JSON.stringify({ type: 'request/header', seq: 2, time: 9, data: { reason: 'change' } })
const anchor = JSON.stringify({ type: 'request/header', seq: 0, time: 9, data: { reason: 'initial' } })
const other = JSON.stringify({ type: 'turn/start', seq: 1, time: 9, data: {} })
expect(headerChangeCount(`${anchor}\n${other}\n\n${change}\n${change}\n`)).toBe(2)
expect(headerChangeCount(`${anchor}\n`)).toBe(0)
})
})

View File

@@ -39,7 +39,7 @@ Agent status (per agent):
Model requests (on `llm/stream`):
- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the fold of the log's `request/header*` events (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
- **a loop-built request is exactly what the log reconstructs** — a frozen request with a live `sessionId` (the loop-built marker; hand-built one-shots like compaction's summarize are unfrozen and skipped) must carry frozen `messages` deep-equal to the derivation over the log prefix strictly before the in-flight step's `step/start` (rebuilt through a FRESH `Session`, so the live cache cannot vouch for itself — and boundary-correct: content logged after `step/start` legitimately belongs to the next request), and every non-content field must equal the latest logged `request/header` (see [the reconstructability RFC](../../../docs/rfc/implemented/architecture/2026-07-05-reconstructable-requests.md)). Registered with `prepend: true` so a short-circuiting `llm/stream` listener (the replay adapter) cannot silence it; prepend orders it against append-registered listeners only — correctness rests on the seq-bounded rebuild, never listener timing.
On any violation it throws `InvariantError` (`code: 'INVARIANT'`).

View File

@@ -60,8 +60,8 @@ interface SessionTrace {
/** Every seq seen so far — validates `sourceEventSeqs` references. */
knownSeqs: Set<number>
/**
* The seqs currently on the surface linked list, in linked-list order
* (head to tail). A replace reorders this relative to seq order (the new
* The seqs currently on the surface, in derived-message order. A replace
* reorders this relative to seq order (the new
* node takes the replaced range's position), so range validation is
* positional, not by seq comparison.
*/
@@ -154,7 +154,7 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
}
}
}
// Fold this event into the tracked surface linked list, validating the
// Fold this event into the tracked surface order, validating the
// replace contract as we go. `append` adds a tail node; `replace` shadows a
// positional range — every shadowed node must appear in sourceEventSeqs.
if (se.surfaceOp !== undefined) {
@@ -497,7 +497,7 @@ export function apply(ctx: Context): void {
// the boundary (an `agent/request`-window inject) is legitimately absent
// from this request, and a current-surface comparison would false-fire.
// - header: every non-content field must equal the fold of the log's
// `request/header*` events — the loop logs the header event BEFORE
// `request/header` events — the loop logs the header event BEFORE
// dispatch, so the fold already covers this request.
//
// Registered with `prepend: true` so a short-circuiting llm/stream listener

View File

@@ -635,7 +635,7 @@ describe('surface invariants', () => {
session.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
session.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 3
// Replace node 2 (position 0) with seq 4 — surface is now [4, 3], so seq 4
// precedes seq 3 in linked-list order even though 4 > 3 numerically.
// precedes seq 3 in surface order even though 4 > 3 numerically.
session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [2] }) // seq 4
// A replace with start=3, end=4 passes the seq check (3 <= 4) but is
// reversed positionally (3 is at pos 1, 4 is at pos 0).
@@ -745,7 +745,7 @@ describe('request-reconstruction cross-check (llm/stream)', () => {
it('expects the folded header\'s session prefix ahead of the derivation (prefix + derived)', async () => {
const { ctx, session, boundary } = await requestSetup()
const prefix = { role: 'user' as const, content: [{ type: 'text' as const, text: '<system-reminder>catalog</system-reminder>' }] }
session.append('request/header-delta', { messagePrefix: [prefix] })
session.append('request/header', { header: { config: { model: 'm' }, messagePrefix: [prefix] }, reason: 'change' })
// The prefixed request matches the fold…
const prefixed = Object.freeze({ model: 'm', messages: Object.freeze([prefix, ...boundary]), sessionId: session.id })
expect(() => { dispatch(ctx, prefixed) }).not.toThrow()

View File

@@ -6,7 +6,7 @@ The contract in one line: `ctx.approval.request(req)` puts exactly one question
The service is the mechanism, answerers are the policy. Answerers are `approval/request` waterfall listeners occupying a single decision slot: answer for an agent you own by returning an outcome without calling `next()`, or delegate an agent you don't recognize by calling `next()` — the chain's built-in default is `unavailable`, so a deployment with no answerer (headless, CI) fails closed with zero configuration. Dispatch is keyed by `req.agent`: a listener registered through `agent.ctx` receives only that agent's questions, while a plain-context listener receives every agent's. Registration order across sibling plugins is not load-order deterministic; compose one terminal answerer per deployment and use `prepend` listeners only for decide-or-delegate gates.
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header*` reads `changed by the user`, otherwise `changed by the operator/config`).
The seam also owns the per-session POLICY tier ([the sandbox RFC § Per-session mode switching](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)): `ApprovalPolicy` is `'ask'` (delegate to the answerers) or `'never'` (deterministically reject without prompting anyone; the strict CI/unattended stance), with `effective = fold(the session's 'approval/policy' events, last one wins) ?? Config.policy` — the session log is the store, written only through `setApprovalPolicy(session, policy)`, which rejects any value outside that closed vocabulary before appending. The service decides `'never'` inside `request()` itself, before dispatching the waterfall (`'never'` → `'rejected'` with the audit pair still landing; no listener registration, including a later `prepend`, can precede it), states `'never'` — and only `'never'` in prose — in a per-agent prompt section, records either value with a source-owned header marker, and narrates a policy switch to the model in at most one coalesced `agent/pre-step` notice. The restart fallback reads the marker rather than deployment-controlled persona prose; attribution is positional (an override event after the last `request/header` reads `changed by the user`, otherwise `changed by the operator/config`).
One seam serves both ask paths of [the sandbox RFC](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md): the `tools/pre-execute` `ask` decision (routed by [`@deepseek-ai/dsh-tools`](../../core/tools/) when this service is mounted; degrading to deny when it is not), and the sandbox post-denial escalated retry (the bash tool's `sandbox_permissions` gate in [`@deepseek-ai/dsh-tool-bash`](../../bash/tool-bash/) — [the sandbox RFC § Escalation](../../../docs/rfc/implemented/feature/2026-07-06-sandbox.md)). The full design: [the approval-seam RFC](../../../docs/rfc/implemented/feature/2026-07-06-approval-seam.md).

View File

@@ -102,7 +102,7 @@ declare module '@deepseek-ai/dsh-session' {
* from the prompt section and the narrator's notices). The LAST such
* event is the session's override ({@link effectiveApprovalPolicy});
* who asked for it is derivable from position (an event after the log's
* last `request/header*` was a runtime switch by the user).
* last `request/header` was a runtime switch by the user).
*/
'approval/policy': { policy: ApprovalPolicy }
}
@@ -328,7 +328,7 @@ export class ApprovalService extends Service {
// narrated no later than the next step. What each session was last told
// is in-memory with a log-derived fallback (the folded header's system
// text), so restarts lose nothing. Attribution is positional: an
// override event after the log's last `request/header*` was a runtime
// override event after the log's last `request/header` was a runtime
// switch by the user; otherwise the configured default moved under the
// session (operator/config).
const narrated = new WeakMap<Agent['session'], ApprovalPolicy>()
@@ -341,7 +341,7 @@ export class ApprovalService extends Service {
const event = events[index] as (typeof events)[number]
if (overrideIndex < 0 && event.type === 'approval/policy') {
overrideIndex = index
} else if (headerIndex < 0 && (event.type === 'request/header' || event.type === 'request/header-delta')) {
} else if (headerIndex < 0 && event.type === 'request/header') {
headerIndex = index
}
}