Merge remote-tracking branch 'origin/master' into session-query-search

# Conflicts:
#	.agents/notes/implemented/feature/2026-07-10-session-query-service.md
#	.agents/notes/implemented/feature/2026-07-10-sqlite-session-query-provider.md
#	.agents/notes/proposed/feature/2026-07-10-sqlite-session-query-provider.md
#	docs/architecture.md
#	docs/capability-seams.md
#	docs/config-catalog.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.md
#	docs/core-data-structures/persistence.md
#	docs/core-data-structures/session-query.md
#	docs/module-graph.md
#	docs/rfc/INDEX.md
#	packages/README.md
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/hooks/hooks-claude/tests/coverage.spec.ts
#	packages/session-persistence/session-persistence-jsonl/src/index.ts
#	packages/session-persistence/session-persistence-jsonl/tests/jsonl.spec.ts
#	packages/session-persistence/session-persistence-sqlite/README.md
#	packages/session-persistence/session-persistence-sqlite/src/index.ts
#	packages/session-persistence/session-persistence-sqlite/src/schema.ts
#	packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts
#	packages/session-persistence/session-persistence/README.md
#	packages/session-persistence/session-persistence/package.json
#	packages/session-query/README.md
#	packages/session-query/session-query/README.md
#	packages/session-query/session-query/package.json
#	packages/session-query/session-query/src/config.ts
#	packages/session-query/session-query/src/index.ts
#	packages/session-query/session-query/src/types.ts
#	pnpm-lock.yaml
#	scripts/gen-doc-graphs.ts
#	scripts/type-equiv.manifest.json
#	tsconfig.host.json
#	tsconfig.json
This commit is contained in:
Hypatia May
2026-07-23 13:56:56 +08:00
2630 changed files with 198302 additions and 30288 deletions

View File

@@ -1,10 +1,10 @@
# session-query/ — session retrieval capability family
Trusted exact reads, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs.
Trusted exact reads, relationship traces, provider-independent semantic filtering, and SQLite full-text search over live and durable session logs.
| Package | Role | ctx key |
|---|---|---|
| [`session-query/`](session-query/README.md) | Logical-corpus reads, semantic extraction/filtering, and the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` |
| [`session-query/`](session-query/README.md) | Logical-corpus title, event, lineage, relationship, and semantic-filter reads plus the abstract search seam | `ctx.sessionQuery`, `ctx.sessionSearch` |
| [`session-query-sqlite/`](session-query-sqlite/README.md) | SQLite FTS5 search with persistent bases and live overlays | `ctx.sessionSearch` |
The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator.
The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. Search uses one abstract service and one concrete owner, not a provider registry or coordinator.

View File

@@ -38,6 +38,10 @@ Abort signals stop queued work and caller waits around asynchronous source obser
None, as this trusted search backend returns hits only to callers and registers no model-facing prompt, schema, tool, or message.
#### KV Cache effect
None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **No caller authorization** — this is a trusted context-wide service; a model tool or UI must enforce its own access policy.

View File

@@ -11,21 +11,27 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
@@ -37,10 +43,11 @@
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -133,6 +133,7 @@ interface SearchRow {
cwd: string | null
parent_session: string | null
seed_length: number | null
delegation_depth: number | null
live: number
persisted: number
seq: number
@@ -486,8 +487,8 @@ export class SessionSearchSqlite extends SessionSearchService {
const db = this._requireDb()
db.prepare(`
INSERT INTO persisted_sessions
(id, version, created_at, cwd, parent_session, seed_length, revision, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, revision, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
entry.header.id,
entry.header.version,
@@ -495,6 +496,7 @@ export class SessionSearchSqlite extends SessionSearchService {
entry.header.cwd ?? null,
entry.header.parentSession ?? null,
entry.header.seedLength ?? null,
entry.header.delegationDepth ?? null,
revision,
generation,
)
@@ -521,8 +523,8 @@ export class SessionSearchSqlite extends SessionSearchService {
const db = this._requireDb()
db.prepare(`
INSERT INTO temp.live_sessions
(id, version, created_at, cwd, parent_session, seed_length, fingerprint, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
(id, version, created_at, cwd, parent_session, seed_length, delegation_depth, fingerprint, generation)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
entry.header.id,
entry.header.version,
@@ -530,6 +532,7 @@ export class SessionSearchSqlite extends SessionSearchService {
entry.header.cwd ?? null,
entry.header.parentSession ?? null,
entry.header.seedLength ?? null,
entry.header.delegationDepth ?? null,
entry.fingerprint,
generation,
)
@@ -673,6 +676,7 @@ function selectedDocumentsSql(): { sql: string } {
ps.cwd AS cwd,
ps.parent_session AS parent_session,
ps.seed_length AS seed_length,
ps.delegation_depth AS delegation_depth,
0 AS live,
1 AS persisted,
CAST(pd.seq AS INTEGER) AS seq,
@@ -694,6 +698,7 @@ function selectedDocumentsSql(): { sql: string } {
ls.cwd AS cwd,
ls.parent_session AS parent_session,
ls.seed_length AS seed_length,
ls.delegation_depth AS delegation_depth,
1 AS live,
CASE WHEN ? = 1 AND EXISTS (
SELECT 1 FROM persisted_sessions AS ps WHERE ps.id = ld.session_id
@@ -792,6 +797,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
&& a.cwd === b.cwd
&& a.parentSession === b.parentSession
&& a.seedLength === b.seedLength
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
}
function rowHeader(row: SearchRow): SessionHeader {
@@ -802,6 +808,7 @@ function rowHeader(row: SearchRow): SessionHeader {
...row.cwd === null ? {} : { cwd: row.cwd },
...row.parent_session === null ? {} : { parentSession: row.parent_session as SessionId },
...row.seed_length === null ? {} : { seedLength: row.seed_length },
...row.delegation_depth === null ? {} : { delegationDepth: row.delegation_depth },
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-query-sqlite`.
* @module @deepseek-ai/dsh-session-query-sqlite/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-query-sqlite'
/** Cordis companion plugin name. */
export const name = 'session-query-sqlite-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: reconciliation, cursor generations, and derived-index
* ownership are validated at each serialized query boundary.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -5,7 +5,7 @@ import { mkdir } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
/** Current derived-index schema version. Incompatible versions reset in place. */
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 2
export const SESSION_QUERY_SQLITE_SCHEMA_VERSION = 3
/** SQLite application id protecting unrelated databases from derived resets. */
export const SESSION_QUERY_SQLITE_APPLICATION_ID = 0x44534851
@@ -79,6 +79,7 @@ function ensurePersistentSchema(db: DatabaseSync): void {
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
revision TEXT NOT NULL,
generation INTEGER NOT NULL
) STRICT
@@ -107,6 +108,7 @@ function ensureTemporarySchema(db: DatabaseSync): void {
cwd TEXT,
parent_session TEXT,
seed_length INTEGER,
delegation_depth INTEGER,
fingerprint TEXT NOT NULL,
generation INTEGER NOT NULL
) STRICT

View File

@@ -75,6 +75,10 @@ class TestPersistence extends SessionPersistence {
static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined
static failure: unknown
locate(_meta: SessionHeader): undefined {
return undefined
}
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map()
this.revisions = new Map()
@@ -152,7 +156,9 @@ async function liveContext(config: ConstructorParameters<typeof SessionSearchSql
describe('SQLite session search', () => {
it('searches two-character Unicode61 tokens in live-only sessions', async () => {
const ctx = await liveContext({ path: ':memory:', snippetChars: 20 })
const session = ctx.sessions.create(SessionId('live'), { meta: { cwd: '/work', createdAt: 10, seedLength: 1 } })
const session = ctx.sessions.create(SessionId('live'), {
meta: { cwd: '/work', createdAt: 10, seedLength: 1, delegationDepth: 2 },
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'An AI helper' }], source: { kind: 'user' } },
@@ -171,7 +177,7 @@ describe('SQLite session search', () => {
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'needle original' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'needle raw' } } },
{ type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
{ type: 'user/message', seq: 2, time: 12, data: { content: [{ type: 'text', text: 'needle summary' }], source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'error', step: 1, message: 'needle failure' } } },
]
ctx.sessions.create(SessionId('a'), { seed: events, meta: { cwd: '/a', parentSession: parent, createdAt: 20 } })
@@ -779,11 +785,14 @@ describe('SQLite reconciliation and source lifecycle', () => {
})
it('rejects immutable header conflicts between live and persisted sources', async () => {
const shared = header('conflict', 10)
const shared = header('conflict', 10, { delegationDepth: 1 })
TestPersistence.reset([{ meta: shared, events: messageEvents('persisted needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
ctx.sessions.create(shared.id, { seed: messageEvents('live needle'), meta: { createdAt: 11 } })
ctx.sessions.create(shared.id, {
seed: messageEvents('live needle'),
meta: { createdAt: 10, delegationDepth: 2 },
})
await expect(ctx.sessionSearch.searchSessions({ query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))

View File

@@ -23,6 +23,9 @@
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
},
{
"path": "../session-query"
}

View File

@@ -1,16 +1,20 @@
# @deepseek-ai/dsh-session-query
Session-history query contracts and provider-independent helpers. The concrete `ctx.sessionQuery` service presents live `ctx.sessions` and an optional, dynamically mounted `ctx.sessionPersistence` as one logical corpus for exact reads and semantic scans. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
Exact session-history retrieval, relationship tracing, and provider-independent filtering 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`. The abstract `ctx.sessionSearch` service defines full-text search without introducing a provider registry.
## 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.
- `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.
- `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.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
Persistence is optional and may mount or unmount dynamically. A cross-corpus list fails with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted exact reads list before loading, and reject a metadata mismatch rather than combining inconsistent observations.
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.
## Filtering and extraction
@@ -26,6 +30,8 @@ The package has no provider coordinator or registration protocol. A concrete bac
`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
| Key | Default | Contract |
@@ -36,7 +42,11 @@ The package has no provider coordinator or registration protocol. A concrete bac
None, as this trusted query service returns cloned session records only to its callers and registers no model-facing prompt, schema, tool, or message.
#### KV Cache effect
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 traversal or provider registry** — lineage/provenance traversal, extractor and search-provider registries, index synchronization, and a model-facing tool are absent. SQLite ownership and tokenizer decisions are recorded in the [implemented search RFC](../../../docs/rfc/implemented/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 service (ctx.sessionQuery)",
"description": "Live-preferred exact session-history retrieval and tracing service (ctx.sessionQuery)",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -11,11 +11,16 @@
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
@@ -23,8 +28,10 @@
"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",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -38,8 +45,10 @@
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -5,13 +5,13 @@ 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. */
/** Configuration for exact session-query reads and traces. */
export interface Config {
/** Maximum accepted raw read context on either side. Defaults to 50. */
readWindowMax?: number
}
/** Stable machine-routable failure taxonomy for session reads and search. */
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */
export type SessionQueryErrorCode =
| 'SESSION_QUERY_ABORTED'
| 'SESSION_QUERY_EVENT_NOT_FOUND'
@@ -21,6 +21,7 @@ export type SessionQueryErrorCode =
| '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'

View File

@@ -66,7 +66,7 @@ function classifySurface(events: readonly SessionEvent[]): Map<number, SessionEv
)
}
const result = new Map<number, SessionEventSurface>()
for (const node of folded.nodes) result.set(node.seq, 'current')
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')
}

View File

@@ -36,7 +36,6 @@ export function extractSessionEventText(event: SessionEvent): string {
case 'step/end':
case 'assistant/chunk':
case 'request/header':
case 'request/header-delta':
return ''
// SessionEventMap is merge-extensible. Unknown events remain
// non-searchable until a concrete first-party consumer defines semantics.
@@ -48,9 +47,11 @@ export function extractSessionEventText(event: SessionEvent): string {
function turnEndText(reason: SessionEvent<'turn/end'>['data']['reason']): string {
switch (reason.kind) {
case 'error':
return joinText(['error', reason.message, reason.code ?? ''])
return 'failure' in reason
? joinText(['error', reason.failure.message, reason.failure.code])
: joinText(['error', reason.message, reason.code ?? ''])
case 'aborted':
return joinText(['aborted', reason.reason ?? ''])
return 'aborted'
case 'rejected':
return joinText(['rejected', reason.reason])
case 'disposed':

View File

@@ -1,5 +1,5 @@
/**
* Exact session-history reads over live and optionally persisted logs.
* Exact session-history reads and traces over live and optionally persisted logs.
*
* @module @deepseek-ai/dsh-session-query
*/
@@ -7,6 +7,8 @@
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,
@@ -14,13 +16,17 @@ import type {
SessionEventSearchHit,
SessionEventSearchDocument,
SessionEventSearchRequest,
SessionEventTrace,
SessionEventTraceRequest,
SessionEventWindow,
SessionLineageTrace,
SessionRecord,
SessionResultFilter,
SessionSearchExecContext,
SessionSearchHit,
SessionSearchPage,
SessionSearchRequest,
SessionSurfaceSnapshot,
} from './types.ts'
import {
SESSION_QUERY_READ_WINDOW_MAX,
@@ -28,13 +34,14 @@ import {
type Config,
} from './config.ts'
import { SessionCorpus } from './corpus.ts'
import { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.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'
@@ -92,7 +99,7 @@ export abstract class SessionSearchService extends Service {
): Promise<SessionSearchPage<SessionEventSearchHit>>
}
/** Live-preferred logical-corpus and exact-event read service. */
/** Live-preferred logical-corpus read, filtering, and relationship-tracing service. */
export class SessionQueryService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
@@ -132,6 +139,16 @@ export class SessionQueryService extends Service {
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.
* @returns latest title snapshot, or `undefined` when the log has no title event.
*/
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
const loaded = await this._corpus.load(sessionId)
return foldSessionTitle(loaded.events)
}
/**
* List lightweight raw-log event records for one logical session.
* @param sessionId - live-preferred session id to read.
@@ -139,7 +156,7 @@ export class SessionQueryService extends Service {
*/
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
const loaded = await this._corpus.load(sessionId)
return buildSessionEventRecords(sessionId, loaded.events)
return tracing.eventRecords(sessionId, loaded.events)
}
/**
@@ -169,6 +186,43 @@ export class SessionQueryService extends Service {
return filterSessionEventDocuments(documents, filters)
}
/**
* Read one session's complete current model surface from one corpus observation.
* @param sessionId - live-preferred session id to read.
* @returns cloned header, current surface, and raw-log capture boundary.
* @throws when source resolution fails or the session surface is invalid.
*/
async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> {
const loaded = await this._corpus.load(sessionId)
return {
session: structuredClone(loaded.header),
capturedThroughSeq: loaded.events.at(-1)?.seq ?? null,
events: tracing.currentSurfaceEvents(sessionId, loaded.events),
}
}
/**
* Trace known ancestry and descendants from one corpus observation.
* @param sessionId - logical session id to trace.
* @returns a complete lineage or an explicit unresolved parent boundary.
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
*/
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
const records = await this._corpus.listSessions()
return tracing.traceSession(records, sessionId)
}
/**
* Trace one event's direct positional and provenance relationships.
* @param request - target session id and event seq.
* @returns direct links plus the target's positional replacement chain.
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
*/
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> {
const loaded = await this._corpus.load(request.sessionId)
return tracing.traceEvent(request.sessionId, loaded.events, request.seq)
}
/**
* Read one full event plus a bounded raw-log context window.
* @param request - target session/seq and context sizes.

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-query`.
* @module @deepseek-ai/dsh-session-query/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-query'
/** Cordis companion plugin name. */
export const name = 'session-query-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: query results are immutable per-call projections whose lineage and event
* relations are validated while they are built; the service retains no observable result state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -16,6 +16,7 @@ export function assertSessionHeadersCompatible(a: SessionHeader, b: SessionHeade
|| 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}"`,

View File

@@ -0,0 +1,248 @@
/** One-shot session-lineage and event-relationship tracing helpers. */
import { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from './config.ts'
import type {
SessionEventRecord,
SessionEventTrace,
SessionLineageNode,
SessionLineageTrace,
SessionRecord,
} from './types.ts'
interface EventLogAnalysis {
records: SessionEventRecord[]
replacedBy: Map<number, number>
replacedEventSeqs: Map<number, number[]>
currentSeqs: number[]
}
/**
* Classify a raw event log with one canonical surface fold.
* @param sessionId - owner of the event log.
* @param events - detached raw event log.
* @returns lightweight records in ascending log order.
*/
export function eventRecords(
sessionId: SessionId,
events: readonly SessionEvent[],
): SessionEventRecord[] {
return analyzeEventLog(sessionId, events).records
}
/**
* Fold and return the current model surface after validating the whole log.
* @param sessionId - owner used in query diagnostics.
* @param events - detached raw event log from one corpus observation.
* @returns detached current surface events in folded order.
*/
export function currentSurfaceEvents(
sessionId: SessionId,
events: readonly SessionEvent[],
): SurfaceEvent[] {
const analysis = analyzeEventLog(sessionId, events)
return analysis.currentSeqs.map((seq) => {
const event = events[seq]
/* v8 ignore next 6 -- analyzeEventLog validated contiguous seqs and foldSurface returned only surface-event seqs. */
if (event === undefined || event.seq !== seq || !isSurfaceEvent(event)) {
throw new SessionQueryError(
`invalid session surface: current node ${seq} is not a surface event`,
'SESSION_QUERY_INVALID_SURFACE',
)
}
return structuredClone(event)
})
}
/**
* Trace one target after one canonical surface fold and whole-log validation.
* @param sessionId - owner of the event log.
* @param events - detached raw event log.
* @param seq - target event seq.
* @returns direct surface and provenance relationships.
*/
export function traceEvent(
sessionId: SessionId,
events: readonly SessionEvent[],
seq: number,
): SessionEventTrace {
const target = events[seq]
if (target === undefined || target.seq !== seq) {
throw new SessionQueryError(
`session "${sessionId}" has no event at seq ${seq}`,
'SESSION_QUERY_EVENT_NOT_FOUND',
)
}
const analysis = analyzeEventLog(sessionId, events)
const replacementChain: number[] = []
let replacement = analysis.replacedBy.get(seq)
while (replacement !== undefined) {
replacementChain.push(replacement)
replacement = analysis.replacedBy.get(replacement)
}
const derivedEventSeqs: number[] = []
for (const event of events) {
if (event.seq <= seq) continue
if (eventSources(event).includes(seq)) derivedEventSeqs.push(event.seq)
}
// The target check above proves the parallel record exists at this index.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const targetRecord = analysis.records[seq]!
const replacedBy = analysis.replacedBy.get(seq)
return {
target: targetRecord,
...replacedBy === undefined ? {} : { replacedBy },
replacementChain,
replacedEventSeqs: analysis.replacedEventSeqs.get(seq) ?? [],
sourceEventSeqs: [...eventSources(target)],
derivedEventSeqs,
}
}
/**
* Trace one target's known ancestry and recursively known descendants.
* @param records - complete logical corpus from one observation.
* @param sessionId - target session id.
* @returns complete or explicitly partial lineage.
*/
export function traceSession(
records: readonly SessionRecord[],
sessionId: SessionId,
): SessionLineageTrace {
const byId = new Map(records.map(record => [record.header.id, record]))
const target = byId.get(sessionId)
if (target === undefined) {
throw new SessionQueryError(
`session "${sessionId}" not found`,
'SESSION_QUERY_SESSION_NOT_FOUND',
)
}
const ancestors: SessionRecord[] = []
const ancestrySeen = new Set<SessionId>([sessionId])
let unresolvedParentId: SessionId | undefined
let parentId = target.header.parentSession
while (parentId !== undefined) {
if (ancestrySeen.has(parentId)) {
throw new SessionQueryError(
`session lineage contains a cycle at "${parentId}"`,
'SESSION_QUERY_INVALID_LINEAGE',
)
}
ancestrySeen.add(parentId)
const parent = byId.get(parentId)
if (parent === undefined) {
unresolvedParentId = parentId
break
}
ancestors.push(parent)
parentId = parent.header.parentSession
}
const childrenByParent = new Map<SessionId, SessionRecord[]>()
for (const record of records) {
const parent = record.header.parentSession
if (parent === undefined) continue
const children = childrenByParent.get(parent) ?? []
children.push(record)
childrenByParent.set(parent, children)
}
for (const children of childrenByParent.values()) {
children.sort((a, b) => a.header.createdAt - b.header.createdAt || a.header.id.localeCompare(b.header.id))
}
const descendants = buildDescendants(childrenByParent, sessionId)
const common = {
target: cloneRecord(target),
ancestors: ancestors.map(cloneRecord),
descendants,
}
if (unresolvedParentId !== undefined) {
return { ...common, complete: false, unresolvedParentId }
}
return {
...common,
complete: true,
root: cloneRecord(ancestors.at(-1) ?? target),
}
}
function analyzeEventLog(
sessionId: SessionId,
events: readonly SessionEvent[],
): EventLogAnalysis {
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 current = new Set(folded.nodes)
const replacedBy = new Map<number, number>()
const replacedEventSeqs = new Map<number, number[]>()
for (const replacement of folded.replacements) {
const removed = replacement.shadowedSeqs
replacedEventSeqs.set(replacement.seq, removed)
for (const removedSeq of removed) {
replacedBy.set(removedSeq, replacement.seq)
}
}
return {
records: events.map(event => ({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: current.has(event.seq)
? 'current'
: replacedBy.has(event.seq) ? 'shadowed' : 'log-only',
})),
replacedBy,
replacedEventSeqs,
currentSeqs: [...folded.nodes],
}
}
function eventSources(event: SessionEvent): readonly number[] {
return (event as SessionEvent<SurfaceEventType>).sourceEventSeqs ?? []
}
function buildDescendants(
childrenByParent: ReadonlyMap<SessionId, readonly SessionRecord[]>,
sessionId: SessionId,
): SessionLineageNode[] {
const descendants: SessionLineageNode[] = []
const stack = [{ sessionId, descendants }]
while (stack.length > 0) {
// The length guard proves a frame exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const frame = stack.pop()!
const nodes: SessionLineageNode[] = []
for (const child of childrenByParent.get(frame.sessionId) ?? []) {
const node = { session: cloneRecord(child), descendants: [] }
nodes.push(node)
frame.descendants.push(node)
}
for (let index = nodes.length - 1; index >= 0; index -= 1) {
// The loop bounds prove this indexed node exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const node = nodes[index]!
stack.push({ sessionId: node.session.header.id, descendants: node.descendants })
}
}
return descendants
}
function cloneRecord(record: SessionRecord): SessionRecord {
return { ...record, header: structuredClone(record.header) }
}

View File

@@ -1,10 +1,17 @@
/**
* Public records for exact reads over the live-preferred logical session corpus.
* Public records for exact reads and relationship traces over the
* live-preferred logical session corpus.
*
* @module @deepseek-ai/dsh-session-query/types
*/
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } 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'
@@ -22,6 +29,16 @@ export interface SessionRecord {
persisted: boolean
}
/** One atomic live-preferred observation of a session's current model surface. */
export interface SessionSurfaceSnapshot {
/** Cloned session header selected from the same corpus observation as `events`. */
session: SessionHeader
/** Highest raw-log seq included in the observation, or `null` for an empty log. */
capturedThroughSeq: number | null
/** Cloned current surface events in model-history order. */
events: SurfaceEvent[]
}
/** Lightweight metadata for one event within a logical session. */
export interface SessionEventRecord {
/** Session that owns the event. */
@@ -36,6 +53,61 @@ export interface SessionEventRecord {
surface: SessionEventSurface
}
/** Recursive descendant node in a session-lineage trace. */
export interface SessionLineageNode {
/** Detached logical-corpus record for this descendant. */
session: SessionRecord
/** Direct children, each carrying its own recursive descendants. */
descendants: SessionLineageNode[]
}
/** Known ancestry and descendants for one logical session. */
export type SessionLineageTrace = {
/** Detached record for the session that was traced. */
target: SessionRecord
/** Known parents from the immediate parent outward. */
ancestors: SessionRecord[]
/** Complete known descendant trees rooted at the target's direct children. */
descendants: SessionLineageNode[]
} & (
| {
/** The complete parent chain is present in the logical corpus. */
complete: true
/** Detached record at the top of the complete lineage. */
root: SessionRecord
}
| {
/** The parent chain leaves the visible logical corpus. */
complete: false
/** First parent id that is not present in the logical corpus. */
unresolvedParentId: SessionId
}
)
/** Request for direct surface and provenance relationships around one event. */
export interface SessionEventTraceRequest {
/** Session that owns the target event. */
sessionId: SessionId
/** Target event seq. */
seq: number
}
/** Direct surface and provenance relationships for one event. */
export interface SessionEventTrace {
/** Lightweight target record. */
target: SessionEventRecord
/** Immediate positional replacement event, when the target was shadowed. */
replacedBy?: number
/** Positional replacers from the immediate replacement to the final replacement. */
replacementChain: number[]
/** Surface nodes directly removed when the target itself performed a replacement. */
replacedEventSeqs: number[]
/** Direct logged provenance sources in their recorded order. */
sourceEventSeqs: number[]
/** Later events that directly name the target as a provenance source, in log order. */
derivedEventSeqs: number[]
}
/** Request for one event plus raw neighboring log context. */
export interface SessionEventReadRequest {
/** Session that owns the target event. */

View File

@@ -49,7 +49,7 @@ describe('session-query semantic extraction', () => {
]
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 1, data: { content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: messageContent, provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'context/message', seq: 2, time: 3, data: { content: messageContent, source: { kind: 'plugin', plugin: 'test' } }, surfaceOp: 'append' },
{ type: 'steering/message', seq: 3, time: 4, data: { turn: 1, content: messageContent, source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'prompt/blocked', seq: 4, time: 5, data: { content: [{ type: 'text', text: 'unsafe' }], source: { kind: 'user' }, reason: 'policy' } },
@@ -73,7 +73,7 @@ describe('session-query semantic extraction', () => {
const reasons: Array<[SessionEvent<'turn/end'>['data']['reason'], string]> = [
[{ kind: 'error', step: 2, message: 'boom', code: 'E' }, 'error\nboom\nE'],
[{ kind: 'error', step: 2, message: 'boom' }, 'error\nboom'],
[{ kind: 'aborted', reason: 'cancelled' }, 'aborted\ncancelled'],
[{ kind: 'error', step: 2, failure: { message: 'provider boom', code: 'SERVER' } }, 'error\nprovider boom\nSERVER'],
[{ kind: 'aborted' }, 'aborted'],
[{ kind: 'rejected', reason: 'denied' }, 'rejected\ndenied'],
[{ kind: 'disposed' }, 'disposed'],
@@ -90,11 +90,10 @@ describe('session-query semantic extraction', () => {
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'step/end', seq: 2, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 3, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { model: 'test' } }, reason: 'initial' } },
{ type: 'request/header-delta', seq: 5, time: 1, data: {} },
{ type: 'future/event', seq: 6, time: 1, data: { text: 'hidden' } } as never,
{ type: 'request/header', seq: 4, time: 1, data: { header: { config: { provider: 'test', model: 'test' } }, reason: 'initial' } },
{ type: 'future/event', seq: 5, time: 1, data: { text: 'hidden' } } as never,
]
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', '', ''])
expect(structural.map(extractSessionEventText)).toEqual(['', '', '', '', '', ''])
})
})
@@ -102,7 +101,7 @@ describe('session-query document and filter helpers', () => {
const events: SessionEvent[] = [
{ type: 'user/message', seq: 0, time: 10, data: { content: [{ type: 'text', text: 'Hello\n(AI)+' }], source: { kind: 'user' } }, surfaceOp: 'append' },
{ type: 'assistant/chunk', seq: 1, time: 11, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'raw' } } },
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] }, surfaceOp: { op: 'replace', start: 0, end: 0 } },
{ type: 'assistant/message', seq: 2, time: 12, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] },
{ type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'interrupted' } } },
]
@@ -173,7 +172,7 @@ describe('session-query document and filter helpers', () => {
type: 'assistant/message',
seq: 0,
time: 1,
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }] },
data: { turn: 1, step: 1, content: [{ type: 'text', text: 'bad' }], provenance: { provider: 'mock', model: 'mock' } },
surfaceOp: { op: 'replace', start: 9, end: 9 },
}]
expect(() => buildSessionEventRecords(id, malformed)).toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))

View File

@@ -7,6 +7,7 @@ import SessionQueryService, {
type SessionEventSurface,
type SessionQueryErrorCode,
} from '@deepseek-ai/dsh-session-query'
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
@@ -35,6 +36,10 @@ class TestPersistence extends SessionPersistence {
this.afterList = undefined
}
locate(_meta: SessionHeader): undefined {
return undefined
}
create(meta: SessionHeader): Promise<void> {
TestPersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
return Promise.resolve()
@@ -90,6 +95,58 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
}
describe('session-query exact reads', () => {
it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => {
const persistedHeader = header('persisted-title', 2)
const sharedHeader = header('shared-title', 3)
TestPersistence.reset([
{
meta: persistedHeader,
events: [{
type: 'session/title',
seq: 0,
time: 20,
data: {
title: 'Persisted title',
messageSeqs: [4],
source: { kind: 'fallback' },
},
}],
},
{
meta: sharedHeader,
events: [{
type: 'session/title',
seq: 0,
time: 30,
data: {
title: 'Stale durable title',
messageSeqs: [1],
source: { kind: 'fallback' },
},
}],
},
])
const ctx = await liveContext()
const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } })
shared.append('session/title', {
title: 'Live title',
messageSeqs: [7],
source: {
kind: 'provider',
provider: SessionTitleProviderId('query-test'),
},
})
await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.readTitle(persistedHeader.id)).resolves.toMatchObject({
title: 'Persisted title', eventSeq: 0, updatedAt: 20,
})
await expect(ctx.sessionQuery.readTitle(shared.id)).resolves.toMatchObject({
title: 'Live title', eventSeq: 0,
})
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
})
it('lists live sessions deterministically and returns detached headers', async () => {
const ctx = await liveContext()
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
@@ -138,6 +195,8 @@ describe('session-query exact reads', () => {
it('classifies current, shadowed, and raw-log-only events through foldSurface', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('surface'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
const first = session.append(
'user/message',
{ content: [{ type: 'text', text: 'first' }], source: { kind: 'user' } },
@@ -150,17 +209,76 @@ describe('session-query exact reads', () => {
})
session.append(
'assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq } },
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'replacement' }] },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
expect((await ctx.sessionQuery.listEvents(session.id)).slice(2).map(record => record.surface))
.toEqual(['shadowed', 'log-only', 'current'])
})
it('reads a detached current surface with its raw-log capture boundary', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('surface-snapshot'), { meta: { cwd: '/work' } })
const first = session.append(
'user/message',
{ content: [{ type: 'text', text: 'old' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'draft' },
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
const retained = session.append(
'user/message',
{ content: [{ type: 'text', text: 'retained tail' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append(
'user/message',
{ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
{ surfaceOp: { op: 'replace', start: 2, end: retained.seq }, sourceEventSeqs: [2, retained.seq] },
)
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'latest answer' }] },
{ surfaceOp: 'append' },
)
const snapshot = await ctx.sessionQuery.readSurface(session.id)
expect(snapshot.session).toEqual(session.header)
expect(snapshot.capturedThroughSeq).toBe(5)
expect(snapshot.events.map(event => [event.seq, event.type])).toEqual([
[4, 'user/message'],
[5, 'assistant/message'],
])
if (snapshot.events[0]?.type !== 'user/message') throw new Error('expected current user message')
snapshot.events[0].data.content = []
Object.assign(snapshot.session, { cwd: '/mutated' })
expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1)
expect(session.header.cwd).toBe('/work')
})
it('returns an empty current surface with a null capture boundary', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('empty-surface'))
await expect(ctx.sessionQuery.readSurface(session.id)).resolves.toMatchObject({
capturedThroughSeq: null,
events: [],
})
})
it('returns a bounded detached raw-event window and validates the request', async () => {
const ctx = await liveContext({ readWindowMax: 1 })
const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
for (const text of ['one', 'two', 'three']) {
session.append(
'user/message',
@@ -169,14 +287,14 @@ describe('session-query exact reads', () => {
)
}
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 1, before: 1, after: 1 })
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([0, 2, 1])
const result = await ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 2, before: 1, after: 1 })
expect([result.startSeq, result.endSeq, result.target.seq]).toEqual([1, 3, 2])
expect(result.session).toEqual(session.header)
Object.assign(result.session, { createdAt: -1 })
if (result.events[0]?.type !== 'user/message') throw new Error('expected user message')
result.events[0].data.content = []
expect(session.header.createdAt).not.toBe(-1)
expect(session.events[0]?.type === 'user/message' && session.events[0].data.content).toHaveLength(1)
expect(session.events[1]?.type === 'user/message' && session.events[1].data.content).toHaveLength(1)
await expect(ctx.sessionQuery.readEvent({ sessionId: session.id, seq: 9 }))
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
@@ -198,6 +316,7 @@ describe('session-query exact reads', () => {
])
const ctx = await liveContext()
const live = ctx.sessions.create(shared.id, { meta: { createdAt: 3, cwd: '/same' } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } },
@@ -207,15 +326,24 @@ describe('session-query exact reads', () => {
expect((await ctx.sessionQuery.listSessions()).map(record => [record.header.id, record.live, record.persisted]))
.toEqual([[shared.id, true, true], [durable.id, false, true]])
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 })
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 1 })
expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0])
.toMatchObject({ text: 'live' })
await expect(ctx.sessionQuery.readSurface(shared.id)).resolves.toMatchObject({
events: [{ data: { content: [{ text: 'live' }] } }],
})
await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 }))
.resolves.toMatchObject({ session: durable })
await expect(ctx.sessionQuery.readSurface(durable.id)).resolves.toMatchObject({
session: durable,
events: [{ data: { content: [{ text: 'durable' }] } }],
})
const sharedEntry = TestPersistence.entries.get(shared.id)!
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/conflict' }
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/same', delegationDepth: 1 }
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
await persistence.dispose()
await expect(ctx.sessionQuery.listSessions()).resolves.toEqual([
{ header: shared, live: true, persisted: false },
@@ -226,6 +354,7 @@ describe('session-query exact reads', () => {
TestPersistence.reset()
const ctx = await liveContext()
const live = ctx.sessions.create(SessionId('live'))
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
'user/message',
{ content: [{ type: 'text', text: 'available' }], source: { kind: 'user' } },
@@ -235,8 +364,8 @@ describe('session-query exact reads', () => {
TestPersistence.listFailure = new Error('list unavailable')
TestPersistence.loadFailure = new Error('load unavailable')
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(1)
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 0 })).resolves.toMatchObject({ target: { seq: 0 } })
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
})
@@ -265,17 +394,8 @@ describe('session-query exact reads', () => {
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
})
it('turns malformed surfaces and direct invalid config into typed errors', async () => {
it('turns persisted malformed surfaces and direct invalid config into typed errors', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('bad-surface'))
session.append(
'assistant/message',
{ turn: 1, step: 1, content: [] },
{ surfaceOp: { op: 'replace', start: 9, end: 9 } },
)
await expect(ctx.sessionQuery.listEvents(session.id))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
const persisted = header('bad-persisted-surface')
TestPersistence.reset([{
meta: persisted,

View File

@@ -0,0 +1,435 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, { type SessionQueryErrorCode } from '@deepseek-ai/dsh-session-query'
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
/** Test-only mutable view used to verify detached returned metadata. */
function mutableHeader(value: SessionHeader): MutableSessionHeader {
return value
}
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
}
function appendEvent(seq: number, sources?: number[]): SessionEvent {
return {
type: 'user/message',
seq,
time: seq + 1,
data: { content: [{ type: 'text', text: `event ${seq}` }], source: { kind: 'user' } },
surfaceOp: 'append',
...sources === undefined ? {} : { sourceEventSeqs: sources },
}
}
class TracePersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listCalls = 0
static loadCalls = 0
static listFailure: Error | undefined
static loadFailure: Error | undefined
static afterList: (() => void) | undefined
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
this.listCalls = 0
this.loadCalls = 0
this.listFailure = undefined
this.loadFailure = undefined
this.afterList = undefined
}
locate(_meta: SessionHeader): undefined {
return undefined
}
create(meta: SessionHeader): Promise<void> {
TracePersistence.entries.set(meta.id, { meta: structuredClone(meta), events: [] })
return Promise.resolve()
}
append(id: SessionIdType, events: readonly SessionEvent[]): Promise<void> {
const entry = TracePersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
entry.events.push(...structuredClone(events))
return Promise.resolve()
}
load(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TracePersistence.loadCalls += 1
if (TracePersistence.loadFailure !== undefined) return Promise.reject(TracePersistence.loadFailure)
const entry = TracePersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
return Promise.resolve(structuredClone(entry))
}
list(): Promise<SessionHeader[]> {
TracePersistence.listCalls += 1
if (TracePersistence.listFailure !== undefined) return Promise.reject(TracePersistence.listFailure)
const result = [...TracePersistence.entries.values()].map(entry => structuredClone(entry.meta))
TracePersistence.afterList?.()
return Promise.resolve(result)
}
listSnapshots(): Promise<never[]> {
return Promise.resolve([])
}
}
async function queryContext(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionQueryService)
return ctx
}
function expectCode(code: SessionQueryErrorCode): Error {
return expect.objectContaining({ code }) as Error
}
function appendTraceEvents(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'draft' },
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } },
{ surfaceOp: 'append', sourceEventSeqs: [2] },
)
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
{ surfaceOp: { op: 'replace', start: 3, end: 3 }, sourceEventSeqs: [3, 2] },
)
session.append(
'context/message',
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
{ surfaceOp: 'append' },
)
session.append('step/end', { turn: 1, step: 1 })
session.append('step/start', { turn: 1, step: 2 })
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
{ surfaceOp: { op: 'replace', start: 4, end: 4 }, sourceEventSeqs: [2, 4] },
)
}
describe('session lineage tracing', () => {
it('returns complete ancestry, deterministic descendant trees, and detached records', async () => {
const ctx = await queryContext()
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 0 } })
const parent = ctx.sessions.create(SessionId('parent'), {
meta: { createdAt: 1, parentSession: root.id },
})
const target = ctx.sessions.create(SessionId('target'), {
meta: { createdAt: 2, parentSession: parent.id },
})
ctx.sessions.create(SessionId('b'), { meta: { createdAt: 4, parentSession: target.id } })
const childA = ctx.sessions.create(SessionId('a'), {
meta: { createdAt: 4, parentSession: target.id },
})
ctx.sessions.create(SessionId('older'), { meta: { createdAt: 3, parentSession: target.id } })
ctx.sessions.create(SessionId('grandchild'), {
meta: { createdAt: 5, parentSession: childA.id },
})
const trace = await ctx.sessionQuery.traceSession(target.id)
expect(trace.complete).toBe(true)
if (!trace.complete) throw new Error('expected complete lineage')
expect(trace.ancestors.map(record => record.header.id)).toEqual([parent.id, root.id])
expect(trace.root.header.id).toBe(root.id)
expect(trace.descendants.map(node => node.session.header.id))
.toEqual([SessionId('older'), SessionId('a'), SessionId('b')])
expect(trace.descendants[1]?.descendants.map(node => node.session.header.id))
.toEqual([SessionId('grandchild')])
mutableHeader(trace.target.header).createdAt = 99
mutableHeader(trace.ancestors[0]!.header).createdAt = 99
mutableHeader(trace.root.header).createdAt = 99
mutableHeader(trace.descendants[0]!.session.header).createdAt = 99
const repeated = await ctx.sessionQuery.traceSession(target.id)
expect(repeated.target.header.createdAt).toBe(2)
expect(repeated.ancestors[0]?.header.createdAt).toBe(1)
expect(repeated.descendants[0]?.session.header.createdAt).toBe(3)
})
it('represents root and unresolved-parent traces explicitly', async () => {
const ctx = await queryContext()
const root = ctx.sessions.create(SessionId('root'), { meta: { createdAt: 1 } })
const partial = ctx.sessions.create(SessionId('partial'), {
meta: { createdAt: 2, parentSession: SessionId('outside') },
})
await expect(ctx.sessionQuery.traceSession(root.id)).resolves.toMatchObject({
complete: true,
root: { header: { id: root.id } },
ancestors: [],
})
await expect(ctx.sessionQuery.traceSession(partial.id)).resolves.toMatchObject({
complete: false,
unresolvedParentId: SessionId('outside'),
ancestors: [],
})
})
it('rejects target-connected cycles and missing targets', async () => {
const ctx = await queryContext()
ctx.sessions.create(SessionId('a'), {
meta: { createdAt: 1, parentSession: SessionId('b') },
})
ctx.sessions.create(SessionId('b'), {
meta: { createdAt: 2, parentSession: SessionId('a') },
})
await expect(ctx.sessionQuery.traceSession(SessionId('a')))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_LINEAGE'))
await expect(ctx.sessionQuery.traceSession(SessionId('missing')))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
})
it('uses one cross-corpus observation and preserves persistence failure semantics', async () => {
const durable = header('durable')
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.traceSession(durable.id)).resolves.toMatchObject({
target: { live: false, persisted: true },
complete: true,
})
expect(TracePersistence.listCalls).toBe(1)
expect(TracePersistence.loadCalls).toBe(0)
TracePersistence.listFailure = new Error('unavailable')
await expect(ctx.sessionQuery.traceSession(durable.id))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
})
it('constructs deeply nested descendants without consuming the JavaScript call stack', async () => {
const ctx = await queryContext()
const root = ctx.sessions.create(SessionId('deep-0'), { meta: { createdAt: 0 } })
let parent = root
for (let depth = 1; depth < 3_000; depth += 1) {
parent = ctx.sessions.create(SessionId(`deep-${depth}`), {
meta: { createdAt: depth, parentSession: parent.id },
})
}
const trace = await ctx.sessionQuery.traceSession(root.id)
expect(trace.complete).toBe(true)
let node = trace.descendants[0]
for (let depth = 1; depth < 3_000; depth += 1) {
if (node === undefined) throw new Error(`lineage ended before depth ${depth}`)
if (depth === 2_999) expect(node.session.header.id).toBe(SessionId('deep-2999'))
node = node.descendants[0]
}
expect(node).toBeUndefined()
})
})
describe('session event tracing', () => {
it('returns direct replacement and provenance links in their contract order', async () => {
const ctx = await queryContext()
const session = ctx.sessions.create(SessionId('trace'))
appendTraceEvents(session)
const original = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 3 })
expect(original.target).toMatchObject({
sessionId: session.id,
seq: 3,
type: 'user/message',
surface: 'shadowed',
})
expect(original).toMatchObject({
replacedBy: 4,
replacementChain: [4, 8],
replacedEventSeqs: [],
sourceEventSeqs: [2],
derivedEventSeqs: [4],
})
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }))
.resolves.toMatchObject({
replacedBy: 8,
replacementChain: [8],
replacedEventSeqs: [3],
sourceEventSeqs: [3, 2],
derivedEventSeqs: [8],
})
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }))
.resolves.toMatchObject({
target: { surface: 'log-only' },
replacementChain: [],
sourceEventSeqs: [],
derivedEventSeqs: [3, 4, 8],
})
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 8 }))
.resolves.toMatchObject({
replacementChain: [],
replacedEventSeqs: [4],
sourceEventSeqs: [2, 4],
derivedEventSeqs: [],
})
})
it('returns fresh trace arrays and target records', async () => {
const ctx = await queryContext()
const session = ctx.sessions.create(SessionId('detached'))
appendTraceEvents(session)
const first = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })
first.target.time = -1
first.replacementChain.push(99)
first.replacedEventSeqs.push(99)
first.sourceEventSeqs.push(99)
first.derivedEventSeqs.push(99)
const repeated = await ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 })
expect(repeated.target.time).not.toBe(-1)
expect(repeated.replacementChain).toEqual([8])
expect(repeated.replacedEventSeqs).toEqual([3])
expect(repeated.sourceEventSeqs).toEqual([3, 2])
expect(repeated.derivedEventSeqs).toEqual([8])
})
it('loads persisted logs once, prefers live logs, and preserves failures and conflicts', async () => {
const durable = header('shared', 1, { cwd: '/same' })
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.resolves.toMatchObject({ target: { type: 'user/message', surface: 'current' } })
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
const live = ctx.sessions.create(durable.id, { meta: { createdAt: 1, cwd: '/same' } })
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append(
'context/message',
{ content: [{ type: 'text', text: 'live' }], source: { kind: 'plugin', plugin: 'test' } },
{ surfaceOp: 'append' },
)
TracePersistence.listFailure = new Error('list unavailable')
TracePersistence.loadFailure = new Error('load unavailable')
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 1 }))
.resolves.toMatchObject({ target: { type: 'context/message' } })
expect([TracePersistence.listCalls, TracePersistence.loadCalls]).toEqual([1, 1])
TracePersistence.reset([{ meta: durable, events: [appendEvent(0)] }])
const failedCtx = await queryContext()
await failedCtx.plugin(TracePersistence)
TracePersistence.listFailure = new Error('list unavailable')
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TracePersistence.listFailure = undefined
TracePersistence.loadFailure = new Error('load unavailable')
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
TracePersistence.loadFailure = undefined
TracePersistence.afterList = () => {
mutableHeader(TracePersistence.entries.get(durable.id)!.meta).cwd = '/changed'
}
await expect(failedCtx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_SOURCE_CONFLICT'))
})
it('checks target existence before surface or provenance analysis', async () => {
const bad = header('bad-target')
const malformed: SessionEvent[] = [appendEvent(0), {
type: 'assistant/message',
seq: 1,
time: 2,
data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } },
surfaceOp: { op: 'replace', start: 9, end: 9 },
sourceEventSeqs: [],
}]
TracePersistence.reset([{ meta: bad, events: malformed }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 9 }))
.rejects.toThrow(expectCode('SESSION_QUERY_EVENT_NOT_FOUND'))
await expect(ctx.sessionQuery.traceEvent({ sessionId: bad.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it.each([
['non-surface sources', [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }, sourceEventSeqs: [0] },
]],
['invalid source array', [
{ ...appendEvent(0), sourceEventSeqs: 'invalid' },
]],
['empty sources', [
appendEvent(0, []),
]],
['sparse sources', [
appendEvent(0, Array<number>(1)),
]],
['duplicate sources', [
appendEvent(0),
appendEvent(1, [0, 0]),
]],
['missing earlier source', [
appendEvent(0),
appendEvent(1, [-1]),
]],
['future source', [
appendEvent(0, [1]),
appendEvent(1),
]],
['replacement without sources', [
appendEvent(0),
{ ...appendEvent(1), surfaceOp: { op: 'replace', start: 0, end: 0 } },
]],
['replacement missing a shadowed source', [
{ type: 'assistant/chunk', seq: 0, time: 1, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'draft' } } },
appendEvent(1),
{ ...appendEvent(2, [0]), surfaceOp: { op: 'replace', start: 1, end: 1 } },
]],
] as const)('rejects an invalid surface log: %s', async (_name, rawEvents) => {
const durable = header('invalid-provenance')
const events = structuredClone(rawEvents) as unknown as SessionEvent[]
TracePersistence.reset([{ meta: durable, events }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('rejects surfaceOp on a non-surface event as an invalid surface', async () => {
const durable = header('invalid-non-surface-op')
const events = [{
type: 'turn/start',
seq: 0,
time: 1,
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
surfaceOp: 'append',
}] as unknown as SessionEvent[]
TracePersistence.reset([{ meta: durable, events }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.traceEvent({ sessionId: durable.id, seq: 0 }))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
it('applies the same surface contract to listEvents', async () => {
const durable = header('list-regression')
TracePersistence.reset([{ meta: durable, events: [appendEvent(0), appendEvent(1, [0, 0])] }])
const ctx = await queryContext()
await ctx.plugin(TracePersistence)
await expect(ctx.sessionQuery.listEvents(durable.id))
.rejects.toThrow(expectCode('SESSION_QUERY_INVALID_SURFACE'))
})
})

View File

@@ -26,8 +26,14 @@
{
"path": "../../core/session"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../support/invariants"
}
]
}