Merge remote-tracking branch 'origin/master' into feat/plan-mode

This commit is contained in:
Tianyi Cui
2026-07-17 22:09:44 +08:00
97 changed files with 10281 additions and 559 deletions

View File

@@ -9,7 +9,7 @@ This is the implementation tier of the compaction capability — see the [interf
This backend owns the compaction policy:
- **Estimation** — a configurable characters-per-token heuristic counts the current session prefix supplied to pre-step, derived history, and system prompt, matching the next request rather than stale logged prefix state.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts. Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes; a single unit larger than the budget remains out of scope.
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured model and cap without running the loop-only `agent/request` seam. The input transcript preserves non-text blocks as tagged placeholders; only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.

View File

@@ -7,12 +7,11 @@
*/
import { Context } from 'cordis'
import { CompactService, renderTranscript } from '@deepseek-ai/dsh-compact'
import { CompactService, renderTranscript, toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import type { CompactionResult } from '@deepseek-ai/dsh-compact'
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { BasicCompactConfig, ResolvedConfig } from './types.ts'
import { resolveConfig } from './types.ts'
@@ -348,15 +347,14 @@ export class BasicCompactService extends CompactService {
}
// Both range edges must preserve assistant tool-call/result pairing.
const events = session.events
if (!isToolPairingBalanced(nodes, events, start)) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const startNode = nodes[startIdx]!
if (!toolPairingBalancedBefore(session, startNode)) {
throw new Error(`compactRegion: start seq ${start} is not a balanced boundary (would split a step's tool-call/result pair)`)
}
// 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
if (!isToolPairingBalanced(nodes, events, afterEnd)) {
const endNode = nodes[endIdx]!
if (!toolPairingBalancedAfter(session, endNode)) {
throw new Error(`compactRegion: end seq ${end} is not a balanced boundary (would split a step, or the step is still open)`)
}
@@ -511,7 +509,7 @@ export class BasicCompactService extends CompactService {
// splitting an assistant↔result pair.
while (keepFromIdx > 0) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
if (isToolPairingBalanced(nodes, events, nodes[keepFromIdx]!.seq)) break
if (toolPairingBalancedBefore(session, nodes[keepFromIdx]!)) break
keepFromIdx -= 1
}
if (keepFromIdx === 0) return null

View File

@@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm'
import { isToolPairingBalanced } from '@deepseek-ai/dsh-session'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
@@ -118,9 +118,9 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', ()
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),
expect(toolPairingBalancedBefore(agent.session, node),
`checkpoint seq ${node.seq} must be a balanced region START`).toBe(true)
expect(isToolPairingBalanced(nodes, events, node.next),
expect(toolPairingBalancedAfter(agent.session, node),
`checkpoint seq ${node.seq} must be a balanced region END`).toBe(true)
}
} finally {

View File

@@ -6,7 +6,7 @@ This package is the interface tier of the compaction capability, split so each c
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` + tool-pairing boundary helpers + the shared transcript renderer (`renderTranscript`/`renderContentBlocks`) |
| `@deepseek-ai/dsh-compact-basic` | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
@@ -23,6 +23,12 @@ Both methods are **abstract** — the backend owns the entire strategy (token es
`compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value.
## Tool-pairing boundaries
The interface exports `toolPairingBalancedBefore(session, node)` and `toolPairingBalancedAfter(session, node)` for snapping and validating compaction edges. A safe edge has no unanswered assistant tool call crossing it. Each helper identifies the node by seq alone and answers from balances cached per cut in current surface order, so a stale caller-held `node.next` cannot choose the cut.
The private per-session cache is keyed by `session.surface.replaceGeneration` and the processed surface-node count. An unchanged generation extends the fold with unseen tail nodes only; a log-only append with no new surface node does no event reads, while a replacement generation rebuilds current membership and balances. Missing event seqs and a `tool/result` without a preceding open call reject as corrupt surface state.
## Surface contract
`SurfaceEventType` is a closed union — only `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` may carry `surfaceOp`. A `compact/*` event therefore **cannot** appear on the surface. A successful compaction instead:

View File

@@ -14,6 +14,7 @@ import type { CompactionResult } from './types.ts'
export type { CompactionResult } from './types.ts'
export { renderContentBlocks, renderTranscript } from './render.ts'
export { toolPairingBalancedAfter, toolPairingBalancedBefore } from './tool-pairing.ts'
/** Minimal agent context compaction needs without depending on the agent package. */
export interface CompactAgentContext {
@@ -67,6 +68,8 @@ export abstract class CompactService extends Service {
* balanced so assistant tool calls remain paired with their results. A model-
* backed implementation forwards cancellation and rejects active, missing,
* reversed, or unbalanced ranges.
* Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter}
* for the edge checks.
*
* @param session - session to mutate.
* @param start - first surface seq, inclusive.

View File

@@ -0,0 +1,132 @@
/**
* Tool-pairing balance over a session surface. Compaction changes surface
* positions, so safe cuts are derived from tool-call/result content in current
* surface order rather than step markers or linked-list fields supplied by a
* caller.
* @module @deepseek-ai/dsh-compact/tool-pairing
*/
import type { Session, SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
/** Incremental balance state for one session surface generation. */
interface BalanceCache {
/** Surface rewrite generation this state describes. */
generation: number
/**
* Balance of every surface cut in current order: a surface of N nodes has
* N + 1 cuts, entry `i` being the cut before node `i` and the final entry
* the cut after the surface tail.
*/
cutBalanced: readonly boolean[]
/** Current surface position of each node seq, indexing {@link cutBalanced}. */
indexBySeq: Map<number, number>
/** In-progress tool-call count after the processed surface tail. */
inProgressToolCalls: number
}
const balanceCacheBySession = new WeakMap<Session, BalanceCache>()
/** Return how one surface event changes the in-progress tool-call count. */
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
default:
return 0
}
}
/** Read and validate the event named by a surface node. */
function eventForNode(events: readonly SessionEvent[], node: SurfaceNode): SessionEvent {
const event = events[node.seq]
if (event === undefined || event.seq !== node.seq) {
throw new Error(`tool-pairing balance: surface seq ${node.seq} has no matching session event (corrupt surface)`)
}
return event
}
/** Fold surface nodes not yet in the cache into its balance state. */
function extendCache(
session: Session,
cache: BalanceCache,
nodes: readonly SurfaceNode[],
): BalanceCache {
const processed = cache.cutBalanced.length - 1
const tail = nodes.slice(processed)
// Validate the unseen tail before mutating the live cache, so a corrupt
// append cannot leave a partially advanced state behind.
const events = session.events
const pendingCuts: boolean[] = []
let inProgressToolCalls = cache.inProgressToolCalls
for (const node of tail) {
inProgressToolCalls += nodeDelta(eventForNode(events, node))
if (inProgressToolCalls < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
pendingCuts.push(inProgressToolCalls === 0)
}
tail.forEach((node, offset) => cache.indexBySeq.set(node.seq, processed + offset))
cache.cutBalanced = cache.cutBalanced.concat(pendingCuts)
cache.inProgressToolCalls = inProgressToolCalls
return cache
}
/** Return balance state synchronized with the current session surface. */
function balanceCache(session: Session): BalanceCache {
const surface = session.surface
const nodes = surface.nodes
const generation = surface.replaceGeneration
const cached = balanceCacheBySession.get(session)
if (cached === undefined || cached.generation !== generation || cached.cutBalanced.length - 1 > nodes.length) {
// A rebuild is the same fold started from the empty-surface state, whose
// single leading cut is trivially balanced.
const rebuilt = extendCache(session, {
generation,
cutBalanced: [true],
indexBySeq: new Map(),
inProgressToolCalls: 0,
}, nodes)
balanceCacheBySession.set(session, rebuilt)
return rebuilt
}
if (cached.cutBalanced.length - 1 < nodes.length) return extendCache(session, cached, nodes)
return cached
}
/** Balance of the cut at a node's position plus offset, rejecting seqs outside current membership. */
function cutBalance(cache: BalanceCache, seq: number, offset: 0 | 1): boolean {
const index = cache.indexBySeq.get(seq)
const balanced = index === undefined ? undefined : cache.cutBalanced[index + offset]
if (balanced === undefined) {
throw new Error(`tool-pairing balance: surface seq ${seq} not found`)
}
return balanced
}
/**
* Whether the cut immediately before a current surface node is tool-pairing balanced.
* @param session - session whose surface is checked.
* @param node - surface node whose leading cut is checked; only its seq identifies it.
* @returns true when no unanswered tool call crosses the cut.
* @throws when the seq is absent from the current surface, a surface node has no
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedBefore(session: Session, node: SurfaceNode): boolean {
return cutBalance(balanceCache(session), node.seq, 0)
}
/**
* Whether the cut immediately after a current surface node is tool-pairing balanced.
* @param session - session whose surface is checked.
* @param node - surface node whose trailing cut is checked; only its seq identifies it.
* @returns true when no unanswered tool call crosses the cut.
* @throws when the seq is absent from the current surface, a surface node has no
* matching log event, or a tool result has no preceding open call.
*/
export function toolPairingBalancedAfter(session: Session, node: SurfaceNode): boolean {
return cutBalance(balanceCache(session), node.seq, 1)
}

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SurfaceNode } from '@deepseek-ai/dsh-session'
const SURFACE = { surfaceOp: 'append' as const }
function seqOf(session: Session, type: SessionEvent['type'], nth = 0): number {
return session.events.filter(event => event.type === type)[nth]!.seq
}
function nodeAt(session: Session, seq: number): SurfaceNode {
const node = session.surface.nodes.find(candidate => candidate.seq === seq)
if (node === undefined) throw new Error(`seq ${seq} is not a surface node`)
return node
}
function before(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedBefore(session, nodeAt(session, seqOf(session, type, nth)))
}
function after(session: Session, type: SessionEvent['type'], nth = 0): boolean {
return toolPairingBalancedAfter(session, nodeAt(session, seqOf(session, type, nth)))
}
function closedToolStep(): Session {
const session = new Session(SessionId('closed-tool-step'))
session.append('user/message', {
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}, SURFACE)
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('c1'),
content: [{ type: 'text', text: 'done' }],
isError: false,
}, SURFACE)
return session
}
describe('tool-pairing boundaries', () => {
it('classifies closed and open single-call steps', () => {
const closed = closedToolStep()
expect(before(closed, 'user/message')).toBe(true)
expect(after(closed, 'user/message')).toBe(true)
expect(before(closed, 'assistant/message')).toBe(true)
expect(after(closed, 'assistant/message')).toBe(false)
expect(before(closed, 'tool/result')).toBe(false)
expect(after(closed, 'tool/result')).toBe(true)
const open = new Session(SessionId('open-tool-step'))
open.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('open'), name: 'bash', arguments: '{}' }],
}, SURFACE)
expect(toolPairingBalancedAfter(open, open.surface.nodes[0]!)).toBe(false)
})
it('requires every result from a multiple-call assistant message', () => {
const session = new Session(SessionId('multiple-calls'))
session.append('assistant/message', {
turn: 1,
step: 1,
content: [
{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' },
],
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
}, SURFACE)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c2'), content: [], isError: false,
}, SURFACE)
expect(after(session, 'tool/result', 0)).toBe(false)
expect(after(session, 'tool/result', 1)).toBe(true)
})
it('keeps neutral nodes inside an open pair unbalanced and free nodes balanced', () => {
const midStep = new Session(SessionId('neutral-mid-step'))
midStep.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
midStep.append('context/message', {
content: [{ type: 'text', text: 'background update' }],
source: { kind: 'plugin', plugin: 'test' },
}, SURFACE)
midStep.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false,
}, SURFACE)
expect(before(midStep, 'context/message')).toBe(false)
expect(after(midStep, 'context/message')).toBe(false)
const free = new Session(SessionId('neutral-free'))
free.append('context/message', {
content: [{ type: 'text', text: 'idle injection' }],
source: { kind: 'user' },
}, SURFACE)
expect(before(free, 'context/message')).toBe(true)
expect(after(free, 'context/message')).toBe(true)
})
})
describe('tool-pairing surface identity', () => {
it('rebuilds after replace and rejects nodes removed from current membership', () => {
const session = closedToolStep()
const staleTail = nodeAt(session, seqOf(session, 'tool/result'))
expect(toolPairingBalancedAfter(session, staleTail)).toBe(true)
const nodes = session.surface.nodes
session.append('user/message', {
content: [{ type: 'text', text: 'checkpoint' }],
source: { kind: 'plugin', plugin: 'compact' },
}, {
surfaceOp: { op: 'replace', start: nodes[0]!.seq, end: nodes.at(-1)!.seq },
sourceEventSeqs: nodes.map(node => node.seq),
})
const checkpoint = session.surface.nodes[0]!
expect(toolPairingBalancedBefore(session, checkpoint)).toBe(true)
expect(toolPairingBalancedAfter(session, checkpoint)).toBe(true)
expect(() => toolPairingBalancedBefore(session, staleTail)).toThrow(/surface seq .* not found/)
expect(() => toolPairingBalancedAfter(session, staleTail)).toThrow(/surface seq .* not found/)
})
it('ignores a caller-held node next field and answers from cached balances', () => {
const session = closedToolStep()
const assistant = nodeAt(session, seqOf(session, 'assistant/message'))
expect(toolPairingBalancedAfter(session, { ...assistant, next: null })).toBe(false)
expect(toolPairingBalancedAfter(session, { ...assistant, next: 999 })).toBe(false)
})
it('rejects missing seqs before and after, including an empty surface', () => {
const session = new Session(SessionId('missing-membership'))
const missing: SurfaceNode = { seq: 999, prev: null, next: null }
expect(() => toolPairingBalancedBefore(session, missing)).toThrow(/surface seq 999 not found/)
expect(() => toolPairingBalancedAfter(session, missing)).toThrow(/surface seq 999 not found/)
session.append('user/message', {
content: [{ type: 'text', text: 'first node after empty cache' }],
source: { kind: 'user' },
}, SURFACE)
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
})
})
describe('tool-pairing cache refresh', () => {
it('does no event reads for unchanged or log-only growth, folds only appended nodes, and rebuilds on replace', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
data: { content: [{ type: 'text', text: 'user' }], source: { kind: 'user' } },
surfaceOp: 'append',
},
{
type: 'assistant/message', seq: 1, time: 1,
data: { turn: 1, step: 1, content: [{ type: 'tool-call', id: CallId('c1'), name: 'one', arguments: '{}' }] },
surfaceOp: 'append',
},
{
type: 'tool/result', seq: 2, time: 2,
data: { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false },
surfaceOp: 'append',
},
]
const nodes: SurfaceNode[] = [
{ seq: 0, prev: null, next: 1 },
{ seq: 1, prev: 0, next: 2 },
{ seq: 2, prev: 1, next: null },
]
let generation = 0
let eventCollectionReads = 0
let eventIndexReads = 0
const trackedEvents = new Proxy(events, {
get(target, property, receiver) {
if (typeof property === 'string' && /^\d+$/.test(property)) eventIndexReads += 1
return Reflect.get(target, property, receiver) as unknown
},
})
const surface = {
get nodes() { return nodes },
get replaceGeneration() { return generation },
}
const session = {
surface,
get events() {
eventCollectionReads += 1
return trackedEvents
},
} as unknown as Session
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
expect(toolPairingBalancedBefore(session, nodes[0]!)).toBe(true)
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(false)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
events.push({
type: 'turn/end', seq: 3, time: 3,
data: { turn: 1, reason: { kind: 'completed' } },
})
expect(toolPairingBalancedAfter(session, nodes[2]!)).toBe(true)
expect(eventCollectionReads).toBe(1)
expect(eventIndexReads).toBe(3)
events.push({
type: 'user/message', seq: 4, time: 4,
data: { content: [{ type: 'text', text: 'tail' }], source: { kind: 'user' } },
surfaceOp: 'append',
})
nodes.push({ seq: 4, prev: 2, next: null })
expect(toolPairingBalancedAfter(session, nodes[3]!)).toBe(true)
expect(eventCollectionReads).toBe(2)
expect(eventIndexReads).toBe(4)
events.push(
{
type: 'assistant/message', seq: 5, time: 5,
data: { turn: 2, step: 1, content: [{ type: 'tool-call', id: CallId('c2'), name: 'two', arguments: '{}' }] },
surfaceOp: 'append',
},
{
type: 'tool/result', seq: 6, time: 6,
data: { turn: 2, step: 1, callId: CallId('c2'), content: [], isError: false },
surfaceOp: 'append',
},
)
nodes.push(
{ seq: 5, prev: 4, next: 6 },
{ seq: 6, prev: 5, next: null },
)
expect(toolPairingBalancedAfter(session, nodes[5]!)).toBe(true)
expect(eventCollectionReads).toBe(3)
expect(eventIndexReads).toBe(6)
events.push({
type: 'user/message', seq: 7, time: 7,
data: { content: [{ type: 'text', text: 'replacement' }], source: { kind: 'user' } },
surfaceOp: { op: 'replace', start: 0, end: 6 },
})
nodes.splice(0, nodes.length, { seq: 7, prev: null, next: null })
generation += 1
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
expect(eventCollectionReads).toBe(4)
expect(eventIndexReads).toBe(7)
})
it('rebuilds defensively when a same-generation surface node count regresses', () => {
const events: SessionEvent[] = [
{
type: 'user/message', seq: 0, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
},
{
type: 'user/message', seq: 1, time: 1,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
},
]
const nodes: SurfaceNode[] = [
{ seq: 0, prev: null, next: 1 },
{ seq: 1, prev: 0, next: null },
]
const session = {
events,
surface: { nodes, replaceGeneration: 0 },
} as unknown as Session
expect(toolPairingBalancedAfter(session, nodes[1]!)).toBe(true)
nodes.pop()
expect(toolPairingBalancedAfter(session, nodes[0]!)).toBe(true)
})
})
describe('tool-pairing corrupt surfaces', () => {
it('throws for an orphan result during a rebuild', () => {
const session = new Session(SessionId('orphan-rebuild'))
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
}, SURFACE)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toThrow(/no matching tool-call/)
})
it('retries an orphan result in an appended tail without committing partial cache state', () => {
const session = new Session(SessionId('orphan-tail'))
session.append('user/message', {
content: [{ type: 'text', text: 'safe head' }], source: { kind: 'user' },
}, SURFACE)
expect(toolPairingBalancedAfter(session, session.surface.nodes[0]!)).toBe(true)
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('orphan'), content: [], isError: false,
}, SURFACE)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
expect(() => toolPairingBalancedAfter(session, session.surface.nodes[1]!)).toThrow(/no matching tool-call/)
})
it('throws when a current surface seq has no matching event or indexes the wrong event', () => {
const missingNode: SurfaceNode = { seq: 1, prev: null, next: null }
const missing = {
events: [{
type: 'user/message', seq: 0, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [missingNode], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(missing, missingNode)).toThrow(/no matching session event/)
const mismatchedNode: SurfaceNode = { seq: 0, prev: null, next: null }
const mismatched = {
events: [{
type: 'user/message', seq: 99, time: 0,
data: { content: [], source: { kind: 'user' } }, surfaceOp: 'append',
} satisfies SessionEvent],
surface: { nodes: [mismatchedNode], replaceGeneration: 0 },
} as unknown as Session
expect(() => toolPairingBalancedBefore(mismatched, mismatchedNode)).toThrow(/no matching session event/)
})
})

View File

@@ -79,7 +79,7 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `create(id, { seed })` validates and freezes a contiguous log and rebuilds its surface. `fork(source, boundary?, childSessionId?)` selects a completed-turn prefix and records lineage.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint.
- Compaction: the `dsh-compact-basic` plugin appends a `user/message` with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes behind a summary checkpoint. Tool-pairing boundary policy and its cache belong to the [`dsh-compact` seam](../../compact/compact/README.md), while this package owns surface membership, positional links, and `replaceGeneration`.
## Model Experience

View File

@@ -24,7 +24,6 @@ export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceFoldReplacement, SurfaceFoldResult, SurfaceNode } from './surface.ts'
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { isToolPairingBalanced } from './tool-pairing.ts'
export { applyHeaderDelta, canonicalHeader, diffHeader, foldRequestHeader, headerEquals } from './request-header.ts'
declare module 'cordis' {

View File

@@ -1,56 +0,0 @@
/**
* Tool-pairing balance over a session surface. Compaction changes surface
* positions, so safe cuts are derived from tool-call/result content on the
* surface rather than step markers in the append-only log.
* @module @deepseek-ai/dsh-session/tool-pairing
*/
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
* unanswered tool calls. An `assistant/message` opens one bracket per
* `tool-call` block; a `tool/result` closes one; every other surface node
* (`user/message`, `context/message`, `steering/message`, a usage-only
* `assistant/message` with no tool-call blocks) is pairing-neutral.
*/
function nodeDelta(event: SessionEvent): number {
switch (event.type) {
case 'assistant/message':
return event.data.content.filter(block => block.type === 'tool-call').length
case 'tool/result':
return -1
// Non-pairing surface nodes and every non-surface event contribute nothing.
default:
return 0
}
}
/**
* Check that a surface cut does not split a tool call from its result. A region
* is safe to collapse only when the cuts before its first node and after its
* last node both return `true`.
* @param nodes - the surface linked list in head→tail order.
* @param events - the session log each node's `seq` indexes into.
* @param beforeSeq - node immediately after the cut; `null` or a seq absent from the surface means after-tail.
* @returns whether every call before the cut has its result before the cut.
* @throws if a result appears without a preceding open call.
*/
export function isToolPairingBalanced(
nodes: readonly SurfaceNode[],
events: readonly SessionEvent[],
beforeSeq: number | null,
): boolean {
let depth = 0
for (const node of nodes) {
if (node.seq === beforeSeq) return depth === 0
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
depth += nodeDelta(events[node.seq]!)
if (depth < 0) {
throw new Error(`tool-pairing balance: tool/result at surface seq ${node.seq} has no matching tool-call (corrupt surface)`)
}
}
// A missing cut node means the after-tail boundary.
return depth === 0
}

View File

@@ -1,293 +0,0 @@
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'
/**
* Unit coverage for compaction-cut safety: a cut is balanced only when it
* separates no assistant tool call from its result. Non-step nodes are neutral,
* and replace operations prove surface order—not raw log order—is authoritative.
*/
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[] } {
return { nodes: session.surface.nodes, events: session.events }
}
/** The cut BEFORE the surface node at `seq` is balanced (safe region start). */
function startBalanced(session: Session, seq: number): boolean {
const { nodes, events } = surfaceOf(session)
return isToolPairingBalanced(nodes, events, seq)
}
/** 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)
}
/** Surface seq of the nth (0-based) event of a given type. */
function seqOf(s: Session, type: SessionEvent['type'], nth = 0): number {
return s.events.filter(e => e.type === type)[nth]!.seq
}
/** A closed turn with one closed step holding an assistant + its tool result. */
function toolStepSession(): Session {
const s = new Session(SessionId('tool-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }, SURFACE)
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'text', text: 'calling' },
{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' },
],
}, SURFACE)
s.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{}' })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
describe('isToolPairingBalanced — region START (cut before a node)', () => {
it('is true for a pre-step user/message (belongs to no step)', () => {
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
it('is true for the first surface node of a step (the assistant/message)', () => {
// The cut before the assistant is balanced — nothing unanswered precedes it.
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'assistant/message'))).toBe(true)
})
it('is false for a tool/result whose assistant/message precedes it in the same step', () => {
// The cut before the tool/result has one unanswered tool-call (the
// assistant's) → starting the region here would orphan that call.
const s = toolStepSession()
expect(startBalanced(s, seqOf(s, 'tool/result'))).toBe(false)
})
it('is true at the surface head (nothing precedes)', () => {
const s = new Session(SessionId('lone'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
expect(startBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — region END (cut after a node)', () => {
it('is true for the last surface node of a closed step (the tool/result)', () => {
// After the tool/result the assistant's single call is answered → balanced.
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'tool/result'))).toBe(true)
})
it('is false for an assistant/message with a later tool/result in the same step', () => {
// After the assistant its tool-call is still unanswered → ending here strands
// the result.
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
})
it('is true for a pre-step user/message', () => {
const s = toolStepSession()
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
it('is false at the tail when the node is inside an open (unclosed) step', () => {
// step/start then an assistant tool-call, but no tool/result yet (mid-flight).
// The after-tail cut still has one unanswered call → not balanced.
const s = new Session(SessionId('open-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
expect(endBalanced(s, seqOf(s, 'assistant/message'))).toBe(false)
})
it('is true at the tail when the node is a trailing inter-step node (step already closed)', () => {
// A steering message appended after step/end, at the tail. The prior step's
// pair is balanced and steering is neutral → the after-tail cut is balanced.
const s = new Session(SessionId('trailing-steer'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 's' }], source: { kind: 'user' } }, SURFACE)
expect(endBalanced(s, seqOf(s, 'steering/message'))).toBe(true)
})
it('is true at the tail when no step ever opened', () => {
const s = new Session(SessionId('no-step'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, SURFACE)
expect(endBalanced(s, seqOf(s, 'user/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — multiple tool calls in one assistant message', () => {
// An assistant message with two tool-calls needs BOTH results before the cut
// after it is balanced — depth +2, then -1, -1.
function twoCallStep(): Session {
const s = new Session(SessionId('two-call'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'tool-call', id: CallId('c1'), name: 'a', arguments: '{}' },
{ type: 'tool-call', id: CallId('c2'), name: 'b', arguments: '{}' },
],
}, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: '1' }], isError: false }, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c2'), content: [{ type: 'text', text: '2' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('is unbalanced after the first of two results (one call still open)', () => {
const s = twoCallStep()
expect(endBalanced(s, seqOf(s, 'tool/result', 0))).toBe(false)
})
it('is balanced after the second result (both calls answered)', () => {
const s = twoCallStep()
expect(endBalanced(s, seqOf(s, 'tool/result', 1))).toBe(true)
})
})
describe('isToolPairingBalanced — a mid-step injection context/message', () => {
// The injected context is pairing-neutral, but both adjacent cuts remain
// unbalanced because the tool call is still open across them.
function midStepInjection(): Session {
const s = new Session(SessionId('mid-inject'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
s.append('context/message', { content: [{ type: 'text', text: 'bg task done' }], source: { kind: 'plugin', plugin: 'tool-bash' } }, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('start cut before the mid-step context/message is unbalanced (call still open)', () => {
const s = midStepInjection()
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(false)
})
it('end cut after the mid-step context/message is unbalanced (call still open)', () => {
const s = midStepInjection()
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(false)
})
})
describe('isToolPairingBalanced on an injection turn (no step)', () => {
// An idle inject() wraps a context/message in a bare turn/start →
// context/message → turn/end with NO step. The context node is a free boundary
// both ways (pairing-neutral, nothing open around it).
function injectionSession(): Session {
const s = new Session(SessionId('injection'))
s.append('turn/start', { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } })
s.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }, SURFACE)
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
it('start: balanced', () => {
const s = injectionSession()
expect(startBalanced(s, seqOf(s, 'context/message'))).toBe(true)
})
it('end: balanced', () => {
const s = injectionSession()
expect(endBalanced(s, seqOf(s, 'context/message'))).toBe(true)
})
})
describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace op', () => {
// A replacement checkpoint has a high log seq but sits at the surface head;
// its cuts are balanced regardless of later raw-log neighbors.
function checkpointHeadedSession(): Session {
const s = new Session(SessionId('checkpoint'))
// A closed turn with a tool step → surface [u1, asst(call), result].
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('user/message', { content: [{ type: 'text', text: 'u1' }], source: { kind: 'user' } }, SURFACE)
s.append('assistant/message', {
turn: 1, step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }],
}, SURFACE)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }, SURFACE)
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// An OPEN turn whose step is in progress (loop fires compaction here).
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 2, step: 1 })
// Compaction replaces the whole turn-1 surface ([u1, asst, result]) with one
// summary user/message — appended now, so it carries a high log seq.
const u1 = seqOf(s, 'user/message')
const result = s.events.find(e => e.type === 'tool/result')!.seq
const shadowedSeqs = s.surface.nodes.map(node => node.seq)
s.append('user/message', {
content: [{ type: 'text', text: 'CHECKPOINT' }],
source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs })
// The step's own assistant/message lands AFTER the checkpoint in the log,
// still inside the open step.
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
return s
}
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
// 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),
)
expect(laterSurfaceInLog).toBeDefined()
expect(nodes[0]!.seq).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)
})
it('end cut after the head checkpoint is balanced (it carries no tool pair)', () => {
// This is the exact assertion the log-position scan failed: the forward log scan from the
// checkpoint reached the open step's assistant/message and wrongly reported mid-step.
const s = checkpointHeadedSession()
expect(endBalanced(s, s.surface.nodes[0]!.seq)).toBe(true)
})
})
describe('isToolPairingBalanced — corrupt surface guard', () => {
it('throws when a tool/result has no preceding tool-call (depth goes negative)', () => {
// A surface that opens with a tool/result (no assistant call before it) is
// structurally corrupt — surfaced loudly rather than mis-classified.
const s = new Session(SessionId('corrupt'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'x' }], isError: false }, SURFACE)
const { nodes, events } = surfaceOf(s)
expect(() => isToolPairingBalanced(nodes, events, null)).toThrow(/no matching tool-call/)
})
})

View File

@@ -25,6 +25,8 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
`PersistenceCoordinator` owns per-id state, write-behind buffers and serialization, the `session/event``session/flush` drain, lazy materialization, crash-tail repair, session adoption, and quiescent disposal. A first-party backend composes one, implements the small `PersistenceBackend` storage hook interface, and delegates its four public service methods. JSONL and SQLite therefore share lifecycle correctness while retaining different storage primitives; see the [coordinator RFC](../../../docs/rfc/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).
When a live session emits `session/disposed`, the coordinator waits for its initialization, serializes a final buffer drain, then releases every map entry owned by that exact `Session` object. A failed final drain keeps the pending buffer for backend teardown to retry. Backend teardown stops event admission first, awaits all in-flight session retirements and remaining per-id operations, drains any retained buffers, and only then closes the storage handle.
The side-effect-free `locate` query remains backend-owned because it describes storage topology rather than write orchestration.
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):

View File

@@ -126,7 +126,8 @@ function seedCoversPrefix(seed: readonly SessionEvent[], prefix: readonly Sessio
*
* All per-id operations are serialized (a per-id promise chain) so concurrent
* flushes / a flush racing a load never interleave storage writes. The
* constructor installs the write-path listeners and the dispose effect.
* constructor installs the write-path listeners, per-session retirement, and
* the backend dispose effect.
*
* @typeParam TornMarker - the backend's opaque torn-tail repair token.
*/
@@ -146,6 +147,8 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* observation boundary; callers do not inspect this bookkeeping directly.
*/
private inits = new Map<Session, Promise<void>>()
/** Final drains started by fire-and-forget session disposal notifications. */
private retirements = new Set<Promise<void>>()
constructor(private ctx: Context, private backend: PersistenceBackend<TornMarker>) {
this.installWritePath()
@@ -267,7 +270,13 @@ export class PersistenceCoordinator<TornMarker = unknown> {
const next = prior.then(op, op)
// Keep the chain alive but swallow this op's rejection for the NEXT waiter
// (the caller still sees the real rejection via `next`).
this.chains.set(id, next.then(() => undefined, () => undefined))
const tail = next.then(() => undefined, () => undefined)
this.chains.set(id, tail)
// Settled tails carry no serialization value. Delete only the exact tail
// installed above: a later operation may already have replaced it.
void tail.then(() => {
if (this.chains.get(id) === tail) this.chains.delete(id)
})
return next
}
@@ -293,27 +302,12 @@ export class PersistenceCoordinator<TornMarker = unknown> {
private installWritePath(): void {
const ctx = this.ctx
// Capture the header on creation; persist a fork's seed once. Record the init
// promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) })
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
// so the write-behind queue owns exactly the record it will flush rather than
// retaining a product-layer record by identity. Serializability is guaranteed
// at the source, so structuredClone is safe.
ctx.on('session/event', (session, event) => {
let buffer = this.buffers.get(session)
if (!buffer) this.buffers.set(session, buffer = [])
buffer.push(structuredClone(event))
})
// Drain to the backend at the durability checkpoint.
ctx.on('session/flush', session => this.flush(session))
// Dispose must reach quiescence: await every init + final drain BEFORE
// returning, then close the backend's own resources (AFTER the drain), so no
// write lands after teardown and a close failure never MASKS a drain error.
// Register the disposer BEFORE the listeners. Cordis tears effects down in
// reverse registration order, so event admission closes before this final
// drain reaches quiescence and closes the backend.
ctx.effect(() => async () => {
await this.awaitRetirements()
let disposeError: unknown
try {
const errors = [
@@ -341,11 +335,63 @@ export class PersistenceCoordinator<TornMarker = unknown> {
}
}, `${this.backend.name} write path`)
// Capture the header on creation; persist a fork's seed once. Record the init
// promise so flush/dispose can await it (onCreated is async).
ctx.on('session/created', (session) => { void this.initFor(session) })
// Session emits an owned frozen event. Keep a persistence-owned copy anyway
// so the write-behind queue owns exactly the record it will flush rather than
// retaining a product-layer record by identity. Serializability is guaranteed
// at the source, so structuredClone is safe.
ctx.on('session/event', (session, event) => {
let buffer = this.buffers.get(session)
if (!buffer) this.buffers.set(session, buffer = [])
buffer.push(structuredClone(event))
})
// Drain to the backend at the durability checkpoint.
ctx.on('session/flush', session => this.flush(session))
// Session disposal is observe-only, so the coordinator observes the
// detached task itself and backend teardown awaits quiescence.
ctx.on('session/disposed', (session) => { this.retire(session) })
// HMR: a hot reload does not replay session/created, so seed existing live
// sessions (mirrors dsh-invariants).
for (const session of ctx.sessions.list()) void this.initFor(session)
}
/** Start, observe, and track one disposed session's final drain. */
private retire(session: Session): void {
const task = this.retireCore(session)
this.retirements.add(task)
const settled = (): void => { this.retirements.delete(task) }
void task.then(settled, (error: unknown) => {
settled()
this.ctx.logger.warn(`${this.backend.name}: session "${session.id}" retirement failed: ${String(error)}`)
})
}
/** Drain and release state owned by one exact disposed Session lifecycle. */
private async retireCore(session: Session): Promise<void> {
await this.inits.get(session)
const id = session.header.id
await this.serialize(id, async () => {
await this.drain(session)
this.buffers.delete(session)
this.inits.delete(session)
if (this.states.get(id)?.owner === session) this.states.delete(id)
})
}
/** Await every retirement admitted before listener teardown. */
private async awaitRetirements(): Promise<void> {
while (this.retirements.size > 0) {
await Promise.allSettled([...this.retirements])
}
}
/** Start (once) the async init for a session and remember its promise. */
private initFor(session: Session): Promise<void> {
const existing = this.inits.get(session)

View File

@@ -9,7 +9,7 @@
* @module @deepseek-ai/dsh-session-persistence/tests/coordinator-contract
*/
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
@@ -401,7 +401,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
}
})
it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => {
it('session disposal drains buffered events before retiring ownership', async () => {
const fix = await makeFixture()
const { ctx, fiber } = await freshCtx(fix)
try {
@@ -413,13 +413,20 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
// Append a turn but do NOT flush — events sit in the write-behind buffer.
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await firstFiber.dispose() // disposed before flush; not materialized, buffer pending
await firstFiber.dispose()
// Disposal is an observe-only notification. Poll storage rather than
// assuming the owning fiber awaits the coordinator's detached drain.
await vi.waitFor(async () => {
expect((await ctx.sessionPersistence.list()).map(meta => meta.id)).toContain(SessionId('buffered'))
})
expect((await ctx.sessionPersistence.load(SessionId('buffered'))).events.map(event => event.seq)).toEqual([0, 1])
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(SessionId('buffered'), { meta: { cwd: WORK } })
}, { inject: ['sessions'] }))
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/already bound to a different live session/)
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/persisted log|id collision/)
} finally {
await fiber.dispose()
await fix.cleanup()

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
SessionPersistence, PersistenceCoordinator,
type PersistenceBackend, type StoredPrefix,
@@ -15,6 +15,15 @@ type MemoryStore = Map<string, { meta: SessionHeader; events: SessionEvent[] }>
/** Optional plugin config: an EXTERNAL store shared across backend instances. */
interface MemoryConfig { store?: MemoryStore }
/** Test-only view of the coordinator containers whose retirement is the contract under test. */
interface CoordinatorInternals {
states: Map<unknown, unknown>
buffers: Map<unknown, unknown>
chains: Map<unknown, unknown>
inits: Map<unknown, unknown>
retirements: Set<Promise<void>>
}
/**
* Reference {@link PersistenceCoordinator} vehicle and abstract-service coverage, backed by a
* dependency-free map with atomic writes and no torn-tail marker. Supplying the map lets multiple
@@ -101,6 +110,49 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
}
}
/** Controllable storage primitive for serialization and retirement failure tests. */
class ControlledBackend implements PersistenceBackend<never> {
readonly name = 'session-persistence-controlled'
readonly store: MemoryStore = new Map()
readonly lifecycle: string[] = []
appendAttempts = 0
loadAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number) => Promise<void>
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts)
const entry = this.store.get(id)
if (entry === undefined) return undefined
return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) }
}
loadLive(id: SessionId, _cwd: string | undefined): Promise<StoredPrefix<never> | undefined> {
return this.loadStored(id)
}
async appendBatch(m: SessionHeader, events: readonly SessionEvent[], _isMaterialized: boolean): Promise<void> {
const attempt = ++this.appendAttempts
await this.beforeAppend?.(attempt)
const entry = this.store.get(m.id)
if (entry === undefined) {
this.store.set(m.id, { meta: structuredClone(m), events: structuredClone(events) as SessionEvent[] })
} else {
entry.events.push(...structuredClone(events) as SessionEvent[])
}
}
async commitRepair(_m: SessionHeader, _tornMarker: undefined, _closers: readonly SessionEvent[]): Promise<void> {}
async list(): Promise<SessionHeader[]> {
return [...this.store.values()].map(entry => structuredClone(entry.meta))
}
async close(): Promise<void> {
this.lifecycle.push('close')
}
}
// Run the shared contract against the in-memory backend.
runPersistenceContract('memory', async () => {
const ctx = new Context()
@@ -122,6 +174,230 @@ runCoordinatorContract('memory', async (): Promise<CoordinatorFixture> => {
}
})
describe('PersistenceCoordinator retirement', () => {
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const loadGate = Promise.withResolvers<boolean>()
try {
const id = SessionId('retiring-lazy-owner')
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await ctx.sessions.flush(first)
const baselineLoads = backend.loadAttempts
backend.beforeLoadStored = async () => { await loadGate.promise }
const blockingLoad = coordinator.load(id)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
await firstFiber.dispose()
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 2) })
loadGate.resolve(true)
await expect(blockingLoad).rejects.toThrow(/not found/)
await expect(ctx.sessions.flush(reuse)).resolves.toBeUndefined()
} finally {
loadGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('a retiring owner with buffered events still rejects same-id reuse', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const loadGate = Promise.withResolvers<boolean>()
try {
const id = SessionId('retiring-buffered-owner')
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await ctx.sessions.flush(first)
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const baselineLoads = backend.loadAttempts
backend.beforeLoadStored = async () => { await loadGate.promise }
const blockingLoad = coordinator.load(id)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(baselineLoads + 1) })
await firstFiber.dispose()
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create(id)
}, { inject: ['sessions'] }))
await expect(ctx.sessions.flush(reuse)).rejects.toThrow(/bound to a different live session/)
loadGate.resolve(true)
await expect(blockingLoad).rejects.toThrow(/not found/)
await vi.waitFor(() => {
expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1])
})
} finally {
loadGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('a settled chain tail cannot delete a newer operation for the same id', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
const first = Promise.withResolvers<boolean>()
const second = Promise.withResolvers<boolean>()
backend.beforeAppend = async (attempt) => {
if (attempt === 1) await first.promise
if (attempt === 2) await second.promise
}
try {
const id = SessionId('chain-tail')
await coordinator.create(meta(id))
const firstAppend = coordinator.append(id, [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
}])
const secondAppend = coordinator.append(id, [{
type: 'turn/end',
seq: 1,
time: 2,
data: { turn: 1, reason: { kind: 'completed' } },
}])
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(1) })
first.resolve(true)
await vi.waitFor(() => { expect(backend.appendAttempts).toBe(2) })
expect(internals.chains.size).toBe(1)
second.resolve(true)
await Promise.all([firstAppend, secondAppend])
await vi.waitFor(() => { expect(internals.chains.size).toBe(0) })
expect(backend.store.get(id)?.events.map(event => event.seq)).toEqual([0, 1])
} finally {
first.resolve(true)
second.resolve(true)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('backend teardown retries a failed session retirement before close', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
backend.beforeAppend = async (attempt) => {
if (attempt === 1) {
backend.lifecycle.push('append-failed')
throw new Error('transient append failure')
}
backend.lifecycle.push('append-committed')
}
try {
let session!: Session
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('retry-retirement'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await sessionFiber.dispose()
await vi.waitFor(() => {
expect(backend.appendAttempts).toBe(1)
expect(internals.retirements.size).toBe(0)
})
expect([...internals.buffers.values()]).toEqual([expect.arrayContaining([
expect.objectContaining({ seq: 0 }),
expect.objectContaining({ seq: 1 }),
])])
await backendFiber.dispose()
expect(backend.store.get(SessionId('retry-retirement'))?.events.map(event => event.seq)).toEqual([0, 1])
expect(backend.lifecycle).toEqual(['append-failed', 'append-committed', 'close'])
} finally {
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
it('backend teardown waits for an in-flight session retirement before close', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
let coordinator!: PersistenceCoordinator<never>
const backendFiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
const internals = coordinator as unknown as CoordinatorInternals
const appendGate = Promise.withResolvers<boolean>()
backend.beforeAppend = async () => {
backend.lifecycle.push('append-started')
await appendGate.promise
backend.lifecycle.push('append-committed')
}
try {
let session!: Session
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId('inflight-retirement'))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await sessionFiber.dispose()
await vi.waitFor(() => {
expect(backend.appendAttempts).toBe(1)
expect(internals.retirements.size).toBe(1)
})
let disposed = false
const teardown = backendFiber.dispose().then(() => { disposed = true })
await Promise.resolve()
expect(disposed).toBe(false)
expect(backend.lifecycle).toEqual(['append-started'])
appendGate.resolve(true)
await teardown
expect(backend.store.get(SessionId('inflight-retirement'))?.events.map(event => event.seq)).toEqual([0, 1])
expect(backend.lifecycle).toEqual(['append-started', 'append-committed', 'close'])
} finally {
appendGate.resolve(true)
await backendFiber.dispose()
await ctx.fiber.dispose()
}
})
})
describe('SessionPersistence service registration', () => {
it('registers as ctx.sessionPersistence and is removed on fiber dispose (HMR safety)', async () => {
const ctx = new Context()
@@ -155,4 +431,37 @@ describe('SessionPersistence service registration', () => {
.rejects.toThrow('session metadata must be losslessly JSON-serializable')
await fiber.dispose()
})
it('retires all coordinator bookkeeping for disposed sessions', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(MemoryPersistence)
const { coordinator } = ctx.sessionPersistence as unknown as { coordinator: CoordinatorInternals }
try {
for (let index = 0; index < 3; index += 1) {
let session!: Session
const sessionFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(SessionId(`disposed-${index}`))
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.sessions.flush(session)
await sessionFiber.dispose()
}
await vi.waitFor(() => {
expect(ctx.sessions.list()).toHaveLength(0)
expect({
states: coordinator.states.size,
buffers: coordinator.buffers.size,
chains: coordinator.chains.size,
inits: coordinator.inits.size,
retirements: coordinator.retirements.size,
}).toEqual({ states: 0, buffers: 0, chains: 0, inits: 0, retirements: 0 })
})
} finally {
await fiber.dispose()
}
})
})