Merge remote-tracking branch 'origin/master' into feat/send-unify
# Conflicts: # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl # packages/cordis/tool-cordis/src/api-catalog.ts # packages/pty/pty-local/tests/index.spec.ts # packages/session-query/session-query/tests/tracing.spec.ts
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
buildSessionEventRecords,
|
||||
buildSessionEventSearchDocuments,
|
||||
compileSessionTextFilter,
|
||||
extractSessionEventText,
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { TestSessionQueryService } from './test-service.ts'
|
||||
|
||||
const id = SessionId('session')
|
||||
|
||||
function header(value: string, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(value), createdAt: 10, ...extra }
|
||||
}
|
||||
|
||||
function expectCode(code: SessionQueryErrorCode): Error {
|
||||
return expect.objectContaining({ code }) as Error
|
||||
}
|
||||
|
||||
describe('session-query semantic extraction', () => {
|
||||
it('extracts first-party message, tool, todo, and failure detail', () => {
|
||||
const callId = CallId('call')
|
||||
const messageContent: SessionEvent<'user/message'>['data']['content'] = [
|
||||
{ type: 'text', text: ' visible ' },
|
||||
{ type: 'reasoning', text: 'thought' },
|
||||
{ type: 'tool-call', id: callId, name: 'read', arguments: '{"path":"a"}' },
|
||||
{
|
||||
type: 'tool-result',
|
||||
toolCallId: callId,
|
||||
content: [{ type: 'text', text: 'nested' }],
|
||||
isError: false,
|
||||
},
|
||||
{ type: 'future-content', payload: 'hidden' } as never,
|
||||
]
|
||||
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, provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
|
||||
{ type: 'user/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' } },
|
||||
{ type: 'tool/call', seq: 5, time: 6, data: { turn: 1, step: 1, callId, name: 'bash', arguments: '{"cmd":"pwd"}' } },
|
||||
{ type: 'tool/result', seq: 6, time: 7, data: { turn: 1, step: 1, callId, content: [{ type: 'text', text: 'failed' }], isError: true, error: { name: 'Oops', code: 'E_OOPS' } }, surfaceOp: 'append' },
|
||||
{ type: 'tool/result', seq: 7, time: 8, data: { turn: 1, step: 1, callId, content: [], isError: false }, surfaceOp: 'append' },
|
||||
{ type: 'todo/write', seq: 8, time: 9, data: { todos: [{ status: 'in_progress', content: 'ship search' }] } },
|
||||
]
|
||||
|
||||
for (const event of events.slice(0, 4)) {
|
||||
expect(extractSessionEventText(event)).toBe('visible\nthought\nread\n{"path":"a"}\nnested')
|
||||
}
|
||||
expect(extractSessionEventText(events[4]!)).toBe('unsafe\npolicy')
|
||||
expect(extractSessionEventText(events[5]!)).toBe('bash\n{"cmd":"pwd"}')
|
||||
expect(extractSessionEventText(events[6]!)).toBe('failed\nOops\nE_OOPS')
|
||||
expect(extractSessionEventText(events[7]!)).toBe('')
|
||||
expect(extractSessionEventText(events[8]!)).toBe('in_progress\nship search')
|
||||
})
|
||||
|
||||
it('extracts meaningful turn outcomes and skips structural or unknown events', () => {
|
||||
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: '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'],
|
||||
[{ kind: 'max-tokens' }, 'max-tokens'],
|
||||
[{ kind: 'interrupted' }, 'interrupted'],
|
||||
[{ kind: 'completed' }, ''],
|
||||
[{ kind: 'future-status' } as never, ''],
|
||||
]
|
||||
for (const [reason, text] of reasons) {
|
||||
expect(extractSessionEventText({ type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason } })).toBe(text)
|
||||
}
|
||||
const structural: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
||||
{ 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: { provider: 'test', model: 'test' } }, reason: 'initial' } },
|
||||
{ type: 'future/event', seq: 5, time: 1, data: { text: 'hidden' } } as never,
|
||||
]
|
||||
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', ''])
|
||||
})
|
||||
})
|
||||
|
||||
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' }], 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' } } },
|
||||
]
|
||||
|
||||
it('classifies every event and omits non-semantic documents', () => {
|
||||
expect(buildSessionEventRecords(id, events).map(record => record.surface))
|
||||
.toEqual(['shadowed', 'log-only', 'current', 'log-only'])
|
||||
const documents = buildSessionEventSearchDocuments(id, events)
|
||||
expect(documents.map(document => [document.seq, document.text, document.surface])).toEqual([
|
||||
[0, 'Hello\n(AI)+', 'shadowed'],
|
||||
[2, 'replacement', 'current'],
|
||||
[3, 'interrupted', 'log-only'],
|
||||
])
|
||||
})
|
||||
|
||||
it('applies every session clause with OR values and validates closed values', () => {
|
||||
const parent = SessionId('parent')
|
||||
const records = [
|
||||
{ header: header('a', { cwd: '/a', parentSession: parent }), live: true, persisted: false, marker: 1 },
|
||||
{ header: header('b', { createdAt: 20 }), live: false, persisted: true, marker: 2 },
|
||||
]
|
||||
expect(filterSessionResults(records, [
|
||||
{ kind: 'id', values: [SessionId('a'), SessionId('x')] },
|
||||
{ kind: 'cwd', values: ['/a', null] },
|
||||
{ kind: 'created-at', from: 5, to: 15 },
|
||||
{ kind: 'parent', values: [parent, null] },
|
||||
{ kind: 'availability', values: ['live'] },
|
||||
])).toEqual([records[0]])
|
||||
expect(filterSessionResults(records, [{ kind: 'cwd', values: [null] }])).toEqual([records[1]])
|
||||
expect(filterSessionResults(records, [{ kind: 'parent', values: [null] }])).toEqual([records[1]])
|
||||
expect(filterSessionResults(records, [{ kind: 'availability', values: ['persisted'] }])).toEqual([records[1]])
|
||||
expect(() => filterSessionResults(records, [{ kind: 'availability', values: ['remote' as never] }]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('applies event metadata and safe literal text clauses', () => {
|
||||
const documents = buildSessionEventSearchDocuments(id, events).map((document, marker) => ({ ...document, marker }))
|
||||
expect(filterSessionEventDocuments(documents, [
|
||||
{ kind: 'seq', from: 0, to: 1 },
|
||||
{ kind: 'time', from: 9, to: 11 },
|
||||
{ kind: 'type', values: ['user/message', 'tool/result'] },
|
||||
{ kind: 'surface', values: ['shadowed'] },
|
||||
{ kind: 'text', text: 'hello (ai)+' },
|
||||
])).toEqual([documents[0]])
|
||||
expect(compileSessionTextFilter('CAFÉ').test('café')).toBe(true)
|
||||
expect(filterSessionEventDocuments(documents)).toEqual(documents)
|
||||
expect(filterSessionEventDocuments(documents, [{ kind: 'surface', values: [] }])).toEqual([])
|
||||
expect(() => filterSessionEventDocuments(documents, [{ kind: 'surface', values: ['future' as never] }]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => compileSessionTextFilter(' \n ')).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('rejects malformed range filters and malformed surfaces', () => {
|
||||
const documents = buildSessionEventSearchDocuments(id, events)
|
||||
for (const filter of [
|
||||
{ kind: 'seq', from: Number.NaN },
|
||||
{ kind: 'seq', to: Number.POSITIVE_INFINITY },
|
||||
{ kind: 'time', from: 2, to: 1 },
|
||||
] as const) {
|
||||
expect(() => filterSessionEventDocuments(documents, [filter]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
}
|
||||
expect(() => filterSessionResults([], [{ kind: 'created-at', from: Number.NaN }]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => filterSessionResults([{ header: header('x'), live: true, persisted: false }], [
|
||||
{ kind: 'created-at', from: Number.NaN },
|
||||
])).toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
const malformed: SessionEvent[] = [{
|
||||
type: 'assistant/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
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'))
|
||||
})
|
||||
|
||||
it('owns filters and rejects malformed runtime filter shapes deterministically', () => {
|
||||
expect(materializeSessionResultFilters([{ kind: 'created-at', to: 2 }]))
|
||||
.toEqual([{ kind: 'created-at', to: 2 }])
|
||||
expect(() => materializeSessionResultFilters('not-an-array' as never))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'id', values: 'bad' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'id', values: [1] } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'cwd', values: 'bad' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{ kind: 'parent', values: [1] } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionResultFilters([{} as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionEventResultFilters([{ kind: 'text', text: 1 } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => materializeSessionEventResultFilters([{ kind: 'future' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => filterSessionResults([], [{ kind: 'future' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
expect(() => filterSessionEventDocuments([], [{ kind: 'future' } as never]))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
})
|
||||
|
||||
it('exposes the scan path on the combined query service', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
const session = ctx.sessions.create(id)
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'Alpha\n beta' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
session.append('user/message', { content: [{ type: 'text', text: 'other' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
await expect(ctx.sessionQuery.filterEvents(id, [{ kind: 'text', text: 'alpha beta' }]))
|
||||
.resolves.toMatchObject([{ seq: 0, text: 'Alpha\n beta' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('registers exact and abstract search behavior under one ctx key', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(TestSessionQueryService)
|
||||
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { Context, type Fiber } from 'cordis'
|
||||
import SessionStore, { SESSION_FORMAT_VERSION, 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 SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
|
||||
import SessionQueryService, {
|
||||
type SessionEventSurface,
|
||||
type SessionQueryErrorCode,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
|
||||
import { TestSessionQueryService } from './test-service.ts'
|
||||
|
||||
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
|
||||
@@ -25,13 +27,15 @@ function eventLog(text = 'hello'): SessionEvent[] {
|
||||
class TestPersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listFailure: unknown
|
||||
static loadFailure: unknown
|
||||
static inspectFailure: unknown
|
||||
static inspectEffect: (() => void) | 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.listFailure = undefined
|
||||
this.loadFailure = undefined
|
||||
this.inspectFailure = undefined
|
||||
this.inspectEffect = undefined
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
@@ -52,10 +56,17 @@ class TestPersistence extends SessionPersistence {
|
||||
}
|
||||
|
||||
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
if (TestPersistence.loadFailure !== undefined) return rejectUnknown(TestPersistence.loadFailure)
|
||||
return this.inspect(id)
|
||||
}
|
||||
|
||||
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure)
|
||||
const entry = TestPersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
const result = structuredClone(entry)
|
||||
TestPersistence.inspectEffect?.()
|
||||
TestPersistence.inspectEffect = undefined
|
||||
return Promise.resolve(result)
|
||||
}
|
||||
|
||||
list(): Promise<SessionHeader[]> {
|
||||
@@ -64,12 +75,20 @@ class TestPersistence extends SessionPersistence {
|
||||
TestPersistence.afterList?.()
|
||||
return Promise.resolve(headers)
|
||||
}
|
||||
|
||||
|
||||
async listSnapshots() {
|
||||
return [...TestPersistence.entries.values()].map(entry => ({
|
||||
header: structuredClone(entry.meta),
|
||||
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
async function liveContext(config: ConstructorParameters<typeof SessionQueryService>[1] = {}): Promise<Context> {
|
||||
async function liveContext(config: ConstructorParameters<typeof TestSessionQueryService>[1] = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQueryService, config)
|
||||
await ctx.plugin(TestSessionQueryService, config)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -86,6 +105,22 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
|
||||
}
|
||||
|
||||
describe('session-query exact reads', () => {
|
||||
it('prefers a live owner that attaches while its persisted prefix is inspected', async () => {
|
||||
const shared = header('attach-during-inspect', 2)
|
||||
TestPersistence.reset([{ meta: shared, events: eventLog('persisted') }])
|
||||
const ctx = await liveContext()
|
||||
await ctx.plugin(TestPersistence)
|
||||
TestPersistence.inspectEffect = () => {
|
||||
ctx.sessions.create(shared.id, {
|
||||
seed: eventLog('live'),
|
||||
meta: { createdAt: shared.createdAt },
|
||||
})
|
||||
}
|
||||
|
||||
await expect(ctx.sessionQuery.filterEvents(shared.id, []))
|
||||
.resolves.toMatchObject([{ sessionId: shared.id, text: 'live' }])
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -151,6 +186,38 @@ describe('session-query exact reads', () => {
|
||||
expect(older.header.createdAt).toBe(1)
|
||||
})
|
||||
|
||||
it('filters sessions symmetrically and owns mutable filter values immediately', async () => {
|
||||
const durable = header('durable-filter', 1)
|
||||
TestPersistence.reset([{ meta: durable, events: eventLog('durable') }])
|
||||
const ctx = await liveContext()
|
||||
const live = ctx.sessions.create(SessionId('live-filter'), { meta: { createdAt: 2 } })
|
||||
live.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
|
||||
const ids = [durable.id]
|
||||
const filtered = ctx.sessionQuery.filterSessions([{ kind: 'id', values: ids }])
|
||||
ids[0] = live.id
|
||||
await expect(filtered).resolves.toEqual([{
|
||||
header: durable,
|
||||
live: false,
|
||||
persisted: true,
|
||||
}])
|
||||
|
||||
const surfaces: SessionEventSurface[] = ['current']
|
||||
const events = ctx.sessionQuery.filterEvents(live.id, [{ kind: 'surface', values: surfaces }])
|
||||
surfaces[0] = 'shadowed'
|
||||
await expect(events).resolves.toMatchObject([{ sessionId: live.id, surface: 'current', text: 'live' }])
|
||||
await expect(ctx.sessionQuery.filterSessions([{ kind: 'future' } as never]))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
await expect(ctx.sessionQuery.filterEvents(live.id, [{ kind: 'future' } as never]))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_FILTER'))
|
||||
await persistence.dispose()
|
||||
})
|
||||
|
||||
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
|
||||
const ctx = await liveContext()
|
||||
const session = ctx.sessions.create(SessionId('surface'))
|
||||
@@ -301,6 +368,8 @@ describe('session-query exact reads', () => {
|
||||
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 },
|
||||
@@ -319,7 +388,7 @@ describe('session-query exact reads', () => {
|
||||
)
|
||||
await ctx.plugin(TestPersistence)
|
||||
TestPersistence.listFailure = new Error('list unavailable')
|
||||
TestPersistence.loadFailure = new Error('load unavailable')
|
||||
TestPersistence.inspectFailure = new Error('inspect unavailable')
|
||||
|
||||
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 } })
|
||||
@@ -337,10 +406,10 @@ describe('session-query exact reads', () => {
|
||||
await expect(ctx.sessionQuery.listEvents(SessionId('absent')))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
|
||||
|
||||
TestPersistence.loadFailure = 'raw failure'
|
||||
TestPersistence.inspectFailure = 'raw failure'
|
||||
await expect(ctx.sessionQuery.listEvents(durable.id))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TestPersistence.loadFailure = undefined
|
||||
TestPersistence.inspectFailure = undefined
|
||||
const durableEntry = TestPersistence.entries.get(durable.id)!
|
||||
durableEntry.meta = { ...durableEntry.meta, cwd: '/changed-after-list' }
|
||||
TestPersistence.afterList = () => {
|
||||
@@ -370,19 +439,41 @@ describe('session-query exact reads', () => {
|
||||
|
||||
const direct = new Context()
|
||||
await direct.plugin(SessionStore)
|
||||
expect(new SessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
|
||||
expect(new TestSessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
|
||||
const invalid = new Context()
|
||||
await invalid.plugin(SessionStore)
|
||||
expect(() => new SessionQueryService(invalid, { readWindowMax: -1 }))
|
||||
expect(() => new TestSessionQueryService(invalid, { readWindowMax: -1 }))
|
||||
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
|
||||
})
|
||||
|
||||
it('leaves the optional persistence dependency optional', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const fiber = await ctx.plugin(SessionQueryService)
|
||||
expect(ctx.sessionQuery).toBeInstanceOf(SessionQueryService)
|
||||
const fiber = await ctx.plugin(TestSessionQueryService)
|
||||
expect(ctx.sessionQuery).toBeInstanceOf(TestSessionQueryService)
|
||||
await fiber.dispose()
|
||||
expect(ctx.sessionQuery).toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits optional-persistence child-fiber quiescence on disposal', async () => {
|
||||
TestPersistence.reset()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const query = await ctx.plugin(TestSessionQueryService)
|
||||
const persistence = await ctx.plugin(TestPersistence)
|
||||
const optional = (ctx.sessionQuery as unknown as {
|
||||
_corpus: { _optionalPersistenceFiber: Fiber }
|
||||
})._corpus._optionalPersistenceFiber
|
||||
let release!: () => void
|
||||
const cleanup = new Promise<void>((resolve) => { release = resolve })
|
||||
optional.ctx.effect(() => () => cleanup)
|
||||
|
||||
let settled = false
|
||||
const disposing = query.dispose().then(() => { settled = true })
|
||||
await Promise.resolve()
|
||||
expect(settled).toBe(false)
|
||||
release()
|
||||
await disposing
|
||||
await persistence.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
26
packages/session-query/session-query/tests/test-service.ts
Normal file
26
packages/session-query/session-query/tests/test-service.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import SessionQueryService from '@deepseek-ai/dsh-session-query'
|
||||
import type {
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchRequest,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
} from '@deepseek-ai/dsh-session-query'
|
||||
|
||||
/** Test-only concrete query service for backend-independent behavior. */
|
||||
export class TestSessionQueryService extends SessionQueryService {
|
||||
override searchSessions(
|
||||
_request: SessionSearchRequest,
|
||||
_exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
|
||||
override searchEvents(
|
||||
_request: SessionEventSearchRequest,
|
||||
_exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>> {
|
||||
return Promise.resolve({ items: [] })
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,8 @@ 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'
|
||||
import { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
|
||||
import { TestSessionQueryService } from './test-service.ts'
|
||||
|
||||
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
||||
|
||||
@@ -30,17 +31,17 @@ function appendEvent(seq: number, sources?: number[]): SessionEvent {
|
||||
class TracePersistence extends SessionPersistence {
|
||||
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
|
||||
static listCalls = 0
|
||||
static loadCalls = 0
|
||||
static inspectCalls = 0
|
||||
static listFailure: Error | undefined
|
||||
static loadFailure: Error | undefined
|
||||
static inspectFailure: 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.inspectCalls = 0
|
||||
this.listFailure = undefined
|
||||
this.loadFailure = undefined
|
||||
this.inspectFailure = undefined
|
||||
this.afterList = undefined
|
||||
}
|
||||
|
||||
@@ -61,8 +62,12 @@ class TracePersistence extends SessionPersistence {
|
||||
}
|
||||
|
||||
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
TracePersistence.loadCalls += 1
|
||||
if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure)
|
||||
return this.inspect(id)
|
||||
}
|
||||
|
||||
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
|
||||
TracePersistence.inspectCalls += 1
|
||||
if (TracePersistence.inspectFailure !== undefined) return Promise.reject(TracePersistence.inspectFailure)
|
||||
const entry = TracePersistence.entries.get(id)
|
||||
if (entry === undefined) return Promise.reject(new Error('missing test session'))
|
||||
return Promise.resolve(structuredClone(entry))
|
||||
@@ -75,12 +80,16 @@ class TracePersistence extends SessionPersistence {
|
||||
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)
|
||||
await ctx.plugin(TestSessionQueryService)
|
||||
return ctx
|
||||
}
|
||||
|
||||
@@ -204,7 +213,7 @@ describe('session lineage tracing', () => {
|
||||
complete: true,
|
||||
})
|
||||
expect(TracePersistence.listCalls).toBe(1)
|
||||
expect(TracePersistence.loadCalls).toBe(0)
|
||||
expect(TracePersistence.inspectCalls).toBe(0)
|
||||
|
||||
TracePersistence.listFailure = new Error('unavailable')
|
||||
await expect(ctx.sessionQuery.traceSession(durable.id))
|
||||
@@ -296,7 +305,7 @@ describe('session event tracing', () => {
|
||||
expect(repeated.derivedEventSeqs).toEqual([8])
|
||||
})
|
||||
|
||||
it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => {
|
||||
it('inspects 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()
|
||||
@@ -304,7 +313,7 @@ describe('session event tracing', () => {
|
||||
|
||||
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])
|
||||
expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).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' } } })
|
||||
@@ -314,10 +323,10 @@ describe('session event tracing', () => {
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
TracePersistence.listFailure = new Error('list unavailable')
|
||||
TracePersistence.loadFailure = new Error('load unavailable')
|
||||
TracePersistence.inspectFailure = new Error('inspect unavailable')
|
||||
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 }))
|
||||
.resolves.toMatchObject({ target: { type: 'user/message' } })
|
||||
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
|
||||
expect([TracePersistence.listCalls, TracePersistence.inspectCalls]).toEqual([1, 1])
|
||||
|
||||
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
|
||||
const failedCtx = await queryContext()
|
||||
@@ -326,10 +335,10 @@ describe('session event tracing', () => {
|
||||
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')
|
||||
TracePersistence.inspectFailure = new Error('inspect unavailable')
|
||||
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
|
||||
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
|
||||
TracePersistence.loadFailure = undefined
|
||||
TracePersistence.inspectFailure = undefined
|
||||
TracePersistence.afterList = () => {
|
||||
mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user