Merge remote-tracking branch 'origin/master' into codex/agent-session-jsonl-location
# Conflicts: # docs/capability-seams.md # packages/cordis/tool-cordis/src/api-catalog.ts
This commit is contained in:
@@ -28,7 +28,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
|
||||
| [`cordis/`](cordis/README.md) | Self-referential runtime toolset: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface |
|
||||
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
|
||||
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
|
||||
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
|
||||
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
|
||||
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
|
||||
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) the leaves load | Support — example infra |
|
||||
|
||||
@@ -2182,7 +2182,10 @@ describe('dynamic nested workspace context injection', () => {
|
||||
agent.session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'compacted summary' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq } })
|
||||
}, {
|
||||
surfaceOp: { op: 'replace', start: contextSeq, end: contextSeq },
|
||||
sourceEventSeqs: [contextSeq],
|
||||
})
|
||||
|
||||
const afterCompact = await ctx.tools.execute({
|
||||
callId: CallId('read-after-compact'),
|
||||
|
||||
@@ -168,10 +168,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
|
||||
},
|
||||
{
|
||||
key: 'sessionQuery',
|
||||
summary: 'Live-preferred logical-corpus and exact-event read service.',
|
||||
summary: 'Live-preferred logical-corpus exact-read and relationship-tracing service.',
|
||||
methods: [
|
||||
'listSessions(): Promise<SessionRecord[]>',
|
||||
'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
|
||||
'async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>',
|
||||
'async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace>',
|
||||
'async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>',
|
||||
],
|
||||
},
|
||||
@@ -867,6 +869,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionEventSurface',
|
||||
declaration: 'export type SessionEventSurface = \'current\' | \'shadowed\' | \'log-only\';',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventTrace',
|
||||
declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventTraceRequest',
|
||||
declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionEventType',
|
||||
declaration: 'export type SessionEventType = keyof SessionEventMap;',
|
||||
@@ -887,6 +897,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
|
||||
name: 'SessionId',
|
||||
declaration: 'export type SessionId = Branded<\'SessionId\'>;',
|
||||
},
|
||||
{
|
||||
name: 'SessionLineageNode',
|
||||
declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}',
|
||||
},
|
||||
{
|
||||
name: 'SessionLineageTrace',
|
||||
declaration: 'export type SessionLineageTrace = {\n target: SessionRecord;\n ancestors: SessionRecord[];\n descendants: SessionLineageNode[];\n} & ({\n complete: true;\n root: SessionRecord;\n} | {\n complete: false;\n unresolvedParentId: SessionId;\n});',
|
||||
},
|
||||
{
|
||||
name: 'SessionLocation',
|
||||
declaration: 'export interface SessionLocation {\n readonly kind: string;\n readonly path: string;\n}',
|
||||
|
||||
@@ -32,7 +32,7 @@ The store pairs announced creation with disposal, publishes post-commit append n
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs.
|
||||
- `session.deriveMessages()` incrementally projects each new surface node once and returns a fresh array over shared frozen messages. A surface rewrite rebuilds the projection; there is no raw-log fallback.
|
||||
- `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants.
|
||||
- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite.
|
||||
@@ -49,7 +49,7 @@ Durable values need one accepted representation, not a check followed by a secon
|
||||
- `SurfaceOp` — how a surface node entered the linked list: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace nodes from `start` through `end` inclusive — both must be valid surface node seqs; `start === end` replaces a single node). Used by compaction to shadow old nodes without deleting them.
|
||||
- `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types.
|
||||
- `SurfaceNode` — `{ seq: number; prev: number | null; next: number | null }`, one node in the surface linked list.
|
||||
- `foldSurface(events)` — replay the canonical surface transitions into detached current nodes and actual replacement ranges, rejecting surface-eligible events that lack their mandatory marker. `SurfaceManager` shares the same transitions while retaining its incremental cache.
|
||||
- `foldSurface(events)` — replay the one canonical surface contract into detached current nodes and actual replacement ranges. The same pass rejects non-contiguous event seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface node; `SurfaceManager` shares the atomic transition while retaining its incremental cache.
|
||||
- `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully-formed surface node (type is surface-eligible AND `surfaceOp` present); the second is the type-only check (is this one of the five `SurfaceEventType` values?), used to detect a surface-eligible event MISSING its marker — e.g. when validating a seed/load log.
|
||||
|
||||
### Request-header reconstruction (`request-header.ts`)
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
|
||||
import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
|
||||
import { snapshotJsonValue } from './json.ts'
|
||||
import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
|
||||
import { SurfaceManager } from './surface.ts'
|
||||
import { foldRequestHeader } from './request-header.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
@@ -131,43 +131,6 @@ function snapshotSessionHeader(id: SessionId, source?: SessionHeader): SessionHe
|
||||
return deepFreeze(record as unknown as SessionHeader)
|
||||
}
|
||||
|
||||
/** Validate the runtime shape of surface metadata after its JSON snapshot. */
|
||||
function assertSurfaceMetadataShape(
|
||||
type: string,
|
||||
surfaceOp: unknown,
|
||||
sourceEventSeqs: unknown,
|
||||
): void {
|
||||
const eligible = isSurfaceEligibleType(type)
|
||||
if (!eligible) {
|
||||
if (surfaceOp !== undefined || sourceEventSeqs !== undefined) {
|
||||
throw new Error(`session event "${type}" is not surface-eligible and cannot carry surface metadata`)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (surfaceOp === undefined) {
|
||||
throw new Error(`session event "${type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
if (surfaceOp !== 'append') {
|
||||
if (surfaceOp === null || typeof surfaceOp !== 'object' || Array.isArray(surfaceOp)) {
|
||||
throw new Error(`session event "${type}" carries an invalid surfaceOp`)
|
||||
}
|
||||
const op = surfaceOp as Record<string, unknown>
|
||||
const keys = Object.keys(op)
|
||||
if (keys.length !== 3 || !Object.hasOwn(op, 'op') || !Object.hasOwn(op, 'start') || !Object.hasOwn(op, 'end')
|
||||
|| op['op'] !== 'replace'
|
||||
|| typeof op['start'] !== 'number' || !Number.isSafeInteger(op['start']) || op['start'] < 0
|
||||
|| typeof op['end'] !== 'number' || !Number.isSafeInteger(op['end']) || op['end'] < 0) {
|
||||
throw new Error(`session event "${type}" carries an invalid replace surfaceOp`)
|
||||
}
|
||||
}
|
||||
if (sourceEventSeqs !== undefined) {
|
||||
if (!Array.isArray(sourceEventSeqs)
|
||||
|| sourceEventSeqs.some(seq => typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0)) {
|
||||
throw new Error(`session event "${type}" sourceEventSeqs must contain non-negative safe integers`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the fixed event envelope after one-pass JSON materialization. */
|
||||
function assertSessionEventEnvelope(value: Record<string, unknown>, index: number): asserts value is SessionEvent {
|
||||
const event = value
|
||||
@@ -250,13 +213,15 @@ export function renderContextContent(
|
||||
*/
|
||||
export class Session {
|
||||
private log: SessionEvent[] = []
|
||||
/** Incremental acceptance state, kept separate from the public lazy view. */
|
||||
private readonly surfaceValidator = new SurfaceManager(this.log)
|
||||
|
||||
/**
|
||||
* Derived surface — a cached linked list of message-producing events.
|
||||
* Lazily rebuilt from `surfaceOp` markers in the log; processes only new
|
||||
* events (delta) on each access — the log is append-only, so prior events
|
||||
* never change.
|
||||
* `append`. Undefined until first accessed (including after fork/seed).
|
||||
* Undefined until first accessed (including after fork/seed).
|
||||
*/
|
||||
private _surface: SurfaceManager | undefined
|
||||
|
||||
@@ -285,7 +250,7 @@ export class Session {
|
||||
// `seq = log.length` contract the whole system relies on). Without this,
|
||||
// a bad seed would surface only later as a backend rejection or a silent
|
||||
// divergence between the live log and disk.
|
||||
this.log = Array.from(seed, (source, index) => {
|
||||
for (const [index, source] of seed.entries()) {
|
||||
// The seed is a persistence/replay boundary: validate and detach the
|
||||
// complete event in one lossless-JSON pass.
|
||||
const snapshot = snapshotJsonValue(source)
|
||||
@@ -296,20 +261,16 @@ export class Session {
|
||||
if (snapshot.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${snapshot.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
}
|
||||
// Surface-eligible events MUST carry a surfaceOp marker — the surface is
|
||||
// the sole source of derived history, so a marker-less message event
|
||||
// would load fine yet vanish from deriveMessages(). `append` enforces
|
||||
// this at compile time via its typed overload; a seed arrives as raw
|
||||
// SessionEvent[] (replay/fork/load), bypassing that, so re-check at
|
||||
// runtime here rather than silently resuming with empty history.
|
||||
const structural = snapshot as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
|
||||
// A seed is accepted incrementally through the same transition as a
|
||||
// live append and a full-log fold. The candidate is planned before it
|
||||
// enters `log`, so a failure cannot partially mutate the surface.
|
||||
try {
|
||||
assertSurfaceMetadataShape(snapshot.type, structural.surfaceOp, structural.sourceEventSeqs)
|
||||
this.surfaceValidator.validateNext(snapshot)
|
||||
} catch (error: unknown) {
|
||||
throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`)
|
||||
}
|
||||
return deepFreeze(snapshot)
|
||||
})
|
||||
this.log.push(deepFreeze(snapshot))
|
||||
}
|
||||
}
|
||||
this.header = snapshotSessionHeader(id, header)
|
||||
}
|
||||
@@ -356,7 +317,10 @@ export class Session {
|
||||
* @throws if `data` or surface metadata is not losslessly JSON-serializable
|
||||
* (BigInt, function, symbol, undefined, negative zero, non-finite number,
|
||||
* circular reference, sparse array, or an exotic object such as
|
||||
* Map/Set/Date/class instance). One recursive pass reads, validates, and
|
||||
* Map/Set/Date/class instance), or when the candidate violates the
|
||||
* canonical surface contract (marker shape and eligibility, unique
|
||||
* earlier provenance, positional replacement validity, and complete
|
||||
* shadowed-node coverage). One recursive pass reads, validates, and
|
||||
* copies each nested value once, so a stateful getter cannot supply one value
|
||||
* to validation and another to storage. The event log is the durable source
|
||||
* of truth, so a bad event fails at the append site rather than later during
|
||||
@@ -382,25 +346,21 @@ export class Session {
|
||||
if (surfaceMetadataSnapshot === undefined) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable surface metadata`)
|
||||
}
|
||||
assertSurfaceMetadataShape(
|
||||
type,
|
||||
(surfaceMetadataSnapshot as { surfaceOp?: unknown }).surfaceOp,
|
||||
(surfaceMetadataSnapshot as { sourceEventSeqs?: unknown }).sourceEventSeqs,
|
||||
)
|
||||
|
||||
const entry = attachments.get(this)
|
||||
if (entry?.appending) {
|
||||
throw new Error('session append cannot reenter while another append is being published')
|
||||
}
|
||||
const event = deepFreeze({
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }),
|
||||
} as unknown as SessionEvent<T>)
|
||||
this.surfaceValidator.validateNext(event as SessionEvent)
|
||||
|
||||
if (entry !== undefined) entry.appending = true
|
||||
try {
|
||||
const event = deepFreeze({
|
||||
type,
|
||||
seq: this.log.length,
|
||||
time: Date.now(),
|
||||
data: dataSnapshot,
|
||||
...surfaceMetadataSnapshot,
|
||||
} as unknown as SessionEvent<T>)
|
||||
let callbacks: SessionCallback[] | undefined
|
||||
const callbackArgs: unknown[] = [this, event]
|
||||
if (entry !== undefined) {
|
||||
|
||||
@@ -85,6 +85,18 @@ interface SurfaceFoldState {
|
||||
replaceGeneration: number
|
||||
}
|
||||
|
||||
/** A validated replacement transition that has not mutated fold state yet. */
|
||||
interface SurfaceReplacePlan extends SurfaceFoldReplacement {
|
||||
kind: 'replace'
|
||||
startIdx: number
|
||||
endIdx: number
|
||||
}
|
||||
|
||||
/** One validated surface transition that has not mutated fold state yet. */
|
||||
type SurfacePlan =
|
||||
| { kind: 'append'; seq: number }
|
||||
| SurfaceReplacePlan
|
||||
|
||||
/** Create an empty surface fold state. */
|
||||
function createFoldState(replaceGeneration = 0): SurfaceFoldState {
|
||||
return {
|
||||
@@ -94,39 +106,89 @@ function createFoldState(replaceGeneration = 0): SurfaceFoldState {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one event and return replacement metadata only when one occurred. */
|
||||
function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
if (!isSurfaceEligibleType(event.type)) return
|
||||
if (!isSurfaceEvent(event)) {
|
||||
throw new Error(`surface event "${event.type}" (seq ${event.seq}) carries no surfaceOp marker`)
|
||||
}
|
||||
/** Whether a runtime value is a non-negative safe event sequence. */
|
||||
function isEventSeq(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
if (event.surfaceOp === 'append') {
|
||||
const tail = state.nodes.length > 0 ? state.nodes[state.nodes.length - 1] : undefined
|
||||
const node: SurfaceNode = { seq: event.seq, prev: tail?.seq ?? null, next: null }
|
||||
if (tail) tail.next = event.seq
|
||||
state.nodes.push(node)
|
||||
state.nodeBySeq.set(event.seq, node)
|
||||
/** Whether a runtime value is the exact positional-replacement shape. */
|
||||
function isReplaceOp(value: object): value is Extract<SurfaceOp, { op: 'replace' }> {
|
||||
const op = value as Record<string, unknown>
|
||||
return Object.keys(op).length === 3
|
||||
&& Object.hasOwn(op, 'op')
|
||||
&& Object.hasOwn(op, 'start')
|
||||
&& Object.hasOwn(op, 'end')
|
||||
&& op['op'] === 'replace'
|
||||
&& isEventSeq(op['start'])
|
||||
&& isEventSeq(op['end'])
|
||||
}
|
||||
|
||||
/** Validate event-local surface eligibility and return its operation. */
|
||||
function surfaceOpOf(event: SessionEvent): SurfaceOp | undefined {
|
||||
const raw = event as SessionEvent & { surfaceOp?: unknown; sourceEventSeqs?: unknown }
|
||||
if (!isSurfaceEligibleType(event.type)) {
|
||||
if (raw.surfaceOp !== undefined) {
|
||||
throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry surfaceOp`)
|
||||
}
|
||||
if (raw.sourceEventSeqs !== undefined) {
|
||||
throw new Error(`session event "${event.type}" is not surface-eligible and cannot carry sourceEventSeqs`)
|
||||
}
|
||||
return
|
||||
}
|
||||
const op = raw.surfaceOp
|
||||
if (op === undefined) {
|
||||
throw new Error(`session event "${event.type}" is surface-eligible and requires a surfaceOp marker`)
|
||||
}
|
||||
if (op === 'append') return op
|
||||
if (op === null || typeof op !== 'object' || Array.isArray(op)) {
|
||||
throw new Error(`session event "${event.type}" carries an invalid surfaceOp`)
|
||||
}
|
||||
if (!isReplaceOp(op)) {
|
||||
throw new Error(`session event "${event.type}" carries an invalid replace surfaceOp`)
|
||||
}
|
||||
return op
|
||||
}
|
||||
|
||||
return {
|
||||
seq: event.seq,
|
||||
start: event.surfaceOp.start,
|
||||
end: event.surfaceOp.end,
|
||||
shadowedSeqs: replaceSurface(state, event.seq, event.surfaceOp),
|
||||
/** Validate provenance against prior log entries and the replacement range. */
|
||||
function assertProvenance(
|
||||
event: SessionEvent,
|
||||
shadowedSeqs: readonly number[],
|
||||
): void {
|
||||
const raw = (event as SessionEvent & { sourceEventSeqs?: unknown }).sourceEventSeqs
|
||||
const sources = new Set<number>()
|
||||
if (raw !== undefined) {
|
||||
if (!Array.isArray(raw)) {
|
||||
throw new Error(`sourceEventSeqs on event at seq ${event.seq} must be an array when present`)
|
||||
}
|
||||
if (raw.length === 0) {
|
||||
throw new Error('sourceEventSeqs must not be empty when present')
|
||||
}
|
||||
let nonEarlierSource: number | undefined
|
||||
for (const source of raw) {
|
||||
if (!isEventSeq(source)) {
|
||||
throw new Error(`session event "${event.type}" sourceEventSeqs must densely contain non-negative safe integers`)
|
||||
}
|
||||
sources.add(source)
|
||||
if (nonEarlierSource === undefined && source >= event.seq) nonEarlierSource = source
|
||||
}
|
||||
if (sources.size !== raw.length) {
|
||||
throw new Error('sourceEventSeqs must not contain duplicates')
|
||||
}
|
||||
if (nonEarlierSource !== undefined) {
|
||||
throw new Error(`sourceEventSeqs must reference earlier events: ${nonEarlierSource} >= current seq ${event.seq}`)
|
||||
}
|
||||
}
|
||||
const missing = shadowedSeqs.filter(seq => !sources.has(seq))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one positional replacement and return the nodes it removed. */
|
||||
function replaceSurface(
|
||||
/** Locate one replacement range without mutating the current fold state. */
|
||||
function replacementRange(
|
||||
state: SurfaceFoldState,
|
||||
newSeq: number,
|
||||
op: Extract<SurfaceOp, { op: 'replace' }>,
|
||||
): number[] {
|
||||
): Pick<SurfaceReplacePlan, 'startIdx' | 'endIdx' | 'shadowedSeqs'> {
|
||||
const startNode = state.nodeBySeq.get(op.start)
|
||||
if (!startNode) {
|
||||
throw new Error(`surface replace: start seq ${op.start} not found in surface`)
|
||||
@@ -140,6 +202,42 @@ function replaceSurface(
|
||||
if (startIdx > endIdx) {
|
||||
throw new Error(`surface replace: start seq ${op.start} (index ${startIdx}) is after end seq ${op.end} (index ${endIdx})`)
|
||||
}
|
||||
return {
|
||||
startIdx,
|
||||
endIdx,
|
||||
shadowedSeqs: state.nodes.slice(startIdx, endIdx + 1).map(node => node.seq),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate one event at its replay boundary and prepare its atomic fold transition. */
|
||||
function planSurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
): SurfacePlan | undefined {
|
||||
if (event.seq !== expectedSeq) {
|
||||
throw new Error(`session event seq ${event.seq} is not contiguous; expected ${expectedSeq}`)
|
||||
}
|
||||
const surfaceOp = surfaceOpOf(event)
|
||||
if (surfaceOp === undefined) return
|
||||
if (surfaceOp === 'append') {
|
||||
assertProvenance(event, [])
|
||||
return { kind: 'append', seq: event.seq }
|
||||
}
|
||||
const range = replacementRange(state, surfaceOp)
|
||||
assertProvenance(event, range.shadowedSeqs)
|
||||
return {
|
||||
kind: 'replace',
|
||||
seq: event.seq,
|
||||
start: surfaceOp.start,
|
||||
end: surfaceOp.end,
|
||||
...range,
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply one already-validated positional replacement. */
|
||||
function replaceSurface(state: SurfaceFoldState, plan: SurfaceReplacePlan): void {
|
||||
const { startIdx, endIdx } = plan
|
||||
|
||||
const removed = state.nodes.splice(startIdx, endIdx - startIdx + 1)
|
||||
for (const node of removed) state.nodeBySeq.delete(node.seq)
|
||||
@@ -147,16 +245,40 @@ function replaceSurface(
|
||||
const prevNode = startIdx > 0 ? state.nodes[startIdx - 1] : undefined
|
||||
const nextNode = startIdx < state.nodes.length ? state.nodes[startIdx] : undefined
|
||||
const newNode: SurfaceNode = {
|
||||
seq: newSeq,
|
||||
seq: plan.seq,
|
||||
prev: prevNode?.seq ?? null,
|
||||
next: nextNode?.seq ?? null,
|
||||
}
|
||||
if (prevNode) prevNode.next = newSeq
|
||||
if (nextNode) nextNode.prev = newSeq
|
||||
if (prevNode) prevNode.next = plan.seq
|
||||
if (nextNode) nextNode.prev = plan.seq
|
||||
state.nodes.splice(startIdx, 0, newNode)
|
||||
state.nodeBySeq.set(newSeq, newNode)
|
||||
state.nodeBySeq.set(plan.seq, newNode)
|
||||
state.replaceGeneration += 1
|
||||
return removed.map(node => node.seq)
|
||||
}
|
||||
|
||||
/** Apply one event and return replacement metadata only when one occurred. */
|
||||
function applySurfaceEvent(
|
||||
state: SurfaceFoldState,
|
||||
event: SessionEvent,
|
||||
expectedSeq: number,
|
||||
): SurfaceFoldReplacement | undefined {
|
||||
const plan = planSurfaceEvent(state, event, expectedSeq)
|
||||
if (plan?.kind === 'append') {
|
||||
const tail = state.nodes.at(-1)
|
||||
const node: SurfaceNode = { seq: plan.seq, prev: tail?.seq ?? null, next: null }
|
||||
if (tail) tail.next = plan.seq
|
||||
state.nodes.push(node)
|
||||
state.nodeBySeq.set(plan.seq, node)
|
||||
} else if (plan?.kind === 'replace') {
|
||||
replaceSurface(state, plan)
|
||||
}
|
||||
if (plan?.kind !== 'replace') return
|
||||
return {
|
||||
seq: plan.seq,
|
||||
start: plan.start,
|
||||
end: plan.end,
|
||||
shadowedSeqs: plan.shadowedSeqs,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,14 +289,16 @@ function replaceSurface(
|
||||
* models cannot disagree with `deriveMessages()` about replacement ranges.
|
||||
* @param events - session events in contiguous seq order.
|
||||
* @returns the current surface and every positional replacement.
|
||||
* @throws when a surface-eligible event lacks its mandatory `surfaceOp`, or a
|
||||
* replacement names nodes that are absent or reversed on the current surface.
|
||||
* @throws when any event violates the unified surface contract: metadata must
|
||||
* be well shaped and type-eligible, event seqs must be contiguous, provenance
|
||||
* must name unique earlier events, and a positional replacement must name and
|
||||
* cite its complete range.
|
||||
*/
|
||||
export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult {
|
||||
const state = createFoldState()
|
||||
const replacements: SurfaceFoldReplacement[] = []
|
||||
for (const event of events) {
|
||||
const replacement = applySurfaceEvent(state, event)
|
||||
for (const [index, event] of events.entries()) {
|
||||
const replacement = applySurfaceEvent(state, event, index)
|
||||
if (replacement !== undefined) replacements.push(replacement)
|
||||
}
|
||||
return {
|
||||
@@ -184,11 +308,10 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult
|
||||
}
|
||||
|
||||
/**
|
||||
* Maintains a cached linked list of surface nodes, rebuilt lazily from
|
||||
* `surfaceOp` markers in the event log. Because the log is append-only, it
|
||||
* processes only the delta since the last rebuild — new events are folded
|
||||
* into the existing surface in O(new events) rather than rescanning the
|
||||
* whole log.
|
||||
* Maintains a cached linked list of surface nodes and validates each candidate
|
||||
* before it enters the event log. Because the log is append-only, it processes
|
||||
* only committed deltas and plans the candidate without mutation rather than
|
||||
* rescanning the whole log.
|
||||
*/
|
||||
export class SurfaceManager {
|
||||
/** Incremental state shared with the complete surface fold. */
|
||||
@@ -198,6 +321,18 @@ export class SurfaceManager {
|
||||
|
||||
constructor(private log: readonly SessionEvent[]) {}
|
||||
|
||||
/**
|
||||
* Validate one candidate as the next log event without applying it. The
|
||||
* committed log is folded first, then the candidate's complete surface and
|
||||
* provenance transition is planned atomically; a failure leaves the current
|
||||
* surface unchanged.
|
||||
* @param event - candidate event that has not entered `log` yet.
|
||||
*/
|
||||
validateNext(event: SessionEvent): void {
|
||||
if (this._lastProcessedSeq < this.log.length - 1) this._processDelta()
|
||||
planSurfaceEvent(this._state, event, this.log.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* The surface's rewrite generation, bumped by every folded `replace` op.
|
||||
* A replace is the ONE operation that rewrites the
|
||||
@@ -227,8 +362,8 @@ export class SurfaceManager {
|
||||
// Index is bounded by i < this.log.length — never undefined.
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const event = this.log[i]!
|
||||
applySurfaceEvent(this._state, event)
|
||||
applySurfaceEvent(this._state, event, i)
|
||||
this._lastProcessedSeq = i
|
||||
}
|
||||
this._lastProcessedSeq = this.log.length - 1
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,35 +317,52 @@ describe('Session', () => {
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
}, {
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp,
|
||||
sourceEventSeqs: [0],
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
const session = new Session(SessionId('seed-unstable-metadata'), seed)
|
||||
const event = session.events[0]!
|
||||
const event = session.events[1]!
|
||||
if (event.type !== 'user/message') throw new Error('test fixture must remain a user/message')
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
})
|
||||
|
||||
it('adds seed context when surface validation throws a non-Error value', () => {
|
||||
it.each([
|
||||
['an Error', new Error('validator failed'), 'validator failed'],
|
||||
['a non-Error value', 'validator failed', 'invalid surface metadata'],
|
||||
] as const)('adds seed context when surface validation throws %s', (_name, failure, expected) => {
|
||||
const originalHasOwn = Object.hasOwn
|
||||
const hasOwn = vi.spyOn(Object, 'hasOwn').mockImplementation((object: object, property: PropertyKey): boolean => {
|
||||
if ((object as Record<string, unknown>)['op'] === 'replace') throw 'validator failed'
|
||||
if ((object as Record<string, unknown>)['op'] === 'replace') throw failure
|
||||
return originalHasOwn(object, property)
|
||||
})
|
||||
const seed = [{
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
}, {
|
||||
type: 'user/message',
|
||||
seq: 1,
|
||||
time: 2,
|
||||
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
surfaceOp: { op: 'replace', start: 0, end: 0 },
|
||||
sourceEventSeqs: [0],
|
||||
}] as unknown as SessionEvent[]
|
||||
|
||||
try {
|
||||
expect(() => new Session(SessionId('seed-non-error-metadata-failure'), seed))
|
||||
.toThrow('invalid seed event at index 0: invalid surface metadata')
|
||||
.toThrow(`invalid seed event at index 1: ${expected}`)
|
||||
} finally {
|
||||
hasOwn.mockRestore()
|
||||
}
|
||||
@@ -431,6 +448,11 @@ describe('Session', () => {
|
||||
|
||||
it('reads a nested append-metadata getter once and stores its first JSON value', () => {
|
||||
const session = new Session(SessionId('append-unstable-metadata'))
|
||||
const source = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
|
||||
{ surfaceOp: 'append' },
|
||||
)
|
||||
let reads = 0
|
||||
const surfaceOp = Object.defineProperty({ op: 'replace', end: 0 }, 'start', {
|
||||
enumerable: true,
|
||||
@@ -443,12 +465,12 @@ describe('Session', () => {
|
||||
const event = session.append(
|
||||
'user/message',
|
||||
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
|
||||
{ surfaceOp } as never,
|
||||
{ surfaceOp, sourceEventSeqs: [0] } as never,
|
||||
)
|
||||
|
||||
expect(reads).toBe(1)
|
||||
expect(event.surfaceOp).toEqual({ op: 'replace', start: 0, end: 0 })
|
||||
expect(session.events).toEqual([event])
|
||||
expect(session.events).toEqual([source, event])
|
||||
})
|
||||
|
||||
it('rejects invalid plain surface metadata shapes at append', () => {
|
||||
@@ -484,7 +506,7 @@ describe('Session', () => {
|
||||
'turn/start',
|
||||
{ turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
{ surfaceOp: 'append' },
|
||||
)).toThrow(/not surface-eligible and cannot carry surface metadata/)
|
||||
)).toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
expect(() => new Session(SessionId('non-surface-metadata-seed'), [{
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import { Session, SessionId, foldSurface, isSurfaceEligibleType, isSurfaceEvent } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
Session,
|
||||
SessionId,
|
||||
foldSurface,
|
||||
isSurfaceEligibleType,
|
||||
isSurfaceEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Build a minimal session with turn boundaries and a single user message. */
|
||||
@@ -13,6 +19,64 @@ function surfaceSession(): Session {
|
||||
return s
|
||||
}
|
||||
|
||||
function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
|
||||
return {
|
||||
type: 'user/message',
|
||||
seq,
|
||||
time: seq,
|
||||
data: { content: [], source: { kind: 'user' } },
|
||||
surfaceOp: 'append',
|
||||
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
|
||||
} as unknown as SessionEvent
|
||||
}
|
||||
|
||||
describe('foldSurface provenance', () => {
|
||||
it('accepts absent or valid provenance and complete replacement coverage', () => {
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
provenanceEvent(1, undefined),
|
||||
{
|
||||
...provenanceEvent(2, [0, 1]),
|
||||
surfaceOp: { op: 'replace', start: 0, end: 1 },
|
||||
},
|
||||
] as SessionEvent[]
|
||||
expect(() => foldSurface(events)).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects provenance on a non-surface event', () => {
|
||||
const event = {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
sourceEventSeqs: [0],
|
||||
} as unknown as SessionEvent
|
||||
expect(() => foldSurface([event])).toThrow(/cannot carry sourceEventSeqs/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['a non-array', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: 'invalid' }], /must be an array/],
|
||||
['an empty array', [provenanceEvent(0, [])], /must not be empty/],
|
||||
['duplicates', [provenanceEvent(0, undefined), provenanceEvent(1, [0, 0])], /must not contain duplicates/],
|
||||
['a sparse array', [provenanceEvent(0, Array<number>(1))], /densely contain/],
|
||||
['a non-number', [{ ...provenanceEvent(0, undefined), sourceEventSeqs: ['0'] }], /non-negative safe integers/],
|
||||
['a fractional number', [provenanceEvent(0, [0.5])], /non-negative safe integers/],
|
||||
['a negative number', [provenanceEvent(0, [-1])], /non-negative safe integers/],
|
||||
['a self reference', [provenanceEvent(0, [0])], /must reference earlier events/],
|
||||
['a non-contiguous event seq', [provenanceEvent(0, undefined), provenanceEvent(2, [1])], /seq 2 is not contiguous; expected 1/],
|
||||
['incomplete replacement coverage', [
|
||||
provenanceEvent(0, undefined),
|
||||
provenanceEvent(1, undefined),
|
||||
{ ...provenanceEvent(2, [0]), surfaceOp: { op: 'replace', start: 0, end: 1 } },
|
||||
], /missing 1/],
|
||||
] as const)(
|
||||
'rejects %s',
|
||||
(_name, events, expected) => {
|
||||
expect(() => foldSurface(events as unknown as SessionEvent[])).toThrow(expected)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('SurfaceManager', () => {
|
||||
it('shares exact nodes and nested replacement ranges with foldSurface', () => {
|
||||
const s = new Session(SessionId('shared-fold'))
|
||||
@@ -36,7 +100,7 @@ describe('SurfaceManager', () => {
|
||||
it('does not retain fold-only replacement history in incremental state', () => {
|
||||
const s = new Session(SessionId('incremental-state'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 } })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
|
||||
|
||||
expect(s.surface.nodes).toEqual([{ seq: 1, prev: null, next: null }])
|
||||
const manager = s.surface as unknown as { _state: object }
|
||||
@@ -47,12 +111,29 @@ describe('SurfaceManager', () => {
|
||||
})
|
||||
|
||||
it('foldSurface reports the same invalid replacement failures as the incremental manager', () => {
|
||||
const s = new Session(SessionId('shared-fold-invalid'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 42, end: 0 }, sourceEventSeqs: [0] })
|
||||
const events = [
|
||||
provenanceEvent(0, undefined),
|
||||
{ ...provenanceEvent(1, [0]), surfaceOp: { op: 'replace', start: 42, end: 0 } },
|
||||
] as SessionEvent[]
|
||||
|
||||
expect(() => foldSurface(s.events)).toThrow(/start seq 42 not found/)
|
||||
expect(() => s.surface.nodes).toThrow(/start seq 42 not found/)
|
||||
expect(() => foldSurface(events)).toThrow(/start seq 42 not found/)
|
||||
expect(() => new Session(SessionId('shared-fold-invalid'), events))
|
||||
.toThrow(/start seq 42 not found/)
|
||||
})
|
||||
|
||||
it('leaves incremental state unchanged when candidate validation fails', () => {
|
||||
const s = new Session(SessionId('atomic-validation'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
|
||||
expect(() => s.append(
|
||||
'assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 0 } },
|
||||
)).toThrow(/missing 0/)
|
||||
|
||||
expect(s.events).toHaveLength(1)
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
expect(s.surface.nodes.map(node => node.seq)).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('foldSurface rejects a surface-eligible event without its mandatory marker', () => {
|
||||
@@ -64,7 +145,20 @@ describe('SurfaceManager', () => {
|
||||
}
|
||||
|
||||
expect(() => foldSurface([malformed]))
|
||||
.toThrow(/surface event "user\/message" \(seq 0\) carries no surfaceOp marker/)
|
||||
.toThrow(/surface-eligible and requires a surfaceOp marker/)
|
||||
})
|
||||
|
||||
it('foldSurface rejects surfaceOp on a non-surface event', () => {
|
||||
const malformed = {
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
surfaceOp: 'append',
|
||||
} as unknown as SessionEvent
|
||||
|
||||
expect(() => foldSurface([malformed]))
|
||||
.toThrow(/not surface-eligible and cannot carry surfaceOp/)
|
||||
})
|
||||
|
||||
it('rebuilds a linked list from surfaceOp: append markers', () => {
|
||||
@@ -161,21 +255,19 @@ describe('SurfaceManager', () => {
|
||||
it('throws when replace start is not found', () => {
|
||||
const s = new Session(SessionId('bad-start'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('assistant/message',
|
||||
expect(() => s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [5, 0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/surface replace: start seq 5 not found/)
|
||||
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] },
|
||||
)).toThrow(/surface replace: start seq 5 not found/)
|
||||
})
|
||||
|
||||
it('throws when replace end is not found', () => {
|
||||
const s = new Session(SessionId('bad-end'))
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('assistant/message',
|
||||
expect(() => s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/surface replace: end seq 99 not found/)
|
||||
)).toThrow(/surface replace: end seq 99 not found/)
|
||||
})
|
||||
|
||||
it('throws when start is after end', () => {
|
||||
@@ -183,22 +275,22 @@ describe('SurfaceManager', () => {
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
|
||||
// start=1, end=0 would be reversed order.
|
||||
s.append('assistant/message',
|
||||
expect(() => s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
|
||||
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
|
||||
)
|
||||
expect(() => s.surface.nodes).toThrow(/start seq 1.*after end seq 0/)
|
||||
)).toThrow(/start seq 1.*after end seq 0/)
|
||||
})
|
||||
|
||||
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
|
||||
const s = new Session(SessionId('immutable'))
|
||||
const sources = [10, 20]
|
||||
s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
const sources = [0]
|
||||
s.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
|
||||
// Mutate caller's array after append.
|
||||
sources.push(30)
|
||||
sources.push(1)
|
||||
sources[0] = 99
|
||||
const logged = s.events[0]! as SurfaceEvent
|
||||
expect(logged.sourceEventSeqs).toEqual([10, 20])
|
||||
const logged = s.events[1]! as SurfaceEvent
|
||||
expect(logged.sourceEventSeqs).toEqual([0])
|
||||
})
|
||||
|
||||
it('replace starting at non-head position links to previous node correctly', () => {
|
||||
@@ -280,15 +372,17 @@ describe('deriveMessages with surface', () => {
|
||||
describe('Session.append surface opts', () => {
|
||||
it('records sourceEventSeqs and surfaceOp on the event', () => {
|
||||
const s = new Session(SessionId('opts'))
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
const event = s.append('assistant/message',
|
||||
{ turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [3, 5, 7] },
|
||||
{ surfaceOp: 'append', sourceEventSeqs: [0, 1] },
|
||||
)
|
||||
expect(event.sourceEventSeqs).toEqual([3, 5, 7])
|
||||
expect(event.sourceEventSeqs).toEqual([0, 1])
|
||||
expect(event.surfaceOp).toBe('append')
|
||||
// The logged event matches the returned event.
|
||||
expect((s.events[0]! as SurfaceEvent).sourceEventSeqs).toEqual([3, 5, 7])
|
||||
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
expect((s.events[2]! as SurfaceEvent).sourceEventSeqs).toEqual([0, 1])
|
||||
expect((s.events[2]! as SurfaceEvent).surfaceOp).toBe('append')
|
||||
})
|
||||
|
||||
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
|
||||
|
||||
@@ -241,10 +241,11 @@ describe('isToolPairingBalanced — CBR-001: a head checkpoint left by a replace
|
||||
// summary user/message — appended now, so it carries a high log seq.
|
||||
const u1 = seqOf(s, 'user/message')
|
||||
const result = s.events.find(e => e.type === 'tool/result')!.seq
|
||||
const shadowedSeqs = s.surface.nodes.map(node => node.seq)
|
||||
s.append('user/message', {
|
||||
content: [{ type: 'text', text: 'CHECKPOINT' }],
|
||||
source: { kind: 'plugin', plugin: 'compact' },
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result } })
|
||||
}, { surfaceOp: { op: 'replace', start: u1, end: result }, sourceEventSeqs: shadowedSeqs })
|
||||
// The step's own assistant/message lands AFTER the checkpoint in the log,
|
||||
// still inside the open step.
|
||||
s.append('assistant/message', { turn: 2, step: 1, content: [{ type: 'text', text: 'a2' }] }, SURFACE)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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.map(node => node.seq))
|
||||
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
|
||||
|
||||
222
packages/session-query/session-query/src/tracing.ts
Normal file
222
packages/session-query/session-query/src/tracing.ts
Normal 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.map(node => node.seq))
|
||||
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) }
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -114,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))
|
||||
@@ -231,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'))
|
||||
|
||||
|
||||
426
packages/session-query/session-query/tests/tracing.spec.ts
Normal file
426
packages/session-query/session-query/tests/tracing.spec.ts
Normal 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'))
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ Runtime event-contract assertions intended for development diagnostics. This pur
|
||||
|
||||
The plugin has no environment guard: it is active wherever it is registered. The default [`dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md) bundle mounts it unconditionally; a custom composition can omit it when the runtime cost is undesirable. It doubles as executable documentation of the event taxonomy — the assertions *are* the contract.
|
||||
|
||||
Session itself owns immutable log storage in every composition: it takes one lossless JSON snapshot of each accepted event, deep-freezes that record, and exposes the log through immutable array snapshots. The invariants plugin checks the cross-record and cross-seam rules that storage immutability cannot express.
|
||||
Session itself owns immutable, surface-valid log storage in every composition: it takes one lossless JSON snapshot of each candidate, validates the complete surface transition, deep-freezes the accepted record, and exposes the log through immutable array snapshots. The invariants plugin checks the remaining cross-record and cross-seam rules that Session does not own.
|
||||
|
||||
Session-log assertions run during Cordis `internal/dispatch`, while `Session.append()` is resolving the `session/event` callback snapshot but before it pushes the candidate into the log. A valid transition is staged by exact event identity and applied to the live trace only when that same committed event reaches the plugin's contained post-commit listener. A later internal dispatch check can therefore veto without advancing either the log or the invariant trace, while ordinary `session/event` observer failures remain observe-only.
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
* turn and step nesting, scoped dispatch, status transitions, and request
|
||||
* reconstruction. The plugin has no environment guard and is active wherever
|
||||
* mounted, including the default `dsh-agent-spine-demo` bundle; custom compositions
|
||||
* may omit it. Sessions still own event snapshots and freezing.
|
||||
* may omit it. Sessions own immutable, surface-valid event storage; this plugin
|
||||
* checks only relationships that event acceptance cannot express.
|
||||
* @module @deepseek-ai/dsh-invariants
|
||||
*/
|
||||
|
||||
@@ -13,7 +14,7 @@ import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { CallId, GenerateOptions } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { scopedSubjectResolverFor } from './scoped-events.generated.ts'
|
||||
|
||||
export const name = 'invariants'
|
||||
@@ -48,15 +49,6 @@ interface SessionTrace {
|
||||
* `step/end` — a result must arrive in the same step as its call.
|
||||
*/
|
||||
pendingCalls: Set<CallId>
|
||||
/** Every seq seen so far — validates `sourceEventSeqs` references. */
|
||||
knownSeqs: Set<number>
|
||||
/**
|
||||
* The seqs currently on the surface linked list, in linked-list order
|
||||
* (head to tail). A replace reorders this relative to seq order (the new
|
||||
* node takes the replaced range's position), so range validation is
|
||||
* positional, not by seq comparison.
|
||||
*/
|
||||
surface: number[]
|
||||
}
|
||||
|
||||
/** One accepted event's deferred mutation of a live session trace. */
|
||||
@@ -68,12 +60,6 @@ interface SessionTraceTransition {
|
||||
| { kind: 'none' }
|
||||
| { kind: 'add' | 'delete'; callId: CallId }
|
||||
| { kind: 'clear' }
|
||||
/** The event's mutation of the derived surface order. */
|
||||
surface:
|
||||
| { kind: 'none' | 'append' }
|
||||
| { kind: 'replace'; start: number; count: number }
|
||||
/** The committed event sequence to add to the known-sequence set. */
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** Assert that a step-scoped event names the currently open turn and step. */
|
||||
@@ -97,73 +83,6 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
|
||||
let nextTurn = trace.nextTurn
|
||||
let nextStep = trace.nextStep
|
||||
let pendingCalls: SessionTraceTransition['pendingCalls'] = { kind: 'none' }
|
||||
let surface: SessionTraceTransition['surface'] = { kind: 'none' }
|
||||
|
||||
// --- Surface invariants ---
|
||||
// Surface metadata (sourceEventSeqs, surfaceOp) is only valid on
|
||||
// surface-eligible event types. The compiler enforces this at append()
|
||||
// call sites; this runtime check catches casts and persisted data.
|
||||
const SURFACE_TYPES = new Set<string>(['user/message', 'assistant/message', 'tool/result', 'context/message', 'steering/message'])
|
||||
// Cast to surface-eligible event type so we can access surfaceOp and
|
||||
// sourceEventSeqs (optional on SessionEvent, mandatory on SurfaceEvent).
|
||||
// SurfaceEvent's mandatory surfaceOp is too strict here — we need to
|
||||
// CHECK whether surface metadata is present, not assume it.
|
||||
const se = event as SessionEvent<SurfaceEventType>
|
||||
if (!SURFACE_TYPES.has(event.type)) {
|
||||
if (se.sourceEventSeqs !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry sourceEventSeqs (non-surface event)`)
|
||||
}
|
||||
if (se.surfaceOp !== undefined) {
|
||||
throw new InvariantError(`${event.type} cannot carry surfaceOp (non-surface event)`)
|
||||
}
|
||||
}
|
||||
if (se.sourceEventSeqs !== undefined) {
|
||||
if (se.sourceEventSeqs.length === 0) {
|
||||
throw new InvariantError('sourceEventSeqs must not be empty when present')
|
||||
}
|
||||
const unique = new Set(se.sourceEventSeqs)
|
||||
if (unique.size !== se.sourceEventSeqs.length) {
|
||||
throw new InvariantError('sourceEventSeqs must not contain duplicates')
|
||||
}
|
||||
for (const ref of se.sourceEventSeqs) {
|
||||
if (ref >= event.seq) {
|
||||
throw new InvariantError(`sourceEventSeqs must reference earlier events: ${ref} >= current seq ${event.seq}`)
|
||||
}
|
||||
if (!trace.knownSeqs.has(ref)) {
|
||||
throw new InvariantError(`sourceEventSeqs references unknown seq ${ref}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fold this event into the tracked surface linked list, validating the
|
||||
// replace contract as we go. `append` adds a tail node; `replace` shadows a
|
||||
// positional range — every shadowed node must appear in sourceEventSeqs.
|
||||
if (se.surfaceOp !== undefined) {
|
||||
if (se.surfaceOp === 'append') {
|
||||
surface = { kind: 'append' }
|
||||
} else {
|
||||
const { start, end } = se.surfaceOp
|
||||
const startIdx = trace.surface.indexOf(start)
|
||||
if (startIdx === -1) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} is not on the surface`)
|
||||
}
|
||||
const endIdx = trace.surface.indexOf(end)
|
||||
if (endIdx === -1) {
|
||||
throw new InvariantError(`surface replace: end seq ${end} is not on the surface`)
|
||||
}
|
||||
if (startIdx > endIdx) {
|
||||
throw new InvariantError(`surface replace: start seq ${start} (pos ${startIdx}) is after end seq ${end} (pos ${endIdx}) on the surface`)
|
||||
}
|
||||
// Every node the replace shadows (surface positions [startIdx, endIdx]
|
||||
// inclusive) must appear in sourceEventSeqs — the provenance contract.
|
||||
const shadowed = trace.surface.slice(startIdx, endIdx + 1)
|
||||
const recorded = new Set(se.sourceEventSeqs ?? [])
|
||||
const missing = shadowed.filter(seq => !recorded.has(seq))
|
||||
if (missing.length > 0) {
|
||||
throw new InvariantError(`surface replace: sourceEventSeqs must include every shadowed surface node; missing ${missing.join(', ')}`)
|
||||
}
|
||||
surface = { kind: 'replace', start: startIdx, count: shadowed.length }
|
||||
}
|
||||
}
|
||||
|
||||
// Boundary/step-scoped events have explicit cases; every OTHER event type —
|
||||
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
|
||||
@@ -263,8 +182,6 @@ function validateEvent(trace: SessionTrace, event: SessionEvent): SessionTraceTr
|
||||
return {
|
||||
scalars: { lastSeq: event.seq, openTurn, openStep, nextTurn, nextStep },
|
||||
pendingCalls,
|
||||
surface,
|
||||
seq: event.seq,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,20 +204,6 @@ function applyTransition(trace: SessionTrace, transition: SessionTraceTransition
|
||||
default:
|
||||
assertNever(transition.pendingCalls, 'session trace pending-call transition')
|
||||
}
|
||||
switch (transition.surface.kind) {
|
||||
case 'none':
|
||||
break
|
||||
case 'append':
|
||||
trace.surface.push(transition.seq)
|
||||
break
|
||||
case 'replace':
|
||||
trace.surface.splice(transition.surface.start, transition.surface.count, transition.seq)
|
||||
break
|
||||
/* v8 ignore next -- validateEvent produces this closed transition union */
|
||||
default:
|
||||
assertNever(transition.surface, 'session trace surface transition')
|
||||
}
|
||||
trace.knownSeqs.add(transition.seq)
|
||||
}
|
||||
|
||||
/** Validate and apply one event while rebuilding an already-committed log. */
|
||||
@@ -345,8 +248,6 @@ export function apply(ctx: Context): void {
|
||||
nextTurn: 1,
|
||||
nextStep: 1,
|
||||
pendingCalls: new Set(),
|
||||
knownSeqs: new Set(),
|
||||
surface: [],
|
||||
})
|
||||
|
||||
/** Build (or rebuild) a session's trace by replaying its whole log. */
|
||||
|
||||
@@ -464,7 +464,7 @@ describe('HMR safety', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('surface invariants', () => {
|
||||
describe('surface contract under the invariants composition', () => {
|
||||
it('accepts well-formed surface metadata', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
@@ -493,7 +493,7 @@ describe('surface invariants', () => {
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [] })
|
||||
}).toThrow(InvariantError)
|
||||
}).toThrow(/must not be empty/)
|
||||
})
|
||||
|
||||
it('rejects duplicate sourceEventSeqs', async () => {
|
||||
@@ -518,7 +518,8 @@ describe('surface invariants', () => {
|
||||
})
|
||||
|
||||
it('accepts sourceEventSeqs referencing a valid earlier event', async () => {
|
||||
// Positive test: ref < current seq and ref is in knownSeqs → passes.
|
||||
// Session seqs are contiguous, so every non-negative ref below the current
|
||||
// seq necessarily names an existing earlier event.
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -538,23 +539,6 @@ describe('surface invariants', () => {
|
||||
}).toThrow(/must reference earlier/)
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs referencing unknown seq (gap in event log)', async () => {
|
||||
// Create an impossible-through-public-API gap so seq 2 is earlier but unknown.
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
;(session as unknown as { log: unknown[] }).log.push({
|
||||
type: 'assistant/chunk',
|
||||
seq: 3,
|
||||
time: Date.now(),
|
||||
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } },
|
||||
})
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: 'append', sourceEventSeqs: [2] })
|
||||
}).toThrow(/unknown seq 2/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose start is positioned after its end on the surface', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
@@ -565,7 +549,7 @@ describe('surface invariants', () => {
|
||||
// Reversed range: start seq 3 is at a later surface position than end seq 2.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 2 }, sourceEventSeqs: [2, 3] })
|
||||
}).toThrow(/is after end seq 2 .* on the surface/)
|
||||
}).toThrow(/is after end seq 2/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose sourceEventSeqs omits a shadowed surface node', async () => {
|
||||
@@ -602,7 +586,7 @@ describe('surface invariants', () => {
|
||||
// seq 1 (step/start) is a real earlier event but never entered the surface.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] })
|
||||
}).toThrow(/start seq 1 is not on the surface/)
|
||||
}).toThrow(/start seq 1 not found in surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace naming an end seq that is not on the surface', async () => {
|
||||
@@ -614,7 +598,7 @@ describe('surface invariants', () => {
|
||||
// start (2) is on the surface but end (99) never entered it.
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 2, end: 99 }, sourceEventSeqs: [2] })
|
||||
}).toThrow(/end seq 99 is not on the surface/)
|
||||
}).toThrow(/end seq 99 not found in surface/)
|
||||
})
|
||||
|
||||
it('rejects a replace whose range is reversed in surface position after a prior replace reordered it', async () => {
|
||||
@@ -631,7 +615,7 @@ describe('surface invariants', () => {
|
||||
// reversed positionally (3 is at pos 1, 4 is at pos 0).
|
||||
expect(() => {
|
||||
session.append('assistant/message', { turn: 1, step: 1, content: [] }, { surfaceOp: { op: 'replace', start: 3, end: 4 }, sourceEventSeqs: [3, 4] }) // seq 5
|
||||
}).toThrow(/is after end seq 4 .* on the surface/)
|
||||
}).toThrow(/is after end seq 4/)
|
||||
})
|
||||
|
||||
it('accepts a replace whose start seq exceeds its end seq when the surface position order is valid', async () => {
|
||||
@@ -675,25 +659,6 @@ describe('surface invariants', () => {
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(/must include every shadowed surface node; missing 3/)
|
||||
})
|
||||
|
||||
it('rejects sourceEventSeqs on a non-surface event', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
// Session rejects this at its own acceptance boundary. Emit a hand-built
|
||||
// record to cover the listener's defensive check for alternate producers.
|
||||
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, sourceEventSeqs: [0] }
|
||||
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
|
||||
.toThrow(/cannot carry sourceEventSeqs/)
|
||||
})
|
||||
|
||||
it('rejects surfaceOp on a non-surface event', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
const event = { type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } }, surfaceOp: 'append' }
|
||||
expect(() => { ctx.emit(scopeTarget(session, undefined), 'session/event', session, event as never) })
|
||||
.toThrow(/cannot carry surfaceOp/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('request-reconstruction cross-check (llm/stream)', () => {
|
||||
|
||||
Reference in New Issue
Block a user