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:
Turtle
2026-07-23 22:41:45 +08:00
333 changed files with 10751 additions and 907 deletions

View File

@@ -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).

View File

@@ -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:^",

View File

@@ -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. */

View File

@@ -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)
}

View 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
}

View 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
}

View 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')
}

View 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)
}

View File

@@ -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,

View 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',
)
}
}

View File

@@ -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
}

View File

@@ -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()
})

View File

@@ -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()
})
})

View 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: [] })
}
}

View File

@@ -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'
}

View File

@@ -15,7 +15,7 @@
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
"path": "../../util/brand"
},
{
"path": "../../llm/llm"