feat(session): add cross-session references

This commit is contained in:
Yichen Jiang
2026-07-21 16:46:48 +08:00
parent 9a5c81f9e5
commit 32d786c439
81 changed files with 2837 additions and 160 deletions

View File

@@ -6,13 +6,14 @@ Exact session-history retrieval and relationship tracing through `ctx.sessionQue
- `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.
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
Persistence is optional and may mount or unmount dynamically. 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.
`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`.
`listEvents()`, `readSurface()`, and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.
`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`.

View File

@@ -15,6 +15,7 @@ import type {
SessionEventWindow,
SessionLineageTrace,
SessionRecord,
SessionSurfaceSnapshot,
} from './types.ts'
import {
SESSION_QUERY_READ_WINDOW_MAX,
@@ -74,6 +75,21 @@ export class SessionQueryService extends Service {
return tracing.eventRecords(sessionId, loaded.events)
}
/**
* Read one session's complete current model surface from one corpus observation.
* @param sessionId - live-preferred session id to read.
* @returns cloned header, current surface, and raw-log capture boundary.
* @throws when source resolution fails or the session surface is invalid.
*/
async readSurface(sessionId: SessionId): Promise<SessionSurfaceSnapshot> {
const loaded = await this._corpus.load(sessionId)
return {
session: structuredClone(loaded.header),
capturedThroughSeq: loaded.events.at(-1)?.seq ?? null,
events: tracing.currentSurfaceEvents(sessionId, loaded.events),
}
}
/**
* Trace known ancestry and descendants from one corpus observation.
* @param sessionId - logical session id to trace.

View File

@@ -1,7 +1,7 @@
/** 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 { foldSurface, isSurfaceEvent } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionId, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session'
import { SessionQueryError } from './config.ts'
import type {
SessionEventRecord,
@@ -15,6 +15,7 @@ interface EventLogAnalysis {
records: SessionEventRecord[]
replacedBy: Map<number, number>
replacedEventSeqs: Map<number, number[]>
currentSeqs: number[]
}
/**
@@ -30,6 +31,30 @@ export function eventRecords(
return analyzeEventLog(sessionId, events).records
}
/**
* Fold and return the current model surface after validating the whole log.
* @param sessionId - owner used in query diagnostics.
* @param events - detached raw event log from one corpus observation.
* @returns detached current surface events in folded order.
*/
export function currentSurfaceEvents(
sessionId: SessionId,
events: readonly SessionEvent[],
): SurfaceEvent[] {
const analysis = analyzeEventLog(sessionId, events)
return analysis.currentSeqs.map((seq) => {
const event = events[seq]
/* v8 ignore next 6 -- analyzeEventLog validated contiguous seqs and foldSurface returned only surface-event seqs. */
if (event === undefined || event.seq !== seq || !isSurfaceEvent(event)) {
throw new SessionQueryError(
`invalid session surface: current node ${seq} is not a surface event`,
'SESSION_QUERY_INVALID_SURFACE',
)
}
return structuredClone(event)
})
}
/**
* Trace one target after one canonical surface fold and whole-log validation.
* @param sessionId - owner of the event log.
@@ -184,6 +209,7 @@ function analyzeEventLog(
})),
replacedBy,
replacedEventSeqs,
currentSeqs: [...folded.nodes],
}
}

View File

@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-session-query/types
*/
import type { SessionEvent, SessionEventType, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionEventType, SessionHeader, SessionId, SurfaceEvent } from '@deepseek-ai/dsh-session'
/** Whether an event is current model context, replaced context, or raw-log-only. */
export type SessionEventSurface = 'current' | 'shadowed' | 'log-only'
@@ -20,6 +20,16 @@ export interface SessionRecord {
persisted: boolean
}
/** One atomic live-preferred observation of a session's current model surface. */
export interface SessionSurfaceSnapshot {
/** Cloned session header selected from the same corpus observation as `events`. */
session: SessionHeader
/** Highest raw-log seq included in the observation, or `null` for an empty log. */
capturedThroughSeq: number | null
/** Cloned current surface events in model-history order. */
events: SurfaceEvent[]
}
/** Lightweight metadata for one event within a logical session. */
export interface SessionEventRecord {
/** Session that owns the event. */

View File

@@ -121,6 +121,64 @@ describe('session-query exact reads', () => {
.toEqual(['shadowed', 'log-only', 'current'])
})
it('reads a detached current surface with its raw-log capture boundary', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('surface-snapshot'), { meta: { cwd: '/work' } })
const first = session.append(
'user/message',
{ content: [{ type: 'text', text: 'old' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: { type: 'text-delta', index: 0, text: 'draft' },
})
session.append(
'user/message',
{ content: [{ type: 'text', text: 'checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
{ surfaceOp: { op: 'replace', start: first.seq, end: first.seq }, sourceEventSeqs: [first.seq] },
)
const retained = session.append(
'user/message',
{ content: [{ type: 'text', text: 'retained tail' }], source: { kind: 'user' } },
{ surfaceOp: 'append' },
)
session.append(
'user/message',
{ content: [{ type: 'text', text: 'latest checkpoint' }], source: { kind: 'plugin', plugin: 'compact' } },
{ surfaceOp: { op: 'replace', start: 2, end: retained.seq }, sourceEventSeqs: [2, retained.seq] },
)
session.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'latest answer' }] },
{ surfaceOp: 'append' },
)
const snapshot = await ctx.sessionQuery.readSurface(session.id)
expect(snapshot.session).toEqual(session.header)
expect(snapshot.capturedThroughSeq).toBe(5)
expect(snapshot.events.map(event => [event.seq, event.type])).toEqual([
[4, 'user/message'],
[5, 'assistant/message'],
])
if (snapshot.events[0]?.type !== 'user/message') throw new Error('expected current user message')
snapshot.events[0].data.content = []
Object.assign(snapshot.session, { cwd: '/mutated' })
expect(session.events[4]?.type === 'user/message' && session.events[4].data.content).toHaveLength(1)
expect(session.header.cwd).toBe('/work')
})
it('returns an empty current surface with a null capture boundary', async () => {
const ctx = await liveContext()
const session = ctx.sessions.create(SessionId('empty-surface'))
await expect(ctx.sessionQuery.readSurface(session.id)).resolves.toMatchObject({
capturedThroughSeq: null,
events: [],
})
})
it('returns a bounded detached raw-event window and validates the request', async () => {
const ctx = await liveContext({ readWindowMax: 1 })
const session = ctx.sessions.create(SessionId('window'), { meta: { cwd: '/work' } })
@@ -173,8 +231,15 @@ describe('session-query exact reads', () => {
const liveRead = await ctx.sessionQuery.readEvent({ sessionId: shared.id, seq: 0 })
expect(liveRead.target.type === 'user/message' && liveRead.target.data.content[0])
.toMatchObject({ text: 'live' })
await expect(ctx.sessionQuery.readSurface(shared.id)).resolves.toMatchObject({
events: [{ data: { content: [{ text: 'live' }] } }],
})
await expect(ctx.sessionQuery.readEvent({ sessionId: durable.id, seq: 0 }))
.resolves.toMatchObject({ session: durable })
await expect(ctx.sessionQuery.readSurface(durable.id)).resolves.toMatchObject({
session: durable,
events: [{ data: { content: [{ text: 'durable' }] } }],
})
const sharedEntry = TestPersistence.entries.get(shared.id)!
sharedEntry.meta = { ...sharedEntry.meta, cwd: '/conflict' }