Merge remote-tracking branch 'origin/master' into session-query-search
# Conflicts: # .agents/notes/implemented/feature/2026-07-10-session-query-service.md # .agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md # .agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.md # docs/architecture.md # docs/capability-seams.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.md # docs/core-data-structures/persistence.md # docs/core-data-structures/session-query.md # docs/module-graph.md # docs/rfc/INDEX.md # packages/README.md # packages/cordis/tool-cordis/src/api-catalog.ts # packages/hooks/hooks-claude/tests/coverage.spec.ts # packages/session-persistence/session-persistence-jsonl/src/index.ts # packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts # packages/session-persistence/session-persistence-sqlite/README.md # packages/session-persistence/session-persistence-sqlite/src/index.ts # packages/session-persistence/session-persistence-sqlite/src/schema.ts # packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts # packages/session-persistence/session-persistence/README.md # packages/session-persistence/session-persistence/package.json # packages/session-query/README.md # packages/session-query/session-query/README.md # packages/session-query/session-query/package.json # packages/session-query/session-query/src/config.ts # packages/session-query/session-query/src/index.ts # packages/session-query/session-query/src/types.ts # pnpm-lock.yaml # scripts/gen-doc-graphs.ts # scripts/type-equiv.manifest.json # tsconfig.host.json # tsconfig.json
This commit is contained in:
@@ -49,7 +49,7 @@ describe('session-query semantic extraction', () => {
|
||||
]
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent, provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
|
||||
{ type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' },
|
||||
{ type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } },
|
||||
@@ -73,7 +73,7 @@ describe('session-query semantic extraction', () => {
|
||||
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
|
||||
[{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'],
|
||||
[{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'],
|
||||
[{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'],
|
||||
[{ kind: 'error', step: 2, failure: { message: 'provider boom', code: 'SERVER' } }, 'error\nprovider boom\nSERVER'],
|
||||
[{ kind: 'aborted' }, 'aborted'],
|
||||
[{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'],
|
||||
[{ kind: 'disposed' }, 'disposed'],
|
||||
@@ -90,11 +90,10 @@ describe('session-query semantic extraction', () => {
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } },
|
||||
{ type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
|
||||
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } },
|
||||
{ type: 'request/header-delta', seq: 5, time: 1, data: {} },
|
||||
{ type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never,
|
||||
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' } },
|
||||
{ type: 'future/event', seq: 5, time: 1, data: { text: 'hidden' } } as never,
|
||||
]
|
||||
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', ''])
|
||||
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', ''])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,7 +101,7 @@ describe('session-query document and filter helpers', () => {
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' },
|
||||
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
|
||||
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
|
||||
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
|
||||
]
|
||||
|
||||
@@ -173,7 +172,7 @@ describe('session-query document and filter helpers', () => {
|
||||
type: 'assistant/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] },
|
||||
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }], provenance: { provider: 'mock', model: 'mock' } },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
}]
|
||||
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
@@ -7,6 +7,7 @@ import SessionQueryService, {
|
||||
type SessionEventSurface,
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
@@ -35,6 +36,10 @@ class TestPersistence extends SessionPersistence {
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
@@ -90,6 +95,58 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
}
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => {
|
||||
const persistedHeader = header('persisted-title', 2)
|
||||
const sharedHeader = header('shared-title', 3)
|
||||
TestPersistence.reset([
|
||||
{
|
||||
meta: persistedHeader,
|
||||
events: [{
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 20,
|
||||
data: {
|
||||
title: 'Persisted title',
|
||||
messageSeqs: [4],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
{
|
||||
meta: sharedHeader,
|
||||
events: [{
|
||||
type: 'session/title',
|
||||
seq: 0,
|
||||
time: 30,
|
||||
data: {
|
||||
title: 'Stale durable title',
|
||||
messageSeqs: [1],
|
||||
source: { kind: 'fallback' },
|
||||
},
|
||||
}],
|
||||
},
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } })
|
||||
shared.append('session/title', {
|
||||
title: 'Live title',
|
||||
messageSeqs: [7],
|
||||
source: {
|
||||
kind: 'provider',
|
||||
provider: SessionTitleProviderId('query-test'),
|
||||
},
|
||||
})
|
||||
await ctx.plugin(TestPersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.readTitle(persistedHeader.id)).resolves.toMatchObject({
|
||||
title: 'Persisted title', eventSeq: 0, updatedAt: 20,
|
||||
})
|
||||
await expect(ctx.sessionQuery.readTitle(shared.id)).resolves.toMatchObject({
|
||||
title: 'Live title', eventSeq: 0,
|
||||
})
|
||||
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
|
||||
})
|
||||
|
||||
it('lists live sessions deterministically and returns detached headers', async () => {
|
||||
const ctx = await liveContext()
|
||||
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
|
||||
@@ -138,6 +195,8 @@ describe('session-query exact reads', () => {
|
||||
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
const first = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } },
|
||||
@@ -150,17 +209,76 @@ describe('session-query exact reads', () => {
|
||||
})
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq } },
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
|
||||
)
|
||||
|
||||
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
|
||||
expect((await ctx.sessionQuery.listEvents(session.id)).slice(2).map(record => record.surface))
|
||||
.toEqual(['shadowed', 'log-only', 'current'])
|
||||
})
|
||||
|
||||
it('reads a detached current surface with its raw-log capture boundary', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface-snapshot'), { meta: { cwd: '/work' } })
|
||||
const first = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'old' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'draft' },
|
||||
})
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
|
||||
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
|
||||
)
|
||||
const retained = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'retained tail' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
|
||||
{ surfaceOp: { op: 'replace', start: 2, end: retained.seq }, sourceEventSeqs: [2, retained.seq] },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'latest answer' }] },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
|
||||
const snapshot = await ctx.sessionQuery.readSurface(session.id)
|
||||
expect(snapshot.session).toEqual(session.header)
|
||||
expect(snapshot.capturedThroughSeq).toBe(5)
|
||||
expect(snapshot.events.map(event => [event.seq, event.type])).toEqual([
|
||||
[4, 'user/message'],
|
||||
[5, 'assistant/message'],
|
||||
])
|
||||
if (snapshot.events[0]?.type !== 'user/message') throw new Error('expected current user message')
|
||||
snapshot.events[0].data.content = []
|
||||
Object.assign(snapshot.session, { cwd: '/mutated' })
|
||||
|
||||
expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1)
|
||||
expect(session.header.cwd).toBe('/work')
|
||||
})
|
||||
|
||||
it('returns an empty current surface with a null capture boundary', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('empty-surface'))
|
||||
await expect(ctx.sessionQuery.readSurface(session.id)).resolves.toMatchObject({
|
||||
capturedThroughSeq: null,
|
||||
events: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns a bounded detached raw-event window and validates the request', async () => {
|
||||
const ctx = await liveContext({ readWindowMax: 1 })
|
||||
const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
for (const text of ['one', 'two', 'three']) {
|
||||
session.append(
|
||||
'user/message',
|
||||
@@ -169,14 +287,14 @@ describe('session-query exact reads', () => {
|
||||
)
|
||||
}
|
||||
|
||||
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 })
|
||||
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1])
|
||||
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 2, before: 1, after: 1 })
|
||||
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2])
|
||||
expect(result.session).toEqual(session.header)
|
||||
Object.assign(result.session, { createdAt: -1 })
|
||||
if (result.events[0]?.type !== 'user/message') throw new Error('expected user message')
|
||||
result.events[0].data.content = []
|
||||
expect(session.header.createdAt).not.toBe(-1)
|
||||
expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1)
|
||||
expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1)
|
||||
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
|
||||
@@ -198,6 +316,7 @@ describe('session-query exact reads', () => {
|
||||
])
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } })
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
|
||||
@@ -207,15 +326,24 @@ describe('session-query exact reads', () => {
|
||||
|
||||
expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted]))
|
||||
.toEqual([[shared.id, true, true], [durable.id, false, true]])
|
||||
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 })
|
||||
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 1 })
|
||||
expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0])
|
||||
.toMatchObject({ text: 'live' })
|
||||
await expect(ctx.sessionQuery.readSurface(shared.id)).resolves.toMatchObject({
|
||||
events: [{ data: { content: [{ text: 'live' }] } }],
|
||||
})
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ session: durable })
|
||||
await expect(ctx.sessionQuery.readSurface(durable.id)).resolves.toMatchObject({
|
||||
session: durable,
|
||||
events: [{ data: { content: [{ text: 'durable' }] } }],
|
||||
})
|
||||
|
||||
const sharedEntry = TestPersistence.entries.get(shared.id)!
|
||||
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/conflict' }
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/same', delegationDepth: 1 }
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
await persistence.dispose()
|
||||
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([
|
||||
{ header: shared, live: true, persisted: false },
|
||||
@@ -226,6 +354,7 @@ describe('session-query exact reads', () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(SessionId('live'))
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } },
|
||||
@@ -235,8 +364,8 @@ describe('session-query exact reads', () => {
|
||||
TestPersistence.listFailure = new Error('list unavailable')
|
||||
TestPersistence.loadFailure = new Error('load unavailable')
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } })
|
||||
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
|
||||
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
|
||||
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
@@ -265,17 +394,8 @@ describe('session-query exact reads', () => {
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
})
|
||||
|
||||
it('turns malformed surfaces and direct invalid config into typed errors', async () => {
|
||||
it('turns persisted malformed surfaces and direct invalid config into typed errors', 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 } },
|
||||
)
|
||||
await expect(ctx.sessionQuery.listEvents(session.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
|
||||
const persisted = header('bad-persisted-surface')
|
||||
TestPersistence.reset([{
|
||||
meta: persisted,
|
||||
|
||||
435
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
435
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
@@ -0,0 +1,435 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
||||
|
||||
/** Test-only mutable view used to verify detached returned metadata. */
|
||||
function mutableHeader(value: SessionHeader): MutableSessionHeader {
|
||||
return value
|
||||
}
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
}
|
||||
|
||||
function appendEvent(seq: number, sources?: number[]): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: seq + 1,
|
||||
data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
...sources === undefined ? {} : { sourceEventSeqs: sources },
|
||||
}
|
||||
}
|
||||
|
||||
class TracePersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listCalls = 0
|
||||
static loadCalls = 0
|
||||
static listFailure: Error | undefined
|
||||
static loadFailure: Error | undefined
|
||||
static afterList: (() => void) | undefined
|
||||
|
||||
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
|
||||
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
|
||||
this.listCalls = 0
|
||||
this.loadCalls = 0
|
||||
this.listFailure = undefined
|
||||
this.loadFailure = undefined
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
locate(_meta: SessionHeader): undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
create(meta: SessionHeader): Promise<void> {
|
||||
TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
entry.events.push(...structuredClone(events))
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
TracePersistence.loadCalls += 1
|
||||
if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure)
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
TracePersistence.listCalls += 1
|
||||
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)
|
||||
const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta))
|
||||
TracePersistence.afterList?.()
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
listSnapshots(): Promise<never[]> {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
}
|
||||
|
||||
async function queryContext(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function expectCode(code: SessionQueryErrorCode): Error {
|
||||
return expect.objectContaining({ code }) as Error
|
||||
}
|
||||
|
||||
function appendTraceEvents(session: Session): void {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
session.append('assistant/chunk', {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
chunk: { type: 'text-delta', index: 0, text: 'draft' },
|
||||
})
|
||||
session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [2] },
|
||||
)
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] },
|
||||
)
|
||||
session.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('step/start', { turn: 1, step: 2 })
|
||||
session.append(
|
||||
'assistant/message',
|
||||
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] },
|
||||
)
|
||||
}
|
||||
|
||||
describe('session lineage tracing', () => {
|
||||
it('returns complete ancestry, deterministic descendant trees, and detached records', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 0 } })
|
||||
const parent = ctx.sessions.create(SessionId('parent'), {
|
||||
meta: { createdAt: 1, parentSession: root.id },
|
||||
})
|
||||
const target = ctx.sessions.create(SessionId('target'), {
|
||||
meta: { createdAt: 2, parentSession: parent.id },
|
||||
})
|
||||
ctx.sessions.create(SessionId('b'), { meta: { createdAt: 4, parentSession: target.id } })
|
||||
const childA = ctx.sessions.create(SessionId('a'), {
|
||||
meta: { createdAt: 4, parentSession: target.id },
|
||||
})
|
||||
ctx.sessions.create(SessionId('older'), { meta: { createdAt: 3, parentSession: target.id } })
|
||||
ctx.sessions.create(SessionId('grandchild'), {
|
||||
meta: { createdAt: 5, parentSession: childA.id },
|
||||
})
|
||||
|
||||
const trace = await ctx.sessionQuery.traceSession(target.id)
|
||||
expect(trace.complete).toBe(true)
|
||||
if (!trace.complete) throw new Error('expected complete lineage')
|
||||
expect(trace.ancestors.map(record => record.header.id)).toEqual([parent.id, root.id])
|
||||
expect(trace.root.header.id).toBe(root.id)
|
||||
expect(trace.descendants.map(node => node.session.header.id))
|
||||
.toEqual([SessionId('older'), SessionId('a'), SessionId('b')])
|
||||
expect(trace.descendants[1]?.descendants.map(node => node.session.header.id))
|
||||
.toEqual([SessionId('grandchild')])
|
||||
|
||||
mutableHeader(trace.target.header).createdAt = 99
|
||||
mutableHeader(trace.ancestors[0]!.header).createdAt = 99
|
||||
mutableHeader(trace.root.header).createdAt = 99
|
||||
mutableHeader(trace.descendants[0]!.session.header).createdAt = 99
|
||||
const repeated = await ctx.sessionQuery.traceSession(target.id)
|
||||
expect(repeated.target.header.createdAt).toBe(2)
|
||||
expect(repeated.ancestors[0]?.header.createdAt).toBe(1)
|
||||
expect(repeated.descendants[0]?.session.header.createdAt).toBe(3)
|
||||
})
|
||||
|
||||
it('represents root and unresolved-parent traces explicitly', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } })
|
||||
const partial = ctx.sessions.create(SessionId('partial'), {
|
||||
meta: { createdAt: 2, parentSession: SessionId('outside') },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(root.id)).resolves.toMatchObject({
|
||||
complete: true,
|
||||
root: { header: { id: root.id } },
|
||||
ancestors: [],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({
|
||||
complete: false,
|
||||
unresolvedParentId: SessionId('outside'),
|
||||
ancestors: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects target-connected cycles and missing targets', async () => {
|
||||
const ctx = await queryContext()
|
||||
ctx.sessions.create(SessionId('a'), {
|
||||
meta: { createdAt: 1, parentSession: SessionId('b') },
|
||||
})
|
||||
ctx.sessions.create(SessionId('b'), {
|
||||
meta: { createdAt: 2, parentSession: SessionId('a') },
|
||||
})
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(SessionId('a')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE'))
|
||||
await expect(ctx.sessionQuery.traceSession(SessionId('missing')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
})
|
||||
|
||||
it('uses one cross-corpus observation and preserves persistence failure semantics', async () => {
|
||||
const durable = header('durable')
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id)).resolves.toMatchObject({
|
||||
target: { live: false, persisted: true },
|
||||
complete: true,
|
||||
})
|
||||
expect(TracePersistence.listCalls).toBe(1)
|
||||
expect(TracePersistence.loadCalls).toBe(0)
|
||||
|
||||
TracePersistence.listFailure = new Error('unavailable')
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
})
|
||||
|
||||
it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => {
|
||||
const ctx = await queryContext()
|
||||
const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } })
|
||||
let parent = root
|
||||
for (let depth = 1; depth < 3_000; depth += 1) {
|
||||
parent = ctx.sessions.create(SessionId(`deep-${depth}`), {
|
||||
meta: { createdAt: depth, parentSession: parent.id },
|
||||
})
|
||||
}
|
||||
|
||||
const trace = await ctx.sessionQuery.traceSession(root.id)
|
||||
expect(trace.complete).toBe(true)
|
||||
let node = trace.descendants[0]
|
||||
for (let depth = 1; depth < 3_000; depth += 1) {
|
||||
if (node === undefined) throw new Error(`lineage ended before depth ${depth}`)
|
||||
if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999'))
|
||||
node = node.descendants[0]
|
||||
}
|
||||
expect(node).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('session event tracing', () => {
|
||||
it('returns direct replacement and provenance links in their contract order', async () => {
|
||||
const ctx = await queryContext()
|
||||
const session = ctx.sessions.create(SessionId('trace'))
|
||||
appendTraceEvents(session)
|
||||
|
||||
const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 3 })
|
||||
expect(original.target).toMatchObject({
|
||||
sessionId: session.id,
|
||||
seq: 3,
|
||||
type: 'user/message',
|
||||
surface: 'shadowed',
|
||||
})
|
||||
expect(original).toMatchObject({
|
||||
replacedBy: 4,
|
||||
replacementChain: [4, 8],
|
||||
replacedEventSeqs: [],
|
||||
sourceEventSeqs: [2],
|
||||
derivedEventSeqs: [4],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }))
|
||||
.resolves.toMatchObject({
|
||||
replacedBy: 8,
|
||||
replacementChain: [8],
|
||||
replacedEventSeqs: [3],
|
||||
sourceEventSeqs: [3, 2],
|
||||
derivedEventSeqs: [8],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }))
|
||||
.resolves.toMatchObject({
|
||||
target: { surface: 'log-only' },
|
||||
replacementChain: [],
|
||||
sourceEventSeqs: [],
|
||||
derivedEventSeqs: [3, 4, 8],
|
||||
})
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 8 }))
|
||||
.resolves.toMatchObject({
|
||||
replacementChain: [],
|
||||
replacedEventSeqs: [4],
|
||||
sourceEventSeqs: [2, 4],
|
||||
derivedEventSeqs: [],
|
||||
})
|
||||
})
|
||||
|
||||
it('returns fresh trace arrays and target records', async () => {
|
||||
const ctx = await queryContext()
|
||||
const session = ctx.sessions.create(SessionId('detached'))
|
||||
appendTraceEvents(session)
|
||||
|
||||
const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })
|
||||
first.target.time = -1
|
||||
first.replacementChain.push(99)
|
||||
first.replacedEventSeqs.push(99)
|
||||
first.sourceEventSeqs.push(99)
|
||||
first.derivedEventSeqs.push(99)
|
||||
const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })
|
||||
expect(repeated.target.time).not.toBe(-1)
|
||||
expect(repeated.replacementChain).toEqual([8])
|
||||
expect(repeated.replacedEventSeqs).toEqual([3])
|
||||
expect(repeated.sourceEventSeqs).toEqual([3, 2])
|
||||
expect(repeated.derivedEventSeqs).toEqual([8])
|
||||
})
|
||||
|
||||
it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => {
|
||||
const durable = header('shared', 1, { cwd: '/same' })
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
|
||||
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
live.append(
|
||||
'context/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 }))
|
||||
.resolves.toMatchObject({ target: { type: 'context/message' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const failedCtx = await queryContext()
|
||||
await failedCtx.plugin(TracePersistence)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.listFailure = undefined
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.loadFailure = undefined
|
||||
TracePersistence.afterList = () => {
|
||||
mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed'
|
||||
}
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
|
||||
})
|
||||
|
||||
it('checks target existence before surface or provenance analysis', async () => {
|
||||
const bad = header('bad-target')
|
||||
const malformed: SessionEvent[] = [appendEvent(0), {
|
||||
type: 'assistant/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } },
|
||||
surfaceOp: { op: 'replace', start: 9, end: 9 },
|
||||
sourceEventSeqs: [],
|
||||
}]
|
||||
TracePersistence.reset([{ meta: bad, events: malformed }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it.each([
|
||||
['non-surface sources', [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] },
|
||||
]],
|
||||
['invalid source array', [
|
||||
{ ...appendEvent(0), sourceEventSeqs: 'invalid' },
|
||||
]],
|
||||
['empty sources', [
|
||||
appendEvent(0, []),
|
||||
]],
|
||||
['sparse sources', [
|
||||
appendEvent(0, Array<number>(1)),
|
||||
]],
|
||||
['duplicate sources', [
|
||||
appendEvent(0),
|
||||
appendEvent(1, [0, 0]),
|
||||
]],
|
||||
['missing earlier source', [
|
||||
appendEvent(0),
|
||||
appendEvent(1, [-1]),
|
||||
]],
|
||||
['future source', [
|
||||
appendEvent(0, [1]),
|
||||
appendEvent(1),
|
||||
]],
|
||||
['replacement without sources', [
|
||||
appendEvent(0),
|
||||
{ ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
]],
|
||||
['replacement missing a shadowed source', [
|
||||
{ type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } },
|
||||
appendEvent(1),
|
||||
{ ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } },
|
||||
]],
|
||||
] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => {
|
||||
const durable = header('invalid-provenance')
|
||||
const events = structuredClone(rawEvents) as unknown as SessionEvent[]
|
||||
TracePersistence.reset([{ meta: durable, events }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it('rejects surfaceOp on a non-surface event as an invalid surface', async () => {
|
||||
const durable = header('invalid-non-surface-op')
|
||||
const events = [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
surfaceOp: 'append',
|
||||
}] as unknown as SessionEvent[]
|
||||
TracePersistence.reset([{ meta: durable, events }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
|
||||
it('applies the same surface contract to listEvents', async () => {
|
||||
const durable = header('list-regression')
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }])
|
||||
const ctx = await queryContext()
|
||||
await ctx.plugin(TracePersistence)
|
||||
|
||||
await expect(ctx.sessionQuery.listEvents(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user