Merge master into codex/simp-session-log-representation

This commit is contained in:
Tianyi Cui
2026-07-17 21:51:36 +08:00
357 changed files with 18546 additions and 2866 deletions

View File

@@ -1,9 +1,9 @@
# session-query/ — session retrieval capability family
Trusted exact reads over live and durable session logs. Phase one contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, and bounded event reads.
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships.
| Package | Role | ctx key |
|---|---|---|
| [`session-query/`](session-query/README.md) | Logical-corpus and exact-event read service | `ctx.sessionQuery` |
| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` |
The family is independent of compaction: it reads the canonical session log but does not participate in compaction policy or execution. Full-text search remains proposed as a phase-two SQLite package rather than a speculative provider seam in this interface package.
The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package.

View File

@@ -1,16 +1,20 @@
# @deepseek-ai/dsh-session-query
Exact session-history retrieval 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`.
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`.
## Reads
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `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.
- `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. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations.
`SessionQueryError.code` is a closed union: `SESSION_QUERY_EVENT_NOT_FOUND`, `SESSION_QUERY_INVALID_CONFIG`, `SESSION_QUERY_INVALID_SURFACE`, `SESSION_QUERY_INVALID_WINDOW`, `SESSION_QUERY_PERSISTENCE_FAILED`, `SESSION_QUERY_SESSION_NOT_FOUND`, and `SESSION_QUERY_SOURCE_CONFLICT`.
`listEvents()` 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`.
`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`.
## Configuration
@@ -25,4 +29,4 @@ None, as this trusted query service returns cloned session records only to its c
## 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.
- **Exact retrieval only** — filters, lineage/provenance traversal, extraction, search-provider protocol, index synchronization, and a model-facing tool are absent. Full-text search belongs beside its first implementation; the proposed SQLite package and its single transaction/reconciliation owner are described in the [phase-two RFC](../../../docs/rfc/proposed/feature/2026-07-10-sqlite-session-query-provider.md).
- **No search or extraction** — filters, extraction registry, search-provider protocol, index synchronization, and a model-facing tool are absent. The [tracing decision](../../../docs/rfc/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](../../../docs/rfc/proposed/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",

View File

@@ -5,16 +5,17 @@ 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 exact session reads. */
/** Stable machine-routable failure taxonomy for exact session reads and traces. */
export type SessionQueryErrorCode =
| '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'

View File

@@ -1,17 +1,19 @@
/**
* 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
*/
import { Context, Service } from 'cordis'
import z from 'schemastery'
import { foldSurface } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type {
SessionEventReadRequest,
SessionEventRecord,
SessionEventTrace,
SessionEventTraceRequest,
SessionEventWindow,
SessionLineageTrace,
SessionRecord,
} from './types.ts'
import {
@@ -20,6 +22,7 @@ import {
type Config,
} from './config.ts'
import { SessionCorpus } from './corpus.ts'
import * as tracing from './tracing.ts'
export type * from './types.ts'
export type { Config, SessionQueryErrorCode } from './config.ts'
@@ -31,7 +34,7 @@ declare module 'cordis' {
}
}
/** Live-preferred logical-corpus and exact-event read service. */
/** Live-preferred logical-corpus exact-read and relationship-tracing service. */
export class SessionQueryService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
@@ -68,7 +71,29 @@ export class SessionQueryService extends Service {
*/
async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]> {
const loaded = await this._corpus.load(sessionId)
return eventRecords(sessionId, loaded.events)
return tracing.eventRecords(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)
}
/**
@@ -110,27 +135,4 @@ export class SessionQueryService extends Service {
}
}
function eventRecords(sessionId: SessionId, events: readonly SessionEvent[]): SessionEventRecord[] {
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 shadowed = new Set(folded.replacements.flatMap(replacement => replacement.shadowedSeqs))
return events.map(event => ({
sessionId,
seq: event.seq,
type: event.type,
time: event.time,
surface: current.has(event.seq) ? 'current' : shadowed.has(event.seq) ? 'shadowed' : 'log-only',
}))
}
export default SessionQueryService

View File

@@ -0,0 +1,222 @@
/** One-shot session-lineage and event-relationship tracing helpers. */
import { foldSurface } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, 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[]>
}
/**
* 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
}
/**
* 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,
}
}
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,5 +1,6 @@
/**
* 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
*/
@@ -33,6 +34,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

@@ -34,6 +34,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()
@@ -110,7 +114,7 @@ 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 } },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
expect((await ctx.sessionQuery.listEvents(session.id)).map(record => record.surface))
@@ -227,11 +231,13 @@ describe('session-query exact reads', () => {
it('turns 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 } },
)
;(session as unknown as { log: SessionEvent[] }).log.push({
type: 'assistant/message',
seq: 0,
time: 1,
data: { 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'))

View File

@@ -0,0 +1,426 @@
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)
}
}
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('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: [0] },
)
session.append(
'assistant/message',
{ turn: 1, step: 1, content: [{ type: 'text', text: 'summary one' }] },
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1, 0] },
)
session.append(
'context/message',
{ content: [{ type: 'text', text: 'context' }], source: { kind: 'plugin', plugin: 'test' } },
{ surfaceOp: 'append' },
)
session.append(
'assistant/message',
{ turn: 1, step: 2, content: [{ type: 'text', text: 'summary two' }] },
{ surfaceOp: { op: 'replace', start: 2, end: 2 }, sourceEventSeqs: [0, 2] },
)
}
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: 1 })
expect(original.target).toMatchObject({
sessionId: session.id,
seq: 1,
type: 'user/message',
surface: 'shadowed',
})
expect(original).toMatchObject({
replacedBy: 2,
replacementChain: [2, 4],
replacedEventSeqs: [],
sourceEventSeqs: [0],
derivedEventSeqs: [2],
})
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 2 }))
.resolves.toMatchObject({
replacedBy: 4,
replacementChain: [4],
replacedEventSeqs: [1],
sourceEventSeqs: [1, 0],
derivedEventSeqs: [4],
})
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 0 }))
.resolves.toMatchObject({
target: { surface: 'log-only' },
replacementChain: [],
sourceEventSeqs: [],
derivedEventSeqs: [1, 2, 4],
})
await expect(ctx.sessionQuery.traceEvent({ sessionId: session.id, seq: 4 }))
.resolves.toMatchObject({
replacementChain: [],
replacedEventSeqs: [2],
sourceEventSeqs: [0, 2],
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: 2 })
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: 2 })
expect(repeated.target.time).not.toBe(-1)
expect(repeated.replacementChain).toEqual([4])
expect(repeated.replacedEventSeqs).toEqual([1])
expect(repeated.sourceEventSeqs).toEqual([1, 0])
expect(repeated.derivedEventSeqs).toEqual([4])
})
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(
'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: 0 }))
.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: [] },
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'))
})
})