feat(session-query): checkpoint build round 1

This commit is contained in:
Hypatia May
2026-07-10 16:51:19 +08:00
parent 42ebbfdf8f
commit aa1dc0e2c7
40 changed files with 3174 additions and 102 deletions

View File

@@ -0,0 +1,704 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, {
SessionQueryError,
filterEventResults,
filterSessionResults,
} from '@deepseek-ai/dsh-session-query'
import type {
SessionEventSearchHit,
SessionEventSearchRequest,
SessionIndexSnapshot,
SessionRecord,
SessionSearchHit,
SessionSearchPage,
SessionSearchProvider,
SessionSearchProviderStatus,
SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
declare module '@deepseek-ai/dsh-llm' {
interface ContentBlockMap {
'test/text': { type: 'test/text'; value: string }
}
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/note': { note: string }
}
}
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
}
function eventLog(text = 'hello'): SessionEvent[] {
return [{
type: 'user/message',
seq: 0,
time: 10,
data: { content: [{ type: 'text', text }], source: { kind: 'user' } },
surfaceOp: 'append',
}]
}
class TestPersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listFailure: unknown
static loadFailure: unknown
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
this.listFailure = undefined
this.loadFailure = undefined
}
create(meta: SessionHeader): Promise<void> {
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
return Promise.resolve()
}
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
const entry = TestPersistence.entries.get(id)
if (entry === undefined) throw new Error('missing test session')
entry.events.push(...structuredClone(events))
return Promise.resolve()
}
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
if (TestPersistence.loadFailure !== undefined) return Promise.reject(asError(TestPersistence.loadFailure))
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
return Promise.resolve(structuredClone(entry))
}
list(): Promise<SessionHeader[]> {
if (TestPersistence.listFailure !== undefined) return Promise.reject(asError(TestPersistence.listFailure))
return Promise.resolve([...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta)))
}
}
class FakeProvider implements SessionSearchProvider {
readonly id: string
statusValue: SessionSearchProviderStatus = { available: true }
persisted = new Map<SessionIdType, SessionIndexSnapshot>()
live = new Map<SessionIdType, SessionIndexSnapshot>()
activeHistory: boolean[] = []
removedPersisted: SessionIdType[] = []
removedLive: SessionIdType[] = []
sessionRequests: SessionSearchRequest[] = []
eventRequests: SessionEventSearchRequest[] = []
failNextLive = false
failNextPersisted = false
failNextActive = false
sessionPage: SessionSearchPage<SessionSearchHit>
eventPage: SessionSearchPage<SessionEventSearchHit>
constructor(id = 'fake') {
this.id = id
this.sessionPage = { providerId: id, items: [] }
this.eventPage = { providerId: id, items: [] }
}
status(): SessionSearchProviderStatus {
return this.statusValue
}
persistedInventory(): Promise<readonly { sessionId: SessionIdType; fingerprint: string }[]> {
return Promise.resolve([...this.persisted.values()].map(snapshot => ({
sessionId: snapshot.session.header.id,
fingerprint: snapshot.fingerprint,
})))
}
setPersistedActive(active: boolean): Promise<void> {
if (this.failNextActive) {
this.failNextActive = false
return Promise.reject(new Error('activation failed'))
}
this.activeHistory.push(active)
return Promise.resolve()
}
replacePersisted(snapshot: SessionIndexSnapshot): Promise<void> {
if (this.failNextPersisted) {
this.failNextPersisted = false
return Promise.reject(new Error('persisted index failed'))
}
this.persisted.set(snapshot.session.header.id, structuredClone(snapshot))
return Promise.resolve()
}
removePersisted(sessionId: SessionIdType): Promise<void> {
this.removedPersisted.push(sessionId)
this.persisted.delete(sessionId)
return Promise.resolve()
}
replaceLive(snapshot: SessionIndexSnapshot): Promise<void> {
if (this.failNextLive) {
this.failNextLive = false
return Promise.reject(new Error('live index failed'))
}
this.live.set(snapshot.session.header.id, structuredClone(snapshot))
return Promise.resolve()
}
removeLive(sessionId: SessionIdType): Promise<void> {
this.removedLive.push(sessionId)
this.live.delete(sessionId)
return Promise.resolve()
}
searchSessions(request: SessionSearchRequest): Promise<SessionSearchPage<SessionSearchHit>> {
this.sessionRequests.push(structuredClone(request))
return Promise.resolve(structuredClone(this.sessionPage))
}
searchEvents(request: SessionEventSearchRequest): Promise<SessionSearchPage<SessionEventSearchHit>> {
this.eventRequests.push(structuredClone(request))
return Promise.resolve(structuredClone(this.eventPage))
}
}
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService, config)
return ctx
}
function expectCode(code: string): Error {
return expect.objectContaining({ code }) as Error
}
function asError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value))
}
describe('pure result filters', () => {
it('chains session filters as AND while values within one filter are OR', () => {
const root: SessionRecord = { header: header('root', 1, { cwd: '/a' }), live: true, persisted: false }
const child: SessionRecord = { header: header('child', 2, { cwd: '/b', parentSession: root.header.id }), live: false, persisted: true }
const both: SessionRecord = { header: header('both', 3, { cwd: '/a', parentSession: root.header.id }), live: true, persisted: true }
const input = [child, root, both]
const output = filterSessionResults(input, [
{ kind: 'cwd', values: ['/a', '/b'] },
{ kind: 'created-at', range: { from: 2, to: 3 } },
{ kind: 'parent', values: [root.header.id] },
{ kind: 'availability', values: ['live', 'persisted'] },
{ kind: 'id', values: [child.header.id, both.header.id] },
])
expect(output).toEqual([child, both])
expect(output[0]).toBe(child)
expect(input).toEqual([child, root, both])
expect(filterSessionResults(input, [{ kind: 'cwd', values: [null] }])).toEqual([])
})
it('filters event ranges/types/status without reordering richer records', () => {
const events = [
{ sessionId: SessionId('s'), seq: 2, type: 'user/message' as const, time: 20, surface: 'current' as const, extra: true },
{ sessionId: SessionId('s'), seq: 1, type: 'tool/call' as const, time: 10, surface: 'shadowed' as const, extra: true },
{ sessionId: SessionId('s'), seq: 3, type: 'assistant/chunk' as const, time: 30, surface: 'log-only' as const, extra: true },
]
const output = filterEventResults(events, [
{ kind: 'seq', range: { from: 1, to: 2 } },
{ kind: 'time', range: { from: 10, to: 20 } },
{ kind: 'type', values: ['user/message', 'tool/call'] },
{ kind: 'surface', values: ['current', 'shadowed'] },
])
expect(output).toEqual(events.slice(0, 2))
expect(output[0]).toBe(events[0])
})
it('rejects invalid serializable filter values', () => {
expect(() => filterSessionResults([], [{ kind: 'created-at', range: { from: 2, to: 1 } }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterEventResults([], [{ kind: 'seq', range: { from: Number.NaN } }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterEventResults([], [{ kind: 'time', range: { to: Number.POSITIVE_INFINITY } }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterEventResults([], [{ kind: 'surface', values: ['other' as never] }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
expect(() => filterSessionResults([], [{ kind: 'availability', values: ['other' as never] }]))
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
})
it('handles absent range bounds and root/availability alternatives', () => {
const record: SessionRecord = { header: header('root'), live: false, persisted: true }
expect(filterSessionResults([record], [
{ kind: 'parent', values: [null] },
{ kind: 'cwd', values: [null] },
{ kind: 'availability', values: ['persisted'] },
])).toEqual([record])
const event = { sessionId: record.header.id, seq: 2, type: 'user/message' as const, time: 4, surface: 'current' as const }
expect(filterEventResults([event], [{ kind: 'seq', range: { to: 2 } }, { kind: 'time', range: { from: 4 } }])).toEqual([event])
})
})
describe('logical corpus reads and traces', () => {
it('lists, classifies, reads, and traces a live session using detached records', async () => {
const ctx = await liveContext({ readWindowMax: 2 })
const session = ctx.sessions.create(SessionId('live'), { meta: { createdAt: 20, cwd: '/work' } })
const original = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const chunk = session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'answer' } })
const answer = session.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'answer' }] }, { surfaceOp: 'append', sourceEventSeqs: [chunk.seq] })
const summary = session.append('assistant/message', { turn: 1, step: 2, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: original.seq, end: original.seq }, sourceEventSeqs: [original.seq] })
const resummary = session.append('assistant/message', { turn: 1, step: 3, content: [{ type: 'text', text: 'resummary' }] }, { surfaceOp: { op: 'replace', start: summary.seq, end: answer.seq }, sourceEventSeqs: [summary.seq, answer.seq] })
const listed = await ctx.sessionQuery.listSessions()
expect(listed).toEqual([{ header: session.header, live: true, persisted: false }])
listed[0]!.header.createdAt = -1
expect(session.header.createdAt).toBe(20)
expect((await ctx.sessionQuery.listEvents(session.id)).map(event => event.surface))
.toEqual(['shadowed', 'log-only', 'shadowed', 'shadowed', 'current'])
const window = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: answer.seq, before: 2, after: 2 })
expect([window.startSeq, window.endSeq]).toEqual([0, 4])
expect(window.target.seq).toBe(answer.seq)
if (window.events[0]?.type !== 'user/message') throw new Error('expected user message')
window.events[0].data.content = []
expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1)
await expect(ctx.sessionQuery.traceEvent(session.id, original.seq)).resolves.toMatchObject({
shadowedBy: summary.seq,
replacementChain: [summary.seq, resummary.seq],
referencedBy: [summary.seq],
})
await expect(ctx.sessionQuery.traceEvent(session.id, summary.seq)).resolves.toMatchObject({
shadows: [original.seq],
references: [original.seq],
referencedBy: [resummary.seq],
})
await expect(ctx.sessionQuery.traceEvent(session.id, chunk.seq)).resolves.toMatchObject({ referencedBy: [answer.seq] })
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 99 })).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, before: 3 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
await expect(ctx.sessionQuery.traceEvent(session.id, 99)).rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
})
it('turns malformed replacement logs into typed surface failures', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('bad-surface'))
session.append('assistant/message', { turn: 1, step: 1, content: [] }, {
surfaceOp: { op: 'replace', start: 9, end: 9 },
sourceEventSeqs: [],
})
await expect(ctx.sessionQuery.listEvents(session.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('returns complete, partial, deterministic, and cycle-checked lineage', async () => {
const ctx = await liveContext()
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } })
const second = ctx.sessions.create(SessionId('second'), { meta: { createdAt: 2, parentSession: root.id } })
const first = ctx.sessions.create(SessionId('first'), { meta: { createdAt: 2, parentSession: root.id } })
const grandchild = ctx.sessions.create(SessionId('grandchild'), { meta: { createdAt: 3, parentSession: first.id } })
const partial = ctx.sessions.create(SessionId('partial'), { meta: { createdAt: 4, parentSession: SessionId('missing') } })
const trace = await ctx.sessionQuery.traceSession(grandchild.id)
expect(trace.parents.map(record => record.header.id)).toEqual([first.id, root.id])
expect(trace.root?.header.id).toBe(root.id)
const rootTrace = await ctx.sessionQuery.traceSession(root.id)
expect(rootTrace.children.map(node => node.session.header.id)).toEqual([first.id, second.id])
expect(rootTrace.children[0]?.children[0]?.session.header.id).toBe(grandchild.id)
await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({ unresolvedParentId: SessionId('missing') })
await expect(ctx.sessionQuery.traceSession(SessionId('absent'))).rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
const cyclic = await liveContext()
const a = new Session(SessionId('a'), [], header('a', 1, { parentSession: SessionId('b') }))
const b = new Session(SessionId('b'), [], header('b', 2, { parentSession: SessionId('a') }))
cyclic.sessions.enter(a)
cyclic.sessions.enter(b)
await expect(cyclic.sessionQuery.traceSession(a.id)).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE'))
})
it('uses live content over a matching persisted base and scopes persistence failures', async () => {
const common = header('same', 5, { cwd: '/w' })
const persistedOnly = header('persisted', 1)
TestPersistence.reset([
{ meta: common, events: eventLog('persisted version') },
{ meta: persistedOnly, events: eventLog('persisted only') },
])
const ctx = await liveContext()
const live = ctx.sessions.create(common.id, { meta: { createdAt: common.createdAt, cwd: '/w' } })
live.append('user/message', { content: [{ type: 'text', text: 'live version' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const persistenceFiber = await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.listEvents(SessionId('not-listed')))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
const records = await ctx.sessionQuery.listSessions()
expect(records.map(record => [record.header.id, record.live, record.persisted])).toEqual([
[common.id, true, true],
[persistedOnly.id, false, true],
])
const liveWindow = await ctx.sessionQuery.readEvent({ sessionId: common.id, seq: 0 })
expect(liveWindow.target.type === 'user/message' && liveWindow.target.data.content[0]).toMatchObject({ text: 'live version' })
await expect(ctx.sessionQuery.readEvent({ sessionId: persistedOnly.id, seq: 0 }))
.resolves.toMatchObject({ session: { persisted: true } })
TestPersistence.listFailure = new Error('list unavailable')
await expect(ctx.sessionQuery.listEvents(common.id)).resolves.toHaveLength(1)
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.listFailure = undefined
TestPersistence.loadFailure = new Error('load unavailable')
await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TestPersistence.loadFailure = new SessionQueryError('typed load failure', 'SESSION_QUERY_TEST_FAILURE')
await expect(ctx.sessionQuery.listEvents(persistedOnly.id)).rejects.toThrow(expectCode('SESSION_QUERY_TEST_FAILURE'))
await persistenceFiber.dispose()
TestPersistence.loadFailure = undefined
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([{ header: common, live: true, persisted: false }])
})
it('rejects immutable source header conflicts', async () => {
TestPersistence.reset([{ meta: header('conflict', 1, { cwd: '/persisted' }), events: eventLog() }])
const ctx = await liveContext()
ctx.sessions.create(SessionId('conflict'), { meta: { createdAt: 1, cwd: '/live' } })
await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
})
})
describe('provider selection and synchronization', () => {
it('selects one usable provider, validates requests/pages, and disposes registration', async () => {
const ctx = await liveContext({ defaultLimit: 2, maxLimit: 3 })
const session = ctx.sessions.create(SessionId('s'))
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
const dispose = ctx.sessionQuery.registerSearchProvider(provider)
const record: SessionRecord = { header: structuredClone(session.header), live: true, persisted: false }
const bestMatch = { sessionId: session.id, seq: 0, type: 'user/message' as const, time: session.events[0]!.time, surface: 'current' as const, snippet: 'hello' }
provider.sessionPage = { providerId: provider.id, items: [
{ ...record, bestMatch }, { ...record, bestMatch }, { ...record, bestMatch },
], nextCursor: 'next' }
const page = await ctx.sessionQuery.searchSessions({ query: ' hello ', sessionFilters: [{ kind: 'availability', values: ['live'] }] })
expect(page.items).toHaveLength(2)
expect(provider.sessionRequests[0]).toMatchObject({ query: 'hello', limit: 2 })
expect(provider.live.get(session.id)?.documents[0]?.text).toBe('hello')
await expect(ctx.sessionQuery.searchSessions({ query: ' ' })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_QUERY'))
await expect(ctx.sessionQuery.searchSessions({ query: 'x', limit: 4 })).rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LIMIT'))
provider.eventPage = { providerId: 'wrong', items: [] }
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_ERROR'))
provider.eventPage = { providerId: provider.id, items: [] }
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x', limit: 1 }, { signal: new AbortController().signal }))
.resolves.toMatchObject({ providerId: provider.id })
dispose()
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE'))
})
it('coalesces concurrent synchronization and supports cancellation while provider search is pending', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('coalesce'))
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
let releaseLive!: () => void
const liveBarrier = new Promise<void>((resolve) => { releaseLive = resolve })
let replacements = 0
provider.replaceLive = async (snapshot) => {
replacements += 1
await liveBarrier
provider.live.set(snapshot.session.header.id, structuredClone(snapshot))
}
const first = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const second = ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
await Promise.resolve()
releaseLive()
await Promise.all([first, second])
expect(replacements).toBe(1)
session.append('user/message', { content: [{ type: 'text', text: 'y' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
let releaseCorpus!: () => void
const corpusBarrier = new Promise<void>((resolve) => { releaseCorpus = resolve })
let corpusReplacements = 0
provider.replaceLive = async (snapshot) => {
corpusReplacements += 1
await corpusBarrier
provider.live.set(snapshot.session.header.id, structuredClone(snapshot))
}
const crossFirst = ctx.sessionQuery.searchSessions({ query: 'x' })
const crossSecond = ctx.sessionQuery.searchSessions({ query: 'x' })
await Promise.resolve()
releaseCorpus()
await Promise.all([crossFirst, crossSecond])
expect(corpusReplacements).toBe(1)
let releaseSearch!: () => void
const searchBarrier = new Promise<void>((resolve) => { releaseSearch = resolve })
provider.searchSessions = async () => {
await searchBarrier
return { providerId: provider.id, items: [] }
}
const controller = new AbortController()
const pending = ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: controller.signal })
await Promise.resolve()
await Promise.resolve()
controller.abort()
await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
releaseSearch()
await Promise.resolve()
provider.searchSessions = () => Promise.reject(new Error('search failed'))
await expect(ctx.sessionQuery.searchSessions({ query: 'x' }, { signal: new AbortController().signal }))
.rejects.toThrow('search failed')
})
it('searches a persisted target after corpus reconciliation', async () => {
const persisted = header('event-persisted', 1)
TestPersistence.reset([{ meta: persisted, events: eventLog('persisted target') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await expect(ctx.sessionQuery.searchEvents({ sessionId: persisted.id, query: 'target' }))
.resolves.toMatchObject({ providerId: provider.id })
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted target')
})
it('fails loudly for duplicate, configured, unavailable, and ambiguous providers', async () => {
const ctx = await liveContext()
const first = new FakeProvider('first')
ctx.sessionQuery.registerSearchProvider(first)
expect(() => ctx.sessionQuery.registerSearchProvider(new FakeProvider('first'))).toThrow(expectCode('SESSION_QUERY_DUPLICATE_PROVIDER'))
const second = new FakeProvider('second')
ctx.sessionQuery.registerSearchProvider(second)
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_AMBIGUOUS'))
const configured = await liveContext({ searchProvider: 'chosen' })
await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_MISSING'))
const chosen = new FakeProvider('chosen')
chosen.statusValue = { available: false, reason: 'unavailable' }
configured.sessionQuery.registerSearchProvider(chosen)
await expect(configured.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_CONFIGURED_UNAVAILABLE'))
chosen.statusValue = { available: true }
await expect(configured.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'chosen' })
})
it('removes provider registrations with their contributing fiber', async () => {
const ctx = await liveContext()
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
inner.sessionQuery.registerSearchProvider(new FakeProvider('scoped'))
}, { inject: ['sessionQuery'] }))
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: 'scoped' })
await fiber.dispose()
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_PROVIDER_UNAVAILABLE'))
})
it('reconciles persisted bases and live overrides, reuses fingerprints, and hides rows on unmount', async () => {
const persisted = header('persisted', 1)
const overlaid = header('overlaid', 1)
TestPersistence.reset([
{ meta: persisted, events: eventLog('persisted') },
{ meta: overlaid, events: eventLog('base') },
])
const ctx = await liveContext()
const live = ctx.sessions.create(overlaid.id, { meta: { createdAt: overlaid.createdAt } })
live.append('user/message', { content: [{ type: 'text', text: 'override' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const persistenceFiber = await ctx.plugin(TestPersistence)
const provider = new FakeProvider()
provider.failNextActive = true
provider.persisted.set(SessionId('stale'), { session: { header: header('stale'), live: false, persisted: true }, fingerprint: 'stale', documents: [] })
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('persisted')
expect(provider.live.get(overlaid.id)?.documents[0]?.text).toBe('override')
expect(provider.removedPersisted).toEqual([SessionId('stale')])
expect(provider.activeHistory.at(-1)).toBe(true)
const fingerprint = provider.persisted.get(persisted.id)?.fingerprint
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(persisted.id)?.fingerprint).toBe(fingerprint)
const announced = header('announced', 3)
TestPersistence.entries.set(announced.id, { meta: announced, events: eventLog('announced') })
await ctx.parallel('session/persisted', announced, { kind: 'append', fromSeq: 0, toSeq: 0 })
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.persisted.get(announced.id)?.documents[0]?.text).toBe('announced')
provider.failNextActive = true
await persistenceFiber.dispose()
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.activeHistory.at(-1)).toBe(false)
expect(provider.persisted.has(persisted.id)).toBe(true)
})
it('synchronizes only a live target for event search and retries dirty failures', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('target'))
session.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'one' })
expect(provider.live.get(session.id)?.documents[0]?.text).toBe('one')
session.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
provider.failNextLive = true
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'two' })).resolves.toMatchObject({ providerId: provider.id })
expect(provider.live.get(session.id)?.documents.map(document => document.text)).toEqual(['one', 'two'])
const controller = new AbortController()
controller.abort()
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' }, { signal: controller.signal }))
.rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('missing'), query: 'x' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
})
it('removes a disposed live override and reveals the provider base', async () => {
const persisted = header('fallback', 1)
TestPersistence.reset([{ meta: persisted, events: eventLog('base') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
let session!: Session
const liveFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create(persisted.id, { meta: { createdAt: persisted.createdAt } })
session.append('user/message', { content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}, { inject: ['sessions'] }))
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.live.has(session.id)).toBe(true)
await liveFiber.dispose()
await Promise.resolve()
await ctx.sessionQuery.searchSessions({ query: 'x' })
expect(provider.removedLive).toContain(session.id)
expect(provider.live.has(session.id)).toBe(false)
expect(provider.persisted.get(session.id)?.documents[0]?.text).toBe('base')
})
it('retries failed persisted reconciliation without affecting canonical writes', async () => {
const persisted = header('retry', 1)
TestPersistence.reset([{ meta: persisted, events: eventLog('retry') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const provider = new FakeProvider()
provider.failNextPersisted = true
ctx.sessionQuery.registerSearchProvider(provider)
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).rejects.toThrow(expectCode('SESSION_QUERY_INDEX_FAILED'))
await expect(ctx.sessionQuery.searchSessions({ query: 'x' })).resolves.toMatchObject({ providerId: provider.id })
expect(provider.persisted.get(persisted.id)?.documents[0]?.text).toBe('retry')
})
})
describe('semantic text extractors', () => {
it('indexes core semantic text and excludes chunks and structural events', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('semantic'))
const nested: ContentBlock[] = [
{ type: 'text', text: 'visible' },
{ type: 'reasoning', text: 'thinking' },
{ type: 'tool-call', id: CallId('block-call'), name: 'block-tool', arguments: '{"x":1}' },
{ type: 'tool-result', toolCallId: CallId('block-call'), content: [{ type: 'text', text: 'block-result' }] },
]
session.append('user/message', { content: nested, source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked prompt' }], source: { kind: 'user' }, reason: 'policy reason' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'shell', arguments: '{"cmd":"pwd"}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'tool output' }], isError: true, error: { name: 'ToolError', code: 'DENIED' } }, { surfaceOp: 'append' })
session.append('todo/write', { todos: [{ content: 'finish tests', status: 'in_progress' }] })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'model failed', code: 'MODEL' } })
session.append('turn/end', { turn: 1, reason: { kind: 'error', step: 1, message: 'uncoded failure' } })
session.append('turn/end', { turn: 2, reason: { kind: 'aborted' } })
session.append('turn/end', { turn: 3, reason: { kind: 'aborted', reason: 'cancelled' } })
session.append('turn/end', { turn: 4, reason: { kind: 'rejected', reason: 'rejected detail' } })
session.append('turn/end', { turn: 5, reason: { kind: 'disposed' } })
session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } })
session.append('turn/end', { turn: 7, reason: { kind: 'interrupted' } })
session.append('turn/end', { turn: 8, reason: { kind: 'completed' } })
session.append('tool/result', { turn: 1, step: 2, callId: CallId('c2'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw chunk' } })
session.append('step/start', { turn: 1, step: 2 })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const documents = provider.live.get(session.id)?.documents ?? []
expect(documents.map(document => document.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
expect(documents.map(document => document.text).join('\n')).toContain('visible\nthinking\nblock-tool\n{"x":1}\nblock-result')
expect(documents.map(document => document.text).join('\n')).toContain('blocked prompt\npolicy reason')
expect(documents.map(document => document.text).join('\n')).toContain('ToolError\nDENIED')
expect(documents.map(document => document.text).join('\n')).toContain('in_progress finish tests')
expect(documents.map(document => document.text).join('\n')).toContain('error\nmodel failed\nMODEL')
expect(documents.map(document => document.text).join('\n')).toContain('aborted\ncancelled')
expect(documents.map(document => document.text).join('\n')).toContain('rejected\nrejected detail')
expect(documents.map(document => document.text).join('\n')).toContain('disposed\nmax-tokens\ninterrupted')
expect(documents.map(document => document.text).join('\n')).not.toContain('raw chunk')
})
it('supports versioned effect-scoped custom event and content extractors', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('custom'))
session.append('test/note', { note: 'event note' })
session.append('user/message', { content: [{ type: 'test/text', value: 'block note' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const provider = new FakeProvider()
ctx.sessionQuery.registerSearchProvider(provider)
let disposeEvent!: () => void
let disposeContent!: () => void
const extractorFiber = await ctx.plugin(Object.assign((inner: Context) => {
disposeEvent = inner.sessionQuery.registerEventTextExtractor('test/note', { version: 'event-v1', extract: event => [event.data.note] })
disposeContent = inner.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v1', extract: block => [block.value] })
}, { inject: ['sessionQuery'] }))
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const first = provider.live.get(session.id)
expect(first?.documents.map(document => document.text)).toEqual(['event note', 'block note'])
expect(() => ctx.sessionQuery.registerEventTextExtractor('test/note', { version: 'v2', extract: () => [] }))
.toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR'))
expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: 'block-v2', extract: () => [] }))
.toThrow(expectCode('SESSION_QUERY_DUPLICATE_EXTRACTOR'))
expect(() => ctx.sessionQuery.registerContentTextExtractor('test/text', { version: ' ', extract: () => [] }))
.toThrow(expectCode('SESSION_QUERY_INVALID_EXTRACTOR'))
disposeEvent()
disposeContent()
await ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'x' })
const second = provider.live.get(session.id)
expect(second?.documents).toEqual([])
expect(second?.fingerprint).not.toBe(first?.fingerprint)
await extractorFiber.dispose()
})
})
describe('configuration', () => {
it('rejects an impossible default page size and exposes typed errors', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await expect(ctx.plugin(SessionQueryService, { defaultLimit: 3, maxLimit: 2 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
const error = new SessionQueryError('test', 'SESSION_QUERY_TEST')
expect(error).toMatchObject({ name: 'SessionQueryError', code: 'SESSION_QUERY_TEST' })
})
it('uses constructor defaults and removes the service on plugin disposal', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionQueryService)
const session = ctx.sessions.create(SessionId('defaults'))
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 0, after: 51 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
await fiber.dispose()
expect(ctx.sessionQuery).toBeUndefined()
const direct = new Context()
await direct.plugin(SessionStore)
const service = new SessionQueryService(direct, {})
const directSession = direct.sessions.create(SessionId('direct-defaults'))
await expect(service.readEvent({ sessionId: directSession.id, seq: 0, before: 51 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_WINDOW'))
await direct.fiber.dispose()
})
})