feat(session-query): add SQLite full-text search

This commit is contained in:
Hypatia May
2026-07-15 10:51:38 +08:00
parent e9f0c37745
commit ecf90ff382
38 changed files with 3181 additions and 120 deletions
@@ -0,0 +1,209 @@
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 SessionQueryService, {
buildSessionEventRecords,
buildSessionEventSearchDocuments,
compileSessionTextFilter,
extractSessionEventText,
filterSessionEventDocuments,
filterSessionResults,
SessionSearchService,
type SessionEventSearchHit,
type SessionEventSearchRequest,
type SessionQueryErrorCode,
type SessionSearchExecContext,
type SessionSearchHit,
type SessionSearchPage,
type SessionSearchRequest,
} from '@deepseek-ai/dsh-session-query'
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 }, 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' } },
{ 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: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'],
[{ 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: { 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,
]
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' }] }, surfaceOp: { op: 'replace', start: 0, end: 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' }] },
surfaceOp: { op: 'replace', start: 9, end: 9 },
}]
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('exposes the scan path on the concrete exact-read service', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService)
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' }])
})
})
class TestSearchService extends SessionSearchService {
searchSessions(_request: SessionSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionSearchHit>> {
return Promise.resolve({ items: [] })
}
searchEvents(_request: SessionEventSearchRequest, _exec?: SessionSearchExecContext): Promise<SessionSearchPage<SessionEventSearchHit>> {
return Promise.resolve({ items: [] })
}
}
it('registers the abstract search seam under its independent ctx key', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(TestSearchService)
await expect(ctx.sessionSearch.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionSearch.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
await fiber.dispose()
expect(ctx.sessionSearch).toBeUndefined()
})