refactor(session): fold the session family into packages/session/

git mv the 12 packages from session-persistence/, session-projection/,
session-title/, and telemetry/ into one session/ group per the
regrouping RFC; merge the four group READMEs into one bilingual
triplet; rewrite the group segment in tsconfig references (intra-group
references shorten to ../<pkg>), tsconfig.base.json paths/globs,
knip.json keys, vitest include, gate scripts, and authored doc/note
citations; regenerate module graph, doc graphs, catalogs, and the
lockfile importer keys. No npm names change.

Full unit suite: 8779 passed; the 18 reported failures reproduce as
env flakes (ambient-proxy IPv6 tunneling, watched-dir inotify
timeouts under parallel load) — each passes in isolation with
NO_PROXY set, matching their known pre-existing behavior on master.
This commit is contained in:
Tianyi Cui
2026-07-30 01:52:06 +08:00
parent 645fcf5713
commit 7e445c3a67
220 changed files with 258 additions and 286 deletions

View File

@@ -0,0 +1,432 @@
/**
* Reusable contract test for any {@link SessionPersistence} backend. A backend
* package imports {@link runPersistenceContract} and calls it with a factory
* that yields a fresh, empty backend (and a teardown), so every backend is held
* to the same append-only / contiguous-seq / lazy-materialization / crash
* semantics. The JSONL backend's own spec adds file-specific tests on top.
*
* @module @deepseek-ai/dsh-session-persistence/tests/contract
*/
import { describe, expect, it } from 'vitest'
import { SESSION_FORMAT_VERSION, Session, SessionId, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SurfaceEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
import { CallId, MessageId, createMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import type { SessionPersistence } from '../src/index.ts'
/** A backend under test plus its teardown. */
export interface ContractBackend {
persistence: SessionPersistence
dispose: () => Promise<void>
}
/** Build a minimal {@link SessionHeader} for a session id. */
export function meta(id: string, cwd?: string): SessionHeader {
return {
version: SESSION_FORMAT_VERSION,
id: SessionId(id),
createdAt: 1000,
...cwd !== undefined ? { cwd } : {},
}
}
/** A well-formed one-turn event log (contiguous seqs from 0). */
export function oneTurnLog(): SessionEvent[] {
return [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'user/message', seq: 1, time: 2, data: freezeMessage({
id: MessageId('one-turn-user'),
role: 'user',
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), surfaceOp: 'append' },
{ type: 'step/start', seq: 2, time: 3, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 3, time: 4, data: {
turn: 1, step: 1,
message: freezeMessage({
id: MessageId('one-turn-assistant'),
role: 'assistant',
content: [{ type: 'text', text: 'hello' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 4, time: 5, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 6, data: { turn: 1, reason: { kind: 'completed' } } },
]
}
/**
* Append recorded events to a live session while forwarding surface metadata verbatim. The broad
* `SessionEvent` union makes the typed marker optional, but the runtime guard must still reject a
* surface event whose fixture omitted it; this helper never synthesizes a default.
*/
export function appendLog(session: Session, events: readonly SessionEvent[]): void {
for (const e of events) {
const se = e as SessionEvent<SurfaceEventType>
if (se.surfaceOp !== undefined) {
const intent: SurfaceIntent = {
surfaceOp: se.surfaceOp,
...se.sourceEventSeqs !== undefined ? { sourceEventSeqs: se.sourceEventSeqs } : {},
}
session.append(e.type, e.data, intent)
} else {
session.append(e.type, e.data)
}
}
}
/**
* Run the backend-agnostic contract suite. `make()` MUST return a fresh, empty
* backend each call.
*/
export function runPersistenceContract(name: string, make: () => Promise<ContractBackend>): void {
describe(`SessionPersistence contract: ${name}`, () => {
it('round-trips a session: create + append → load returns identical meta and byte-identical events', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s1', '/work')
const log = oneTurnLog()
await persistence.create(m)
await persistence.append(m.id, log)
const loaded = await persistence.load(m.id)
expect(loaded.meta).toMatchObject({ version: SESSION_FORMAT_VERSION, id: m.id, cwd: '/work' })
expect(loaded.events).toEqual(log)
} finally {
await dispose()
}
})
it('rejects a fractional creation timestamp without reserving its session id', async () => {
const { persistence, dispose } = await make()
try {
const m = { ...meta('fractional-created-at'), createdAt: 1.5 }
await expect(persistence.create(m))
.rejects.toThrow('session metadata createdAt must be a non-negative safe integer')
const valid = meta('fractional-created-at')
await persistence.create(valid)
await persistence.append(valid.id, oneTurnLog())
expect((await persistence.load(valid.id)).meta.createdAt).toBe(valid.createdAt)
} finally {
await dispose()
}
})
it('crash recovery: load preserves an interrupted (unclosed) turn and closes it with turn/end {interrupted}', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('interrupted')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
// A second turn that crashed mid-flight: turn/start + step/start were
// durably written, but no step/end / turn/end ever arrived.
await persistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
])
const beforeRepair = (await persistence.listSnapshots())
.find(snapshot => snapshot.header.id === m.id)?.revision
const inspected = await persistence.inspect(m.id)
const afterInspect = (await persistence.listSnapshots())
.find(snapshot => snapshot.header.id === m.id)?.revision
expect(afterInspect).toBe(beforeRepair)
expect(inspected.events.map(e => e.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end',
'turn/start', 'step/start', 'step/end', 'turn/end',
])
// load PRESERVES the interrupted turn's events (a turn can be huge — they
// must not be truncated) and closes the orphaned turn with synthetic
// boundary events: step/end (the step was open) then turn/end {interrupted}.
const loaded = await persistence.load(m.id)
const afterRepair = (await persistence.listSnapshots())
.find(snapshot => snapshot.header.id === m.id)?.revision
expect(afterRepair).not.toBe(beforeRepair)
expect(loaded.events.map(e => e.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
'turn/start', 'step/start', 'step/end', 'turn/end', // turn 2: real events + synthetic closers
])
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
const last = loaded.events.at(-1)!
expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
// The closed log is durable and continuable: a fresh append continues at
// the balanced length (seq 10), and a reload round-trips identically.
await persistence.append(m.id, [
{ type: 'turn/start', seq: 10, time: 9, data: { turn: 3 } },
{ type: 'turn/end', seq: 11, time: 10, data: { turn: 3, reason: { kind: 'completed' } } },
])
const reloaded = await persistence.load(m.id)
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
} finally {
await dispose()
}
})
it('crash recovery: an unstarted assistant tool request gets a retryable synthetic result', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('interrupted-toolcall')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog()) // turn 1, committed (seqs 0..5)
// Turn 2 crashed AFTER the assistant message asked for a tool call but
// BEFORE the tool/result was written (the loop runs tools after logging
// the assistant message — a process killed mid-tool lands exactly here).
await persistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
{ type: 'step/start', seq: 7, time: 8, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 9, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-x'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: 'append' },
])
const loaded = await persistence.load(m.id)
// The orphaned call is answered by a synthetic error tool/result BEFORE
// step/end + turn/end {interrupted}, so the step (and turn) are balanced
// and a resumed session derives a valid transcript (no dangling call).
expect(loaded.events.map(e => e.type)).toEqual([
'turn/start', 'user/message', 'step/start', 'assistant/message', 'step/end', 'turn/end', // turn 1
'turn/start', 'step/start', 'assistant/message', 'tool/result', 'step/end', 'turn/end', // turn 2
])
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data).toMatchObject({
message: {
source: { kind: 'tool', callId: CallId('call-x') },
content: [{ type: 'tool-result', toolCallId: CallId('call-x'), isError: true }],
},
error: { code: TOOL_NOT_STARTED },
})
// The synthetic result carries the SAME callId as the orphaned tool-call,
// so deriveMessages() pairs them — no provider-invalid dangling call.
const call = loaded.events.findLast(e => e.type === 'assistant/message')
const callId = call?.type === 'assistant/message'
&& call.data.message.content.find(b => b.type === 'tool-call')
expect(callId && callId.type === 'tool-call' && callId.id).toBe(CallId('call-x'))
} finally {
await dispose()
}
})
it('crash recovery: a recorded tool call with no result tells the model to assess retry risk', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('unknown-tool-outcome')
await persistence.create(m)
await persistence.append(m.id, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-risk'), name: 'write', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: 'append' },
{ type: 'tool/call', seq: 3, time: 4, data: { turn: 1, step: 1, callId: CallId('call-risk'), name: 'write', arguments: '{}' } },
])
const loaded = await persistence.load(m.id)
const synthetic = loaded.events.find(e => e.type === 'tool/result')
expect(synthetic?.type === 'tool/result' && synthetic.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (synthetic?.type !== 'tool/result' || synthetic.data.message.content[0].content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(synthetic.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(synthetic.data.message.content[0].content[0].text).toContain('if it may have side effects, first verify external state or ask the user')
const resumed = Session.create(m.id, loaded.events, loaded.meta)
const resumedResult = resumed.deriveMessages().find(message => message.content.some(block => block.type === 'tool-result'))
expect(resumedResult?.content[0]).toMatchObject({
type: 'tool-result', toolCallId: CallId('call-risk'), isError: true,
})
} finally {
await dispose()
}
})
it('list() excludes a created-but-never-appended (zero-event) session', async () => {
const { persistence, dispose } = await make()
try {
await persistence.create(meta('empty'))
expect((await persistence.list()).map(m => m.id)).not.toContain(SessionId('empty'))
expect((await persistence.listSnapshots()).map(snapshot => snapshot.header.id))
.not.toContain(SessionId('empty'))
} finally {
await dispose()
}
})
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
const { persistence, dispose } = await make()
try {
const reason = new Error('persistence observation cancelled')
const controller = new AbortController()
await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([])
controller.abort(reason)
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
.rejects.toBe(reason)
await expect(persistence.readFrom(SessionId('cancelled-read-from'), 0, controller.signal))
.rejects.toBe(reason)
} finally {
await dispose()
}
})
it('readFrom returns exactly the stored suffix from the requested seq, without mutating the log', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('read-from', '/work')
const log = oneTurnLog()
await persistence.create(m)
await persistence.append(m.id, log)
const whole = await persistence.readFrom(m.id, 0)
expect(whole.meta).toMatchObject({ id: m.id, cwd: '/work' })
expect(whole.events).toEqual(log)
const suffix = await persistence.readFrom(m.id, 3)
expect(suffix.events).toEqual(log.slice(3))
expect(suffix.events[0]?.seq).toBe(3)
// At/past the stored end: an empty tail, never an error.
await expect(persistence.readFrom(m.id, log.length)).resolves.toMatchObject({ events: [] })
await expect(persistence.readFrom(m.id, log.length + 100)).resolves.toMatchObject({ events: [] })
// Non-mutating: an interrupted-turn log is served as stored, no closers.
await persistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2 } },
])
const tail = await persistence.readFrom(m.id, 6)
expect(tail.events.map(event => event.type)).toEqual(['turn/start'])
await expect(persistence.readFrom(SessionId('absent-read-from'), 0)).rejects.toThrow('not found')
await expect(persistence.readFrom(m.id, -1)).rejects.toThrow('non-negative safe integer')
await expect(persistence.readFrom(m.id, 1.5)).rejects.toThrow('non-negative safe integer')
} finally {
await dispose()
}
})
it('lists stable lightweight revisions that change after an append', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s2')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog())
expect((await persistence.list()).map(x => x.id)).toContain(m.id)
const first = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
const repeated = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
expect(first).toBeDefined()
expect(repeated?.revision).toBe(first?.revision)
await persistence.append(m.id, [{
type: 'turn/start',
seq: 6,
time: 7,
data: { turn: 2 },
}])
const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
expect(changed?.revision).not.toBe(first?.revision)
} finally {
await dispose()
}
})
it('append rejects a batch whose first seq does not match the stored next-seq', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s3')
await persistence.create(m)
await persistence.append(m.id, oneTurnLog()) // seqs 0..5, next-seq = 6
// A re-append of an already-stored seq must be rejected, not duplicated.
const restated = oneTurnLog()
await expect(persistence.append(m.id, restated)).rejects.toThrow()
} finally {
await dispose()
}
})
it('append rejects a mid-batch seq gap', async () => {
const { persistence, dispose } = await make()
try {
const m = meta('s4')
await persistence.create(m)
const gapped: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
{ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }, // gap: missing seq 1
]
await expect(persistence.append(m.id, gapped)).rejects.toThrow()
} finally {
await dispose()
}
})
it('append rejects non-JSON-serializable event data, naming the event type', async () => {
const { persistence, dispose } = await make()
try {
// Every value `isJsonValue` rejects must be rejected by the backend, not just BigInt —
// otherwise a backend could pass this contract while still accepting values that
// corrupt the durable round-trip. Each value is carried in a plugin-added field on one
// user message so the contract covers the complete JSON-value boundary.
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
cyclic['self'] = cyclic
const badValues: unknown[] = [
1n, // BigInt
undefined, // dropped by JSON.stringify
Infinity, // → null
() => 0, // function
Symbol('s'), // symbol
new Map(), // exotic object
cyclic, // circular ref
]
for (const [i, bad] of badValues.entries()) {
// A fresh session per value isolates each rejection (a rejected append
// must leave no state behind, but isolating keeps the assertion clean).
const mi = meta(`s5-${i}`)
await persistence.create(mi)
const events = [
{
type: 'user/message',
seq: 0,
time: 1,
data: {
id: MessageId(`invalid-json-${i}`),
role: 'user',
content: [{ type: 'text', text: 'x' }],
source: { kind: 'user' },
extra: bad,
},
},
] as unknown as SessionEvent[]
await expect(persistence.append(mi.id, events)).rejects.toThrow(/losslessly JSON-serializable/)
}
} finally {
await dispose()
}
})
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,360 @@
/** Unit coverage for unpublished Session preparation ownership and sharing. */
import { describe, expect, it, vi } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { observeQueuedAbort, SessionPreparations } from '../src/preparations.ts'
interface PreparedSource {
readonly session: Session
readonly label: string
}
function prepared(label: string): PreparedSource {
return { session: Session.create(SessionId(label)), label }
}
function committed(source: PreparedSource): Promise<{ source: PreparedSource; state: string }> {
return Promise.resolve({ source, state: source.label })
}
describe('SessionPreparations inspection', () => {
it('shares in-flight and ready sources, then invalidates them', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(2)
const id = SessionId('shared-inspection')
const gate = Promise.withResolvers<PreparedSource>()
const load = vi.fn(() => gate.promise)
const first = preparations.inspect(id, load)
const second = preparations.inspect(id, load, new AbortController().signal)
const source = prepared(id)
expect(preparations.has(id)).toBe(true)
gate.resolve(source)
await expect(first).resolves.toBe(source)
await expect(second).resolves.toBe(source)
await expect(preparations.inspect(id, load)).resolves.toBe(source)
expect(load).toHaveBeenCalledOnce()
preparations.invalidate(id)
preparations.invalidate(id)
expect(preparations.has(id)).toBe(false)
})
it('keeps a shared load alive when its first observer cancels', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('cancelled-first-observer')
const gate = Promise.withResolvers<PreparedSource>()
const load = vi.fn(() => gate.promise)
const controller = new AbortController()
const reason = new Error('first observer cancelled')
const first = preparations.inspect(id, load, controller.signal)
const joined = preparations.inspect(id, load)
controller.abort(reason)
await expect(first).rejects.toBe(reason)
const source = prepared(id)
gate.resolve(source)
await expect(joined).resolves.toBe(source)
await expect(preparations.inspect(id, load)).resolves.toBe(source)
expect(load).toHaveBeenCalledOnce()
})
it('evicts completed loads whose observers cancelled before readiness', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const firstId = SessionId('cancelled-ready-first')
const secondId = SessionId('cancelled-ready-second')
const firstGate = Promise.withResolvers<PreparedSource>()
const secondGate = Promise.withResolvers<PreparedSource>()
const firstController = new AbortController()
const secondController = new AbortController()
const first = preparations.inspect(firstId, () => firstGate.promise, firstController.signal)
const second = preparations.inspect(secondId, () => secondGate.promise, secondController.signal)
firstController.abort(new Error('first observer cancelled'))
secondController.abort(new Error('second observer cancelled'))
await expect(first).rejects.toThrow('first observer cancelled')
await expect(second).rejects.toThrow('second observer cancelled')
firstGate.resolve(prepared(firstId))
await firstGate.promise
secondGate.resolve(prepared(secondId))
await secondGate.promise
await Promise.resolve()
expect(preparations.has(firstId)).toBe(false)
expect(preparations.has(secondId)).toBe(true)
})
it('removes failed and invalidated in-flight loads without changing their observers', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const failedId = SessionId('failed-inspection')
const failure = new Error('load failed')
await expect(preparations.inspect(failedId, () => Promise.reject(failure))).rejects.toBe(failure)
expect(preparations.has(failedId)).toBe(false)
const invalidatedId = SessionId('invalidated-inspection')
const gate = Promise.withResolvers<PreparedSource>()
const inspection = preparations.inspect(invalidatedId, () => gate.promise)
preparations.invalidate(invalidatedId)
const source = prepared(invalidatedId)
gate.resolve(source)
await expect(inspection).resolves.toBe(source)
expect(preparations.has(invalidatedId)).toBe(false)
const rejectedId = SessionId('invalidated-rejection')
const rejectedGate = Promise.withResolvers<PreparedSource>()
const rejected = preparations.inspect(rejectedId, () => rejectedGate.promise)
preparations.invalidate(rejectedId)
rejectedGate.reject(failure)
await expect(rejected).rejects.toBe(failure)
})
it('removes a load that throws before returning its promise', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('synchronous-load-failure')
const failure = new Error('synchronous load failure')
await expect(preparations.inspect(id, () => { throw failure })).rejects.toBe(failure)
expect(preparations.has(id)).toBe(false)
})
it('evicts ready entries while leaving reserved entries alone', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const reservedA = await preparations.reserve(
SessionId('reserved-a'),
() => Promise.resolve(prepared('reserved-a')),
committed,
)
const reservedB = await preparations.reserve(
SessionId('reserved-b'),
() => Promise.resolve(prepared('reserved-b')),
committed,
)
expect(reservedA).toBeDefined()
expect(reservedB).toBeDefined()
await preparations.inspect(SessionId('ready-c'), () => Promise.resolve(prepared('ready-c')))
preparations.release(reservedA!, true)
expect(preparations.has(SessionId('reserved-b'))).toBe(true)
expect(preparations.has(SessionId('ready-c'))).toBe(false)
expect(preparations.has(SessionId('reserved-a'))).toBe(true)
preparations.discard(reservedB!)
preparations.invalidate(SessionId('reserved-a'))
})
it('discards only the exact ready source and retains exclusive reservations', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const ready = prepared('discard-ready')
expect(preparations.discardReady(ready.session.id, ready)).toBe('missing')
await preparations.inspect(ready.session.id, () => Promise.resolve(ready))
expect(preparations.discardReady(ready.session.id, prepared('different'))).toBe('missing')
expect(preparations.discardReady(ready.session.id, ready)).toBe('discarded')
const reserved = await preparations.reserve(
ready.session.id,
() => Promise.resolve(ready),
committed,
)
expect(preparations.discardReady(ready.session.id, ready)).toBe('retained')
preparations.release(reserved!, false)
})
})
describe('SessionPreparations reservation', () => {
it('waits for an existing reservation, republishes the exact Session, and attaches once', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(2)
const id = SessionId('reservation-wait')
const source = prepared(id)
const first = await preparations.reserve(id, () => Promise.resolve(source), committed)
expect(first).toBeDefined()
expect(preparations.reservationFor(source.session)).toBe(first)
expect(() => preparations.reservationFor(Session.create(id))).toThrow(/cannot publish/)
expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
let secondSettled = false
const secondPromise = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
.then((reservation) => {
secondSettled = true
return reservation
})
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(secondSettled).toBe(false)
preparations.release(first!, true)
const second = await secondPromise
expect(second?.source).toBe(source)
preparations.attach(second!)
expect(preparations.reservationFor(source.session)).toBeUndefined()
expect(() => { preparations.attach(second!) }).toThrow(/no longer reserved/)
preparations.discard(second!)
preparations.release(second!, true)
expect(() => { preparations.assertWritable(id) }).not.toThrow()
})
it('supports abortable reservation waits without cancelling the held reservation', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('abortable-reservation-wait')
const first = await preparations.reserve(id, () => Promise.resolve(prepared(id)), committed)
const controller = new AbortController()
const reason = { kind: 'cancelled' }
const waiting = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed, controller.signal)
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
controller.abort(reason)
await expect(waiting).rejects.toBe(reason)
expect(preparations.reservationFor(first!.source.session)).toBe(first)
preparations.release(first!, false)
expect(preparations.has(id)).toBe(false)
})
it('removes a failed commit and wakes another waiter as invalidated', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('failed-commit')
const commitStarted = Promise.withResolvers<undefined>()
const commitGate = Promise.withResolvers<{ source: PreparedSource; state: string }>()
const source = prepared(id)
const failure = new Error('commit failed')
const first = preparations.reserve(id, () => Promise.resolve(source), () => {
commitStarted.resolve(undefined)
return commitGate.promise
})
await commitStarted.promise
expect(() => { preparations.assertWritable(id) }).toThrow(/is reserved/)
const second = preparations.reserve(id, () => Promise.resolve(prepared('unused')), committed)
commitGate.reject(failure)
await expect(first).rejects.toBe(failure)
await expect(second).resolves.toBeUndefined()
expect(preparations.has(id)).toBe(false)
})
it('returns a post-commit cancellation to the ready pool', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('post-commit-cancel')
const source = prepared(id)
const controller = new AbortController()
const reason = new Error('cancel after commit')
await expect(preparations.reserve(id, () => Promise.resolve(source), async (value) => {
controller.abort(reason)
return { source: value, state: value.label }
}, controller.signal)).rejects.toBe(reason)
expect(preparations.takeReady(id)).toBe(source)
expect(preparations.takeReady(id)).toBeUndefined()
})
it('does not revive an invalidated commit after post-commit cancellation', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('invalidated-commit-cancel')
const source = prepared(id)
const commitStarted = Promise.withResolvers<undefined>()
const commitGate = Promise.withResolvers<undefined>()
const controller = new AbortController()
const reason = new Error('cancel invalidated commit')
const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
commitStarted.resolve(undefined)
await commitGate.promise
return { source: value, state: value.label }
}, controller.signal)
await commitStarted.promise
preparations.invalidate(id)
controller.abort(reason)
commitGate.resolve(undefined)
await expect(reservation).rejects.toBe(reason)
expect(preparations.has(id)).toBe(false)
})
it('does not reserve an entry invalidated while its commit succeeds', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('invalidated-successful-commit')
const source = prepared(id)
const commitStarted = Promise.withResolvers<undefined>()
const commitGate = Promise.withResolvers<undefined>()
const reservation = preparations.reserve(id, () => Promise.resolve(source), async (value) => {
commitStarted.resolve(undefined)
await commitGate.promise
return { source: value, state: value.label }
})
await commitStarted.promise
preparations.invalidate(id)
commitGate.resolve(undefined)
await expect(reservation).resolves.toBeUndefined()
expect(preparations.has(id)).toBe(false)
})
it('returns undefined when a load is invalidated before reservation', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('invalidated-reservation')
const gate = Promise.withResolvers<PreparedSource>()
const reservation = preparations.reserve(id, () => gate.promise, committed)
preparations.invalidate(id)
gate.resolve(prepared(id))
await expect(reservation).resolves.toBeUndefined()
})
it('skips pending adoption and accepts a ready source exactly once', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const id = SessionId('take-ready')
const gate = Promise.withResolvers<PreparedSource>()
const inspection = preparations.inspect(id, () => gate.promise)
expect(preparations.takeReady(id)).toBeUndefined()
const source = prepared(id)
gate.resolve(source)
await inspection
expect(preparations.takeReady(id)).toBe(source)
expect(preparations.takeReady(id)).toBeUndefined()
})
it('rejects publication while only an inspection exists', async () => {
const preparations = new SessionPreparations<PreparedSource, string>(1)
const source = prepared('inspection-publication')
await preparations.inspect(source.session.id, () => Promise.resolve(source))
expect(() => preparations.reservationFor(source.session)).toThrow(/cannot publish/)
})
})
describe('observeQueuedAbort', () => {
it('relays fulfillment and rejection exactly', async () => {
const signal = new AbortController().signal
await expect(observeQueuedAbort(Promise.resolve('value'), signal)).resolves.toBe('value')
const failure = { kind: 'failed' }
const rejected = Promise.withResolvers<never>()
rejected.reject(failure)
await expect(observeQueuedAbort(rejected.promise, signal)).rejects.toBe(failure)
})
it('rejects promptly with an exact abort reason and ignores later settlement', async () => {
const operation = Promise.withResolvers<string>()
const controller = new AbortController()
const reason = { kind: 'aborted' }
const observed = observeQueuedAbort(operation.promise, controller.signal)
controller.abort(reason)
await expect(observed).rejects.toBe(reason)
operation.resolve('late')
await Promise.resolve()
})
it('observes a pre-aborted signal through the default start predicate', async () => {
const controller = new AbortController()
controller.abort('pre-aborted')
await expect(observeQueuedAbort(new Promise<never>(() => {}), controller.signal))
.rejects.toBe('pre-aborted')
})
it('lets an operation that already started own cancellation settlement', async () => {
const operation = Promise.withResolvers<string>()
const controller = new AbortController()
const observed = observeQueuedAbort(operation.promise, controller.signal, () => true)
controller.abort(new Error('too late'))
operation.resolve('owned')
await expect(observed).resolves.toBe('owned')
})
})

View File

@@ -0,0 +1,275 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import { SessionWriteBehind } from '../src/write-behind.ts'
/** Minimal ordered event fixture; batching does not interpret event vocabulary. */
function event(seq: number): SessionEvent<'turn/start'> {
return {
type: 'turn/start',
seq,
time: seq,
data: { turn: seq + 1 },
}
}
afterEach(() => {
vi.useRealTimers()
})
describe('SessionWriteBehind', () => {
it('uses one fixed window from the first queued event and owns its copy', async () => {
vi.useFakeTimers()
const batches: SessionEvent[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => { batches.push(structuredClone(events) as SessionEvent[]) },
reportBackgroundFailure: vi.fn(),
})
const first = event(0)
controller.enqueue(first)
first.data.turn = 99
await vi.advanceTimersByTimeAsync(150)
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(49)
expect(batches).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(batches).toEqual([[
expect.objectContaining({ seq: 0, data: { turn: 1 } }),
expect.objectContaining({ seq: 1 }),
]])
expect(controller.hasWork).toBe(false)
})
it('coalesces twenty events admitted ten milliseconds apart into one 200 ms batch', async () => {
vi.useFakeTimers()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => { batches.push(events.map(item => item.seq)) },
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
for (let seq = 1; seq < 20; seq += 1) {
await vi.advanceTimersByTimeAsync(10)
controller.enqueue(event(seq))
}
expect(batches).toEqual([])
await vi.advanceTimersByTimeAsync(10)
expect(batches).toEqual([Array.from({ length: 20 }, (_, seq) => seq)])
await controller.flush()
})
it('makes concurrent flushes one immediate barrier that drains admitted tails', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) await gate.promise
},
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
const first = controller.flush()
const second = controller.flush()
expect(second).toBe(first)
await Promise.resolve()
expect(batches).toEqual([[0]])
controller.enqueue(event(1))
gate.resolve(true)
await first
expect(batches).toEqual([[0], [1]])
expect(controller.hasWork).toBe(false)
expect(vi.getTimerCount()).toBe(0)
})
it('starts a new window for work admitted after an already-quiescent barrier', async () => {
vi.useFakeTimers()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => { batches.push(events.map(item => item.seq)) },
reportBackgroundFailure: vi.fn(),
})
const barrier = controller.flush()
controller.enqueue(event(0))
await barrier
expect(batches).toEqual([])
expect(vi.getTimerCount()).toBe(1)
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0]])
expect(controller.hasWork).toBe(false)
})
it('starts an over-budget tail immediately after the active write', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) await gate.promise
},
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0]])
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0]])
gate.resolve(true)
await vi.advanceTimersByTimeAsync(0)
expect(batches).toEqual([[0], [1]])
await controller.flush()
})
it('keeps a tail deadline that has not expired when the active write finishes', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) await gate.promise
},
reportBackgroundFailure: vi.fn(),
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(50)
gate.resolve(true)
await vi.advanceTimersByTimeAsync(0)
expect(batches).toEqual([[0]])
await vi.advanceTimersByTimeAsync(149)
expect(batches).toEqual([[0]])
await vi.advanceTimersByTimeAsync(1)
expect(batches).toEqual([[0], [1]])
await controller.flush()
})
it('pauses automatic retries after failure and preserves order for new work', async () => {
vi.useFakeTimers()
const failure = new Error('storage unavailable')
const report = vi.fn()
const batches: number[][] = []
let attempt = 0
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (++attempt === 1) throw failure
},
reportBackgroundFailure: report,
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
expect(report).toHaveBeenCalledWith(failure)
expect(controller.hasWork).toBe(true)
await vi.advanceTimersByTimeAsync(1_000)
expect(batches).toEqual([[0]])
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(199)
expect(batches).toEqual([[0]])
await vi.advanceTimersByTimeAsync(1)
expect(batches).toEqual([[0], [0, 1]])
await controller.flush()
})
it('observes an overlapping background failure and retries it inside flush', async () => {
vi.useFakeTimers()
const gate = Promise.withResolvers<boolean>()
const report = vi.fn()
const batches: number[][] = []
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (batches.length === 1) {
await gate.promise
throw new Error('transient')
}
},
reportBackgroundFailure: report,
})
controller.enqueue(event(0))
await vi.advanceTimersByTimeAsync(200)
const first = controller.flush()
const second = controller.flush()
gate.resolve(true)
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
expect(batches).toEqual([[0], [0]])
expect(report).toHaveBeenCalledOnce()
expect(controller.hasWork).toBe(false)
})
it('surfaces a barrier failure without detached logging and retains its batch', async () => {
vi.useFakeTimers()
const failure = new Error('durability failed')
const report = vi.fn()
const batches: number[][] = []
let attempt = 0
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
batches.push(events.map(item => item.seq))
if (++attempt === 1) throw failure
},
reportBackgroundFailure: report,
})
controller.enqueue(event(0))
await expect(controller.flush()).rejects.toBe(failure)
expect(report).not.toHaveBeenCalled()
expect(controller.hasWork).toBe(true)
controller.enqueue(event(1))
await vi.advanceTimersByTimeAsync(200)
expect(batches).toEqual([[0], [0, 1]])
await controller.flush()
})
it('retains a failed batch larger than the engine call-argument limit', async () => {
const failure = new Error('durability failed')
const batchSize = 150_000
const sizes: number[] = []
let attempt = 0
const controller = new SessionWriteBehind({
maxDelayMs: 200,
write: async (events) => {
sizes.push(events.length)
if (++attempt === 1) throw failure
},
reportBackgroundFailure: vi.fn(),
})
for (let seq = 0; seq < batchSize; seq += 1) controller.enqueue(event(seq))
await expect(controller.flush()).rejects.toBe(failure)
expect(controller.hasWork).toBe(true)
await controller.flush()
expect(sizes).toEqual([batchSize, batchSize])
expect(controller.hasWork).toBe(false)
})
})