fix(session-query): harden SQLite search reconciliation

This commit is contained in:
Hypatia May
2026-07-15 12:10:24 +08:00
parent ecf90ff382
commit f88ca85ffd
40 changed files with 1315 additions and 227 deletions

View File

@@ -102,11 +102,16 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ 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
// 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
@@ -173,18 +178,33 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
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('list() includes a session once it has events', async () => {
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, trigger: { kind: 'message', source: { kind: 'user' } } },
}])
const changed = (await persistence.listSnapshots()).find(snapshot => snapshot.header.id === m.id)
expect(changed?.revision).not.toBe(first?.revision)
} finally {
await dispose()
}

View File

@@ -3,8 +3,8 @@ import { Context } from 'cordis'
import SessionStore, { SessionId, isJsonValue } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import {
SessionPersistence, PersistenceCoordinator, assertSerializable, seedCoversPrefix,
type PersistenceBackend, type StoredPrefix,
SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, assertSerializable, seedCoversPrefix,
type PersistenceBackend, type SessionPersistenceSnapshot, type StoredPrefix,
} from '../src/index.ts'
import { runPersistenceContract, meta, oneTurnLog } from './contract.ts'
import { runCoordinatorContract, type CoordinatorFixture } from './coordinator-contract.ts'
@@ -109,6 +109,14 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
async list(): Promise<SessionHeader[]> {
return [...this.store.values()].map(e => structuredClone(e.meta))
}
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
return [...this.store.values()].map(entry => ({
header: structuredClone(entry.meta),
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
}))
}
}
// Run the shared contract against the in-memory backend.