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:
@@ -1,10 +1,12 @@
|
||||
# @deepseek-ai/dsh-session-query
|
||||
|
||||
Exact session-history retrieval and relationship tracing through `ctx.sessionQuery`. The service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
`SessionQueryService` is the combined abstract `ctx.sessionQuery` contract. It implements exact session-history retrieval, relationship tracing, and provider-independent filtering over live `ctx.sessions` plus optional dynamically mounted `ctx.sessionPersistence`; concrete backends implement its two full-text methods. Matching ids produce one record: live events win, while `live` and `persisted` report both source availabilities. Conflicting immutable headers fail with `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
|
||||
## Reads
|
||||
|
||||
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
|
||||
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
|
||||
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
|
||||
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.
|
||||
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
|
||||
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
|
||||
@@ -14,9 +16,21 @@ Exact session-history retrieval and relationship tracing through `ctx.sessionQue
|
||||
|
||||
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
|
||||
|
||||
`listEvents()`, `readSurface()`, and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
|
||||
## Filtering and extraction
|
||||
|
||||
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_LINEAGE`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
|
||||
`SessionResultFilter` covers id, nullable cwd, created-at range, nullable parent, and source availability. `SessionEventResultFilter` covers seq/time ranges, event type, surface, and semantic text. Filter arrays are ANDed; values within one list clause are ORed. Empty list values match nothing, ranges are inclusive, and malformed ranges or closed-union values fail with `SESSION_QUERY_INVALID_FILTER`.
|
||||
|
||||
The text clause is deliberately independent of FTS providers: caller text is escaped into a Unicode, case-insensitive regular expression, and each whitespace run matches one or more whitespace characters. It is a literal semantic-text scan, not a full-text query. `extractSessionEventText()` and `buildSessionEventSearchDocuments()` define the shared first-party document projection; structural boundaries, stream chunks, request headers, and unknown declaration-merged variants produce no document.
|
||||
|
||||
## Full-text methods
|
||||
|
||||
`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
|
||||
|
||||
The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
|
||||
|
||||
`SessionQueryError.code` is a closed union covering request validation, missing targets, malformed surfaces, source conflicts, persistence/index failures, cancellation, and invalid or stale cursors; the exact literals are defined in [`src/config.ts`](src/config.ts).
|
||||
|
||||
`listEvents()`, `readSurface()`, and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -35,4 +49,4 @@ None; this package neither assembles nor sends a provider request.
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **No caller authorization** — this is trusted context-wide infrastructure; a future model tool or UI must constrain which sessions its caller may inspect.
|
||||
- **No search or extraction** — filters, extraction registry, search-provider protocol, index synchronization, and a model-facing tool are absent. The [tracing decision](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics; content-bearing full-text-search results and their chainable filters belong beside their first implementation in the proposed [SQLite package](../../../.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
- **No registries or model-facing tool** — extractor and search-provider registries, recursive event-provenance traversal, and a model-facing tool are absent. The [tracing decision](../../../.agents/notes/implemented/feature/2026-07-13-session-query-tracing.md) owns relationship semantics; SQLite ownership and tokenizer decisions live in the [implemented search note](../../../.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-session-query",
|
||||
"description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)",
|
||||
"description": "Combined session query service contract with concrete reads, traces, and filters",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -27,6 +27,7 @@
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
@@ -39,10 +40,8 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
/** Public configuration and typed failures for session-query. */
|
||||
/** Public configuration and typed failures for the combined session-query service. */
|
||||
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Default maximum `before`/`after` raw-event window. */
|
||||
export const SESSION_QUERY_READ_WINDOW_MAX = 50
|
||||
|
||||
/** Configuration for exact session-query reads and traces. */
|
||||
/** Backend-independent configuration inherited by every session-query implementation. */
|
||||
export interface Config {
|
||||
/** Maximum accepted raw read context on either side. Defaults to 50. */
|
||||
readWindowMax?: number
|
||||
}
|
||||
|
||||
/** Stable machine-routable failure taxonomy for exact session reads and traces. */
|
||||
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
|
||||
export type SessionQueryErrorCode =
|
||||
| 'SESSION_QUERY_ABORTED'
|
||||
| 'SESSION_QUERY_EVENT_NOT_FOUND'
|
||||
| 'SESSION_QUERY_INDEX_FAILED'
|
||||
| 'SESSION_QUERY_INVALID_CONFIG'
|
||||
| 'SESSION_QUERY_INVALID_CURSOR'
|
||||
| 'SESSION_QUERY_INVALID_FILTER'
|
||||
| 'SESSION_QUERY_INVALID_LIMIT'
|
||||
| 'SESSION_QUERY_INVALID_QUERY'
|
||||
| 'SESSION_QUERY_INVALID_LINEAGE'
|
||||
| 'SESSION_QUERY_INVALID_SURFACE'
|
||||
| 'SESSION_QUERY_INVALID_WINDOW'
|
||||
| 'SESSION_QUERY_PERSISTENCE_FAILED'
|
||||
| 'SESSION_QUERY_SESSION_NOT_FOUND'
|
||||
| 'SESSION_QUERY_STALE_CURSOR'
|
||||
| 'SESSION_QUERY_SOURCE_CONFLICT'
|
||||
|
||||
/** Typed session-query failure whose `code` is one closed taxonomy member. */
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/** Live/persisted logical-corpus resolution for session-query. */
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Context, Fiber } from 'cordis'
|
||||
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type SessionPersistence from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionRecord } from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import { assertSessionHeadersCompatible } from './sources.ts'
|
||||
|
||||
/** Detached source selected for one exact read. */
|
||||
export interface LogicalSession {
|
||||
@@ -17,18 +18,19 @@ export interface LogicalSession {
|
||||
/** Resolves a live-preferred corpus against the persistence service mounted now. */
|
||||
export class SessionCorpus {
|
||||
private _persistence: SessionPersistence | undefined
|
||||
private readonly _optionalPersistenceFiber: Fiber
|
||||
|
||||
constructor(private readonly _ctx: Context) {
|
||||
this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
const service = childCtx.sessionPersistence
|
||||
this._persistence = service
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
|
||||
if (this._persistence === service) this._persistence = undefined
|
||||
}, 'sessionQuery.persistenceBinding')
|
||||
})
|
||||
_ctx.effect(() => {
|
||||
const fiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
|
||||
const service = childCtx.sessionPersistence
|
||||
this._persistence = service
|
||||
childCtx.effect(() => () => {
|
||||
/* v8 ignore next -- a stale optional-service disposer cannot clear a replacement */
|
||||
if (this._persistence === service) this._persistence = undefined
|
||||
}, 'sessionQuery.persistenceBinding')
|
||||
})
|
||||
return () => void fiber.dispose()
|
||||
return () => this._optionalPersistenceFiber.dispose()
|
||||
}, 'sessionQuery.optionalPersistence')
|
||||
}
|
||||
|
||||
@@ -45,7 +47,7 @@ export class SessionCorpus {
|
||||
}
|
||||
for (const session of this._ctx.sessions.list()) {
|
||||
const durable = records.get(session.id)
|
||||
if (durable !== undefined) assertCompatibleHeaders(session.header, durable.header)
|
||||
if (durable !== undefined) assertSessionHeadersCompatible(session.header, durable.header)
|
||||
records.set(session.id, {
|
||||
header: structuredClone(session.header),
|
||||
live: true,
|
||||
@@ -70,17 +72,19 @@ export class SessionCorpus {
|
||||
if (persistence === undefined) throw notFound(sessionId)
|
||||
const listed = (await listPersisted(persistence)).find(header => header.id === sessionId)
|
||||
if (listed === undefined) throw notFound(sessionId)
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['load']>>
|
||||
let loaded: Awaited<ReturnType<SessionPersistence['inspect']>>
|
||||
try {
|
||||
loaded = await persistence.load(sessionId)
|
||||
loaded = await persistence.inspect(sessionId)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
`failed to load session "${sessionId}": ${errorMessage(error)}`,
|
||||
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
|
||||
'SESSION_QUERY_PERSISTENCE_FAILED',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
assertCompatibleHeaders(loaded.meta, listed)
|
||||
const attached = this._ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) return snapshotLive(attached)
|
||||
assertSessionHeadersCompatible(loaded.meta, listed)
|
||||
return {
|
||||
header: structuredClone(loaded.meta),
|
||||
events: loaded.events.map(event => structuredClone(event)),
|
||||
@@ -107,22 +111,6 @@ function snapshotLive(session: Session): LogicalSession {
|
||||
}
|
||||
}
|
||||
|
||||
function assertCompatibleHeaders(a: SessionHeader, b: SessionHeader): void {
|
||||
if (
|
||||
a.version !== b.version
|
||||
|| a.id !== b.id
|
||||
|| a.createdAt !== b.createdAt
|
||||
|| a.cwd !== b.cwd
|
||||
|| a.parentSession !== b.parentSession
|
||||
|| a.seedLength !== b.seedLength
|
||||
) {
|
||||
throw new SessionQueryError(
|
||||
`live and persisted headers conflict for session "${a.id}"`,
|
||||
'SESSION_QUERY_SOURCE_CONFLICT',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function compareSessions(a: SessionRecord, b: SessionRecord): number {
|
||||
return b.header.createdAt - a.header.createdAt || a.header.id.localeCompare(b.header.id)
|
||||
}
|
||||
|
||||
15
packages/session-query/session-query/src/cursor.ts
Normal file
15
packages/session-query/session-query/src/cursor.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/** Opaque cursor identity for session-search pagination. */
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Provider-owned opaque continuation token returned by session search. */
|
||||
export type SessionSearchCursor = Branded<'SessionSearchCursor'>
|
||||
|
||||
/**
|
||||
* Brand an encoded provider cursor for the public search contract.
|
||||
* @param value - opaque encoded cursor value.
|
||||
* @returns the same runtime string with session-search cursor identity.
|
||||
*/
|
||||
export function SessionSearchCursor(value: string): SessionSearchCursor {
|
||||
return value as SessionSearchCursor
|
||||
}
|
||||
74
packages/session-query/session-query/src/documents.ts
Normal file
74
packages/session-query/session-query/src/documents.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/** Shared event metadata and semantic-document projection. */
|
||||
|
||||
import { foldSurface } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEventRecord, SessionEventSearchDocument, SessionEventSurface } from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
import { extractSessionEventText } from './extraction.ts'
|
||||
|
||||
/**
|
||||
* Project a raw log into lightweight surface-aware event records.
|
||||
* @param sessionId - session that owns the log.
|
||||
* @param events - complete contiguous raw event log.
|
||||
* @returns one record per event in ascending seq order.
|
||||
*/
|
||||
export function buildSessionEventRecords(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEventRecord[] {
|
||||
const surfaceBySeq = classifySurface(events)
|
||||
return events.map(event => ({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: surfaceBySeq.get(event.seq) ?? 'log-only',
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Build first-party semantic documents for one complete raw event log.
|
||||
* @param sessionId - session that owns the log.
|
||||
* @param events - complete contiguous raw event log.
|
||||
* @returns searchable documents in ascending seq order; structural events are omitted.
|
||||
*/
|
||||
export function buildSessionEventSearchDocuments(
|
||||
sessionId: SessionId,
|
||||
events: readonly SessionEvent[],
|
||||
): SessionEventSearchDocument[] {
|
||||
const surfaceBySeq = classifySurface(events)
|
||||
const documents: SessionEventSearchDocument[] = []
|
||||
for (const event of events) {
|
||||
const text = extractSessionEventText(event)
|
||||
if (text.length === 0) continue
|
||||
documents.push({
|
||||
sessionId,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
time: event.time,
|
||||
surface: surfaceBySeq.get(event.seq) ?? 'log-only',
|
||||
text,
|
||||
})
|
||||
}
|
||||
return documents
|
||||
}
|
||||
|
||||
function classifySurface(events: readonly SessionEvent[]): Map<number, SessionEventSurface> {
|
||||
let folded: ReturnType<typeof foldSurface>
|
||||
try {
|
||||
folded = foldSurface(events)
|
||||
} catch (error: unknown) {
|
||||
throw new SessionQueryError(
|
||||
/* v8 ignore next -- foldSurface throws Error instances */
|
||||
`invalid session surface: ${error instanceof Error ? error.message : 'unknown error'}`,
|
||||
'SESSION_QUERY_INVALID_SURFACE',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
const result = new Map<number, SessionEventSurface>()
|
||||
for (const seq of folded.nodes) result.set(seq, 'current')
|
||||
for (const replacement of folded.replacements) {
|
||||
for (const seq of replacement.shadowedSeqs) result.set(seq, 'shadowed')
|
||||
}
|
||||
return result
|
||||
}
|
||||
93
packages/session-query/session-query/src/extraction.ts
Normal file
93
packages/session-query/session-query/src/extraction.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** First-party semantic text extraction for session-query consumers. */
|
||||
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* Extract searchable semantic text from one first-party session event.
|
||||
*
|
||||
* Structural boundaries, raw stream chunks, request envelopes, and unknown
|
||||
* declaration-merged events contribute no text.
|
||||
* @param event - event to inspect.
|
||||
* @returns newline-joined semantic text, or an empty string when non-searchable.
|
||||
*/
|
||||
export function extractSessionEventText(event: SessionEvent): string {
|
||||
switch (event.type) {
|
||||
case 'user/message':
|
||||
case 'assistant/message':
|
||||
case 'steering/message':
|
||||
return contentText(event.data.content)
|
||||
case 'prompt/blocked':
|
||||
return joinText([contentText(event.data.content), event.data.reason])
|
||||
case 'tool/call':
|
||||
return joinText([event.data.name, event.data.arguments])
|
||||
case 'tool/result':
|
||||
return joinText([
|
||||
contentText(event.data.content),
|
||||
event.data.error?.name ?? '',
|
||||
event.data.error?.code ?? '',
|
||||
])
|
||||
case 'todo/write':
|
||||
return joinText(event.data.todos.flatMap(todo => [todo.status, todo.content]))
|
||||
case 'turn/end':
|
||||
return turnEndText(event.data.reason)
|
||||
case 'turn/start':
|
||||
case 'step/start':
|
||||
case 'step/end':
|
||||
case 'assistant/chunk':
|
||||
case 'request/header':
|
||||
return ''
|
||||
// SessionEventMap is merge-extensible. Unknown events remain
|
||||
// non-searchable until a concrete first-party consumer defines semantics.
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
|
||||
switch (reason.kind) {
|
||||
case 'error':
|
||||
return 'failure' in reason
|
||||
? joinText(['error', reason.failure.message, reason.failure.code])
|
||||
: joinText(['error', reason.message, reason.code ?? ''])
|
||||
case 'aborted':
|
||||
return 'aborted'
|
||||
case 'rejected':
|
||||
return joinText(['rejected', reason.reason])
|
||||
case 'disposed':
|
||||
case 'max-tokens':
|
||||
case 'interrupted':
|
||||
return reason.kind
|
||||
case 'completed':
|
||||
return ''
|
||||
// TurnEndReasonMap is merge-extensible. Unknown outcomes stay out until
|
||||
// their owner defines which detail is semantic rather than structural.
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
type SessionContentBlock = SessionEvent<'user/message'>['data']['content'][number]
|
||||
|
||||
function contentText(content: readonly SessionContentBlock[]): string {
|
||||
return joinText(content.flatMap(blockText))
|
||||
}
|
||||
|
||||
function blockText(block: SessionContentBlock): string[] {
|
||||
switch (block.type) {
|
||||
case 'text':
|
||||
case 'reasoning':
|
||||
return [block.text]
|
||||
case 'tool-call':
|
||||
return [block.name, block.arguments]
|
||||
case 'tool-result':
|
||||
return block.content.flatMap(blockText)
|
||||
// ContentBlockMap is merge-extensible. Unknown blocks do not become
|
||||
// searchable merely because their payload happens to contain strings.
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function joinText(parts: readonly string[]): string {
|
||||
return parts.map(part => part.trim()).filter(Boolean).join('\n')
|
||||
}
|
||||
243
packages/session-query/session-query/src/filters.ts
Normal file
243
packages/session-query/session-query/src/filters.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
/** Pure provider-independent predicates for logical sessions and event text. */
|
||||
|
||||
import type {
|
||||
SessionEventResultFilter,
|
||||
SessionEventSearchDocument,
|
||||
SessionRecord,
|
||||
SessionResultFilter,
|
||||
SessionResultRange,
|
||||
} from './types.ts'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
|
||||
/**
|
||||
* Apply ANDed logical-session filters while preserving input order.
|
||||
* @param records - detached logical-session records to inspect.
|
||||
* @param filters - clauses whose list values are ORed within each clause.
|
||||
* @returns records accepted by every clause.
|
||||
*/
|
||||
export function filterSessionResults<T extends SessionRecord>(
|
||||
records: readonly T[],
|
||||
filters: readonly SessionResultFilter[] = [],
|
||||
): T[] {
|
||||
const predicates = filters.map(sessionPredicate)
|
||||
return records.filter(record => predicates.every(predicate => predicate(record)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply ANDed event filters to extracted semantic documents.
|
||||
* @param documents - semantic documents produced by {@link buildSessionEventSearchDocuments}.
|
||||
* @param filters - metadata and literal-text predicates.
|
||||
* @returns documents accepted by every clause, in input order.
|
||||
*/
|
||||
export function filterSessionEventDocuments<T extends SessionEventSearchDocument>(
|
||||
documents: readonly T[],
|
||||
filters: readonly SessionEventResultFilter[] = [],
|
||||
): T[] {
|
||||
const predicates = filters.map(eventPredicate)
|
||||
return documents.filter(document => predicates.every(predicate => predicate(document)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and validate logical-session filters before an asynchronous boundary.
|
||||
* @param filters - caller-owned clauses to materialize.
|
||||
* @returns detached validated clauses.
|
||||
*/
|
||||
export function materializeSessionResultFilters(
|
||||
filters: readonly SessionResultFilter[],
|
||||
): SessionResultFilter[] {
|
||||
assertArray(filters)
|
||||
return filters.map((filter) => {
|
||||
switch (filter.kind) {
|
||||
case 'id':
|
||||
return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) }
|
||||
case 'cwd':
|
||||
return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) }
|
||||
case 'created-at':
|
||||
return copyRange(filter.kind, filter)
|
||||
case 'parent':
|
||||
return { kind: filter.kind, values: copyNullableStrings(filter.kind, filter.values) }
|
||||
case 'availability': {
|
||||
const values = copyStrings(filter.kind, filter.values)
|
||||
assertAllowedValues(filter.kind, values, ['live', 'persisted'])
|
||||
return { kind: filter.kind, values }
|
||||
}
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy and validate event filters before an asynchronous boundary.
|
||||
* @param filters - caller-owned clauses to materialize.
|
||||
* @returns detached validated clauses.
|
||||
*/
|
||||
export function materializeSessionEventResultFilters(
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): SessionEventResultFilter[] {
|
||||
assertArray(filters)
|
||||
return filters.map((filter) => {
|
||||
switch (filter.kind) {
|
||||
case 'seq':
|
||||
case 'time':
|
||||
return copyRange(filter.kind, filter)
|
||||
case 'type':
|
||||
return { kind: filter.kind, values: copyStrings(filter.kind, filter.values) }
|
||||
case 'surface': {
|
||||
const values = copyStrings(filter.kind, filter.values)
|
||||
assertAllowedValues(filter.kind, values, ['current', 'shadowed', 'log-only'])
|
||||
return { kind: filter.kind, values }
|
||||
}
|
||||
case 'text':
|
||||
if (typeof filter.text !== 'string') throw invalidFilter('text filter text must be a string')
|
||||
return { kind: filter.kind, text: filter.text }
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile a literal case-insensitive, whitespace-flexible semantic-text match.
|
||||
* @param text - caller-provided literal text.
|
||||
* @returns Unicode-aware regular expression safe from regex injection.
|
||||
*/
|
||||
export function compileSessionTextFilter(text: string): RegExp {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) {
|
||||
throw new SessionQueryError(
|
||||
'session text filter must contain non-whitespace text',
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
const pattern = trimmed
|
||||
.split(/\s+/u)
|
||||
.map(part => part.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&'))
|
||||
.join('\\s+')
|
||||
return new RegExp(pattern, 'iu')
|
||||
}
|
||||
|
||||
function sessionPredicate(filter: SessionResultFilter): (record: SessionRecord) => boolean {
|
||||
switch (filter.kind) {
|
||||
case 'id':
|
||||
return record => filter.values.includes(record.header.id)
|
||||
case 'cwd':
|
||||
return record => filter.values.includes(record.header.cwd ?? null)
|
||||
case 'created-at': {
|
||||
const range = validateRange(filter.kind, filter)
|
||||
return record => matchesRange(record.header.createdAt, range)
|
||||
}
|
||||
case 'parent':
|
||||
return record => filter.values.includes(record.header.parentSession ?? null)
|
||||
case 'availability':
|
||||
assertAllowedValues(filter.kind, filter.values, ['live', 'persisted'])
|
||||
return record => filter.values.some(value => value === 'live' ? record.live : record.persisted)
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
function eventPredicate(filter: SessionEventResultFilter): (document: SessionEventSearchDocument) => boolean {
|
||||
switch (filter.kind) {
|
||||
case 'seq': {
|
||||
const range = validateRange(filter.kind, filter)
|
||||
return document => matchesRange(document.seq, range)
|
||||
}
|
||||
case 'time': {
|
||||
const range = validateRange(filter.kind, filter)
|
||||
return document => matchesRange(document.time, range)
|
||||
}
|
||||
case 'type':
|
||||
return document => filter.values.includes(document.type)
|
||||
case 'surface':
|
||||
assertAllowedValues(filter.kind, filter.values, ['current', 'shadowed', 'log-only'])
|
||||
return document => filter.values.includes(document.surface)
|
||||
case 'text': {
|
||||
const pattern = compileSessionTextFilter(filter.text)
|
||||
return document => pattern.test(document.text)
|
||||
}
|
||||
default:
|
||||
return unknownFilter(filter)
|
||||
}
|
||||
}
|
||||
|
||||
function copyStrings<T extends string>(name: string, values: readonly T[]): T[] {
|
||||
if (!isRuntimeArray(values) || values.some(value => typeof value !== 'string')) {
|
||||
throw invalidFilter(`${name} filter values must be an array of strings`)
|
||||
}
|
||||
return [...values]
|
||||
}
|
||||
|
||||
function assertArray(value: unknown): void {
|
||||
if (!Array.isArray(value)) throw invalidFilter('filters must be an array')
|
||||
}
|
||||
|
||||
function copyNullableStrings<T extends string>(name: string, values: readonly (T | null)[]): Array<T | null> {
|
||||
if (!isRuntimeArray(values) || values.some(value => value !== null && typeof value !== 'string')) {
|
||||
throw invalidFilter(`${name} filter values must be an array of strings or null`)
|
||||
}
|
||||
return [...values]
|
||||
}
|
||||
|
||||
function copyRange<K extends 'created-at' | 'seq' | 'time'>(
|
||||
kind: K,
|
||||
range: SessionResultRange,
|
||||
): { kind: K } & SessionResultRange {
|
||||
const copy = {
|
||||
kind,
|
||||
...range.from === undefined ? {} : { from: range.from },
|
||||
...range.to === undefined ? {} : { to: range.to },
|
||||
}
|
||||
validateRange(kind, copy)
|
||||
return copy
|
||||
}
|
||||
|
||||
function unknownFilter(filter: never): never {
|
||||
const kind = (filter as { kind?: unknown }).kind
|
||||
throw invalidFilter(`unknown filter kind ${typeof kind === 'string' ? `"${kind}"` : '(missing)'}`)
|
||||
}
|
||||
|
||||
function assertAllowedValues(
|
||||
name: string,
|
||||
values: readonly string[],
|
||||
allowed: readonly string[],
|
||||
): void {
|
||||
for (const value of values) {
|
||||
if (!allowed.includes(value)) {
|
||||
throw new SessionQueryError(
|
||||
`session ${name} filter contains unknown value "${value}"`,
|
||||
'SESSION_QUERY_INVALID_FILTER',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateRange(name: string, range: SessionResultRange): SessionResultRange {
|
||||
if (range.from !== undefined && !Number.isFinite(range.from)) {
|
||||
throw invalidRange(name, 'from must be finite')
|
||||
}
|
||||
if (range.to !== undefined && !Number.isFinite(range.to)) {
|
||||
throw invalidRange(name, 'to must be finite')
|
||||
}
|
||||
if (range.from !== undefined && range.to !== undefined && range.from > range.to) {
|
||||
throw invalidRange(name, 'from must be less than or equal to to')
|
||||
}
|
||||
return range
|
||||
}
|
||||
|
||||
function matchesRange(value: number, range: SessionResultRange): boolean {
|
||||
return (range.from === undefined || value >= range.from)
|
||||
&& (range.to === undefined || value <= range.to)
|
||||
}
|
||||
|
||||
function invalidRange(name: string, detail: string): SessionQueryError {
|
||||
return invalidFilter(`${name} filter ${detail}`)
|
||||
}
|
||||
|
||||
function invalidFilter(detail: string): SessionQueryError {
|
||||
return new SessionQueryError(`session ${detail}`, 'SESSION_QUERY_INVALID_FILTER')
|
||||
}
|
||||
|
||||
function isRuntimeArray(value: unknown): boolean {
|
||||
return Array.isArray(value)
|
||||
}
|
||||
@@ -1,22 +1,30 @@
|
||||
/**
|
||||
* Exact session-history reads and traces over live and optionally persisted logs.
|
||||
* Combined session-history reads, traces, filters, and full-text search seam.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session-query
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
|
||||
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
|
||||
import type {
|
||||
SessionEventResultFilter,
|
||||
SessionEventReadRequest,
|
||||
SessionEventRecord,
|
||||
SessionEventSearchHit,
|
||||
SessionEventSearchDocument,
|
||||
SessionEventSearchRequest,
|
||||
SessionEventTrace,
|
||||
SessionEventTraceRequest,
|
||||
SessionEventWindow,
|
||||
SessionLineageTrace,
|
||||
SessionRecord,
|
||||
SessionResultFilter,
|
||||
SessionSearchExecContext,
|
||||
SessionSearchHit,
|
||||
SessionSearchPage,
|
||||
SessionSearchRequest,
|
||||
SessionSurfaceSnapshot,
|
||||
} from './types.ts'
|
||||
import {
|
||||
@@ -25,11 +33,29 @@ import {
|
||||
type Config,
|
||||
} from './config.ts'
|
||||
import { SessionCorpus } from './corpus.ts'
|
||||
import { buildSessionEventSearchDocuments } from './documents.ts'
|
||||
import {
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
} from './filters.ts'
|
||||
import * as tracing from './tracing.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
export { SessionSearchCursor } from './cursor.ts'
|
||||
export type { Config, SessionQueryErrorCode } from './config.ts'
|
||||
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
|
||||
export { extractSessionEventText } from './extraction.ts'
|
||||
export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
|
||||
export {
|
||||
compileSessionTextFilter,
|
||||
filterSessionEventDocuments,
|
||||
filterSessionResults,
|
||||
materializeSessionEventResultFilters,
|
||||
materializeSessionResultFilters,
|
||||
} from './filters.ts'
|
||||
export { assertSessionHeadersCompatible } from './sources.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -37,12 +63,15 @@ declare module 'cordis' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Live-preferred logical-corpus exact-read and relationship-tracing service. */
|
||||
export class SessionQueryService extends Service {
|
||||
/**
|
||||
* Unified live-preferred session query service.
|
||||
*
|
||||
* Exact reads, filters, and traces are backend-independent concrete behavior.
|
||||
* A backend implements full-text observation, reconciliation, ranking, cursor
|
||||
* generations, and query execution on the same `ctx.sessionQuery` service.
|
||||
*/
|
||||
export abstract class SessionQueryService extends Service {
|
||||
static inject = ['sessions']
|
||||
static Config: z<Config> = z.object({
|
||||
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
|
||||
})
|
||||
|
||||
private readonly _readWindowMax: number
|
||||
private readonly _corpus: SessionCorpus
|
||||
@@ -59,6 +88,28 @@ export class SessionQueryService extends Service {
|
||||
this._corpus = new SessionCorpus(ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Search the live-preferred logical corpus and group by session.
|
||||
* @param request - query text, metadata filters, page size, and cursor.
|
||||
* @param exec - optional cancellation control.
|
||||
* @returns session hits ranked by their strongest matching event.
|
||||
*/
|
||||
abstract searchSessions(
|
||||
request: SessionSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionSearchHit>>
|
||||
|
||||
/**
|
||||
* Search events within one live-preferred logical session.
|
||||
* @param request - target session, query text, filters, page size, and cursor.
|
||||
* @param exec - optional cancellation control.
|
||||
* @returns matching event hits in deterministic relevance order.
|
||||
*/
|
||||
abstract searchEvents(
|
||||
request: SessionEventSearchRequest,
|
||||
exec?: SessionSearchExecContext,
|
||||
): Promise<SessionSearchPage<SessionEventSearchHit>>
|
||||
|
||||
/**
|
||||
* List the complete logical corpus using live-preferred records.
|
||||
* @returns deterministic newest-first cloned session records.
|
||||
@@ -67,6 +118,16 @@ export class SessionQueryService extends Service {
|
||||
return this._corpus.listSessions()
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter the complete logical corpus with provider-independent predicates.
|
||||
* @param filters - ANDed session metadata and availability clauses.
|
||||
* @returns matching cloned records in deterministic newest-first order.
|
||||
*/
|
||||
async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
const ownedFilters = materializeSessionResultFilters(filters)
|
||||
return this._filterSessions(ownedFilters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold the latest log-backed title from one live-preferred logical session.
|
||||
* @param sessionId - live or persisted session id to read.
|
||||
@@ -87,6 +148,33 @@ export class SessionQueryService extends Service {
|
||||
return tracing.eventRecords(sessionId, loaded.events)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan first-party semantic event documents with provider-independent filters.
|
||||
* @param sessionId - live-preferred session id to scan.
|
||||
* @param filters - ANDed metadata and literal-text predicates.
|
||||
* @returns matching semantic documents in ascending seq order.
|
||||
*/
|
||||
async filterEvents(
|
||||
sessionId: SessionId,
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): Promise<SessionEventSearchDocument[]> {
|
||||
const ownedFilters = materializeSessionEventResultFilters(filters)
|
||||
return this._filterEvents(sessionId, ownedFilters)
|
||||
}
|
||||
|
||||
private async _filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
|
||||
return filterSessionResults(await this._corpus.listSessions(), filters)
|
||||
}
|
||||
|
||||
private async _filterEvents(
|
||||
sessionId: SessionId,
|
||||
filters: readonly SessionEventResultFilter[],
|
||||
): Promise<SessionEventSearchDocument[]> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
const documents = buildSessionEventSearchDocuments(sessionId, loaded.events)
|
||||
return filterSessionEventDocuments(documents, filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one session's complete current model surface from one corpus observation.
|
||||
* @param sessionId - live-preferred session id to read.
|
||||
@@ -132,16 +220,27 @@ export class SessionQueryService extends Service {
|
||||
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
|
||||
const before = this._readWindow('before', request.before)
|
||||
const after = this._readWindow('after', request.after)
|
||||
const loaded = await this._corpus.load(request.sessionId)
|
||||
const target = loaded.events[request.seq]
|
||||
if (target === undefined || target.seq !== request.seq) {
|
||||
const sessionId = request.sessionId
|
||||
const seq = request.seq
|
||||
return this._readEvent(sessionId, seq, before, after)
|
||||
}
|
||||
|
||||
private async _readEvent(
|
||||
sessionId: SessionId,
|
||||
seq: number,
|
||||
before: number,
|
||||
after: number,
|
||||
): Promise<SessionEventWindow> {
|
||||
const loaded = await this._corpus.load(sessionId)
|
||||
const target = loaded.events[seq]
|
||||
if (target === undefined || target.seq !== seq) {
|
||||
throw new SessionQueryError(
|
||||
`session "${request.sessionId}" has no event at seq ${request.seq}`,
|
||||
`session "${sessionId}" has no event at seq ${seq}`,
|
||||
'SESSION_QUERY_EVENT_NOT_FOUND',
|
||||
)
|
||||
}
|
||||
const startSeq = Math.max(0, request.seq - before)
|
||||
const endSeq = Math.min(loaded.events.length - 1, request.seq + after)
|
||||
const startSeq = Math.max(0, seq - before)
|
||||
const endSeq = Math.min(loaded.events.length - 1, seq + after)
|
||||
return {
|
||||
session: loaded.header,
|
||||
target,
|
||||
|
||||
26
packages/session-query/session-query/src/sources.ts
Normal file
26
packages/session-query/session-query/src/sources.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/** Shared immutable-header checks for logical session source observers. */
|
||||
|
||||
import type { SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from './config.ts'
|
||||
|
||||
/**
|
||||
* Reject incompatible observations of one logical session source.
|
||||
* @param a - first live, listed, or loaded header observation.
|
||||
* @param b - second header observation expected to identify the same source.
|
||||
*/
|
||||
export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeader): void {
|
||||
if (
|
||||
a.version !== b.version
|
||||
|| a.id !== b.id
|
||||
|| a.createdAt !== b.createdAt
|
||||
|| a.cwd !== b.cwd
|
||||
|| a.parentSession !== b.parentSession
|
||||
|| a.seedLength !== b.seedLength
|
||||
|| (a.delegationDepth ?? 0) !== (b.delegationDepth ?? 0)
|
||||
) {
|
||||
throw new SessionQueryError(
|
||||
`session source headers conflict for session "${a.id}"`,
|
||||
'SESSION_QUERY_SOURCE_CONFLICT',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,16 @@
|
||||
* @module @deepseek-ai/dsh-session-query/types
|
||||
*/
|
||||
|
||||
import type { SessionEvent, SessionEventType, SessionHeader, SessionId, SurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import type {
|
||||
SessionEvent,
|
||||
SessionEventType,
|
||||
SessionHeader,
|
||||
SessionId,
|
||||
SurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { SessionSearchCursor } from './cursor.ts'
|
||||
|
||||
export type { SessionSearchCursor } from './cursor.ts'
|
||||
|
||||
/** Whether an event is current model context, replaced context, or raw-log-only. */
|
||||
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
|
||||
@@ -124,3 +133,99 @@ export interface SessionEventWindow {
|
||||
/** Last seq included in `events`. */
|
||||
endSeq: number
|
||||
}
|
||||
|
||||
/** Inclusive numeric interval used by time and sequence filters. */
|
||||
export interface SessionResultRange {
|
||||
/** Inclusive lower bound. */
|
||||
from?: number
|
||||
/** Inclusive upper bound. */
|
||||
to?: number
|
||||
}
|
||||
|
||||
/** Source availability predicates understood by logical-session filters. */
|
||||
export type SessionAvailability = 'live' | 'persisted'
|
||||
|
||||
/**
|
||||
* One logical-session predicate. A filter array is ANDed; `values` within a
|
||||
* clause are ORed.
|
||||
*/
|
||||
export type SessionResultFilter =
|
||||
| { kind: 'id'; values: readonly SessionId[] }
|
||||
| { kind: 'cwd'; values: readonly (string | null)[] }
|
||||
| ({ kind: 'created-at' } & SessionResultRange)
|
||||
| { kind: 'parent'; values: readonly (SessionId | null)[] }
|
||||
| { kind: 'availability'; values: readonly SessionAvailability[] }
|
||||
|
||||
/**
|
||||
* One event predicate. A filter array is ANDed; list-valued clauses are ORed.
|
||||
* Text is a literal, case-insensitive, whitespace-flexible semantic-text scan.
|
||||
*/
|
||||
export type SessionEventResultFilter =
|
||||
| ({ kind: 'seq' } & SessionResultRange)
|
||||
| ({ kind: 'time' } & SessionResultRange)
|
||||
| { kind: 'type'; values: readonly SessionEventType[] }
|
||||
| { kind: 'surface'; values: readonly SessionEventSurface[] }
|
||||
| { kind: 'text'; text: string }
|
||||
|
||||
/** Event predicates a full-text provider can apply before relevance ranking. */
|
||||
export type SessionEventMetadataFilter = Exclude<SessionEventResultFilter, { kind: 'text' }>
|
||||
|
||||
/** Searchable semantic document derived from one session event. */
|
||||
export interface SessionEventSearchDocument extends SessionEventRecord {
|
||||
/** First-party semantic text used by scan filters and full-text indexes. */
|
||||
text: string
|
||||
}
|
||||
|
||||
/** One cursor-paginated result page. */
|
||||
export interface SessionSearchPage<T> {
|
||||
/** Results for this page in contract-defined order. */
|
||||
items: readonly T[]
|
||||
/** Opaque continuation cursor, absent on the final page. */
|
||||
nextCursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Controls shared by cross-session and within-session search calls. */
|
||||
export interface SessionSearchExecContext {
|
||||
/** Abort caller waiting and interrupt provider work where supported. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Cross-session full-text search request. */
|
||||
export interface SessionSearchRequest {
|
||||
/** Full-text query interpreted as data, never executable FTS syntax. */
|
||||
query: string
|
||||
/** Logical-session predicates applied before event ranking. */
|
||||
sessionFilters?: readonly SessionResultFilter[]
|
||||
/** Event predicates applied before event ranking. */
|
||||
eventFilters?: readonly SessionEventMetadataFilter[]
|
||||
/** Maximum sessions in this page. */
|
||||
limit?: number
|
||||
/** Opaque cursor returned for the identical normalized request. */
|
||||
cursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** Within-session full-text search request. */
|
||||
export interface SessionEventSearchRequest {
|
||||
/** Session whose live-preferred logical log is searched. */
|
||||
sessionId: SessionId
|
||||
/** Full-text query interpreted as data, never executable FTS syntax. */
|
||||
query: string
|
||||
/** Event predicates applied before ranking. */
|
||||
filters?: readonly SessionEventMetadataFilter[]
|
||||
/** Maximum events in this page. */
|
||||
limit?: number
|
||||
/** Opaque cursor returned for the identical normalized request. */
|
||||
cursor?: SessionSearchCursor
|
||||
}
|
||||
|
||||
/** One event full-text search hit with a bounded plain-text excerpt. */
|
||||
export interface SessionEventSearchHit extends SessionEventRecord {
|
||||
/** Plain text excerpt selected around the match. */
|
||||
snippet: string
|
||||
}
|
||||
|
||||
/** One grouped cross-session hit, ranked by its strongest matching event. */
|
||||
export interface SessionSearchHit extends SessionRecord {
|
||||
/** Strongest matching event for this session. */
|
||||
bestMatch: SessionEventSearchHit
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
|
||||
Reference in New Issue
Block a user