mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(session): metadata seam + JSON-serializability invariant
Adds the durable-session metadata seam and enforces the log's
JSON-serializability invariant at the source:
- SessionHeader / SessionSummary / SessionMeta and CreateSessionOptions in
dsh-session; Session gains a readonly `header`; SessionStore.create takes
`(id?, options?: { seed?; meta? })` (validated absolute cwd, parentSession
lineage). The injection TurnTrigger variant is added for the idle-inject
one-shot turn that a later change introduces.
- isJsonValue (new json.ts): a value round-trips through JSON losslessly —
rejects BigInt, function, symbol, undefined, non-finite numbers, sparse
arrays, circular refs, and exotic objects (Map/Set/Date/class instances).
- Session.append throws on non-JSON-serializable data, and the Session
constructor validates every seed event (isJsonValue + contiguous seq from
0), so a replay/fork seed can never build a live log no backend can
persist — the source-level guarantee a durable backend relies on.
Migrates the ~3 internal positional-seed `create(id, seed)` call sites to
`{ seed }`, and adapts the invariants tests forced by the new guard (the
bad-seq seed is now caught by the constructor; the cyclic deep-freeze test
drives via session/event since append rejects cyclic data; a direct
session/event drives the invariants seq-monotonicity check). Docs kept
backend-agnostic (the persistence packages arrive in a later PR).
This commit is contained in:
@@ -425,7 +425,7 @@ describe('agent loop', () => {
|
||||
send(agent, 'run')
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
const replayed = ctx.sessions.create('replayed', [...agent.session.events])
|
||||
const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] })
|
||||
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
|
||||
// event-by-event identity of types
|
||||
expect(replayed.events.map(e => e.type)).toEqual(
|
||||
|
||||
@@ -429,7 +429,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
|
||||
await ctx2.plugin(AgentLoop, { agents: [] })
|
||||
ctx2.llm.registerAdapter(['mock'], second)
|
||||
|
||||
const seeded = ctx2.sessions.create('forked', [...agent.session.events])
|
||||
const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] })
|
||||
const forked = new LoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
|
||||
ctx2.effect(() => forked.start())
|
||||
|
||||
|
||||
@@ -36,6 +36,16 @@ describe('session-log invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects a non-monotonic seq (replay spine)', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// Session.append enforces seq-contiguity at the source, so drive the
|
||||
// invariants seq check directly via session/event with a regressing seq.
|
||||
ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
|
||||
expect(() => { ctx.emit('session/event', session, { type: 'turn/end', seq: 0, time: 2, data: { turn: 1, reason: { kind: 'completed' } } } as never) })
|
||||
.toThrow(/seq must strictly increase/)
|
||||
})
|
||||
|
||||
it('rejects a turn/start while another turn is open', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
@@ -98,13 +108,15 @@ describe('session-log invariants', () => {
|
||||
|
||||
it('holds seeded sessions to the contract on session/created', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
// A seed whose seq is non-monotonic must be rejected when the session is
|
||||
// created (the constructor copies the seed without emitting session/event).
|
||||
// A seq-contiguous, serializable seed (so it passes Session's constructor
|
||||
// validation) that nonetheless violates turn nesting — a second turn/start
|
||||
// while the first turn is still open — must be rejected by the invariants
|
||||
// plugin when it replays the seed on session/created.
|
||||
const badSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 0, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/start' as const, seq: 1, time: 0, data: { turn: 2, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
]
|
||||
expect(() => ctx.sessions.create(undefined, badSeed)).toThrow(InvariantError)
|
||||
expect(() => ctx.sessions.create(undefined, { seed: badSeed })).toThrow(InvariantError)
|
||||
})
|
||||
|
||||
it('tracks turns per session independently', async () => {
|
||||
@@ -218,7 +230,7 @@ describe('dev-freeze', () => {
|
||||
const seed = [
|
||||
{ type: 'user/message' as const, seq: 0, time: 0, data: { content: [{ type: 'text' as const, text: 'seeded' }], source: { kind: 'user' as const } } },
|
||||
]
|
||||
const session = ctx.sessions.create(undefined, seed)
|
||||
const session = ctx.sessions.create(undefined, { seed })
|
||||
expect(Object.isFrozen(session.events[0])).toBe(true)
|
||||
})
|
||||
|
||||
@@ -240,10 +252,17 @@ describe('dev-freeze', () => {
|
||||
it('terminates on a cyclic event datum (WeakSet guard)', async () => {
|
||||
const { ctx } = await setup()
|
||||
const session = ctx.sessions.create()
|
||||
// A self-referential structure must not loop forever.
|
||||
// The deep-freeze WeakSet guard must terminate on a self-referential
|
||||
// structure rather than recursing forever. Session.append now rejects
|
||||
// non-serializable (incl. cyclic) data at the source, so drive the freeze
|
||||
// handler directly via hand-built session/events — exactly the shape the
|
||||
// invariants listener receives. Open a turn first (seq 0) so the cyclic
|
||||
// user/message (seq 1) satisfies seq-contiguity.
|
||||
ctx.emit('session/event', session, { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } } as never)
|
||||
const cyclic: Record<string, unknown> = { type: 'text', text: 'x' }
|
||||
cyclic['self'] = cyclic
|
||||
expect(() => session.append('user/message', { content: [cyclic as never], source: { kind: 'user' } })).not.toThrow()
|
||||
const event = { type: 'user/message', seq: 1, time: 1, data: { content: [cyclic], source: { kind: 'user' } } }
|
||||
expect(() => { ctx.emit('session/event', session, event as never) }).not.toThrow()
|
||||
expect(Object.isFrozen(cyclic)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.sessions.create(id?: string, seed?: SessionEvent[]): Session` Create a session. `seed` replays/forks an existing event log. Disposed with the calling fiber.
|
||||
- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader` (the store fills `version`/`id`/`createdAt`). Disposed with the calling fiber.
|
||||
- `ctx.sessions.get(id: string): Session | undefined`
|
||||
- `ctx.sessions.list(): Session[]`
|
||||
|
||||
@@ -24,9 +24,16 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
|
||||
|
||||
Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O.
|
||||
- `session.append(type, data): SessionEvent` — synchronous, never blocks on I/O. **Throws** if `data` is not losslessly JSON-serializable (BigInt, function, symbol, undefined, non-finite number, circular ref, or an exotic object like Map/Set/Date) — the event log is the durable source of truth, so this invariant is enforced at the source (exported as `isJsonValue` for backends to reuse on their replay/fork entry points).
|
||||
- `session.deriveMessages(): Message[]` — derive the LLM message history from the event log. Raw `assistant/chunk` events are skipped; `context/message` and `steering/message` render as tagged synthetic user messages.
|
||||
- `session.events`, `session.seq`, `session.id`
|
||||
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bare `Session` construction.
|
||||
|
||||
### Metadata types (`types.ts`)
|
||||
|
||||
- `SessionHeader` — immutable, written once: `{ version, id, createdAt, cwd?, parentSession? }`.
|
||||
- `SessionSummary` — mutable, updateable without touching the log: `{ updatedAt, title?, firstPrompt? }`.
|
||||
- `SessionMeta = SessionHeader & SessionSummary` — owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export these rather than own them (which would force a package cycle).
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
@@ -38,11 +45,9 @@ Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types
|
||||
|
||||
### Extension points
|
||||
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. See `examples/echo-agent/src/session-jsonl.ts` for the pattern.
|
||||
- Replay/fork: `ctx.sessions.create(id, seed)` seeds a new session with an existing event log.
|
||||
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`/`SessionSummary`/`SessionMeta`, `session.header`) is what such a backend stores beside the log.
|
||||
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log.
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Real persistence backends** (JSONL per session dir, sqlite) — future phase.
|
||||
- **Session event vocabulary review** — `TODO(review)` once the loop and a persistence plugin coexist.
|
||||
- **Session branching/tree** (pi-style entry tree) — defered unless needed beyond seed-based forking.
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from './types.ts'
|
||||
import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts'
|
||||
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader } from './types.ts'
|
||||
import { isJsonValue } from './json.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
export { isJsonValue } from './json.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
@@ -61,8 +64,35 @@ export class Session {
|
||||
/** Set by the store so appends are observable; undefined when detached. */
|
||||
onAppend: ((event: SessionEvent) => void) | undefined
|
||||
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[]) {
|
||||
if (seed) this.log = [...seed]
|
||||
/**
|
||||
* Immutable creation metadata (format version, cwd, lineage). Supplied by
|
||||
* the store via `ctx.sessions.create()`. When a `Session` is constructed
|
||||
* bare (tests, ad-hoc replay), a minimal v1 header is synthesized so
|
||||
* `session.header` is always present. Kept out of the event log — it is a
|
||||
* storage concern, not replayable conversation state.
|
||||
*/
|
||||
readonly header: SessionHeader
|
||||
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[], header?: SessionHeader) {
|
||||
if (seed) {
|
||||
// Validate the seed to the SAME invariants `append` enforces, so a
|
||||
// replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a
|
||||
// live log that no persistence backend could store: each event's `data`
|
||||
// must be JSON-serializable, and `seq` must be contiguous from 0 (the
|
||||
// `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.
|
||||
seed.forEach((event, index) => {
|
||||
if (event.seq !== index) {
|
||||
throw new Error(`seed event at index ${index} has seq ${event.seq} (expected ${index}); seed must be contiguous from 0`)
|
||||
}
|
||||
if (!isJsonValue(event.data)) {
|
||||
throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`)
|
||||
}
|
||||
})
|
||||
this.log = [...seed]
|
||||
}
|
||||
this.header = header ?? { version: 1, id, createdAt: Date.now() }
|
||||
}
|
||||
|
||||
get events(): readonly SessionEvent[] {
|
||||
@@ -77,8 +107,19 @@ export class Session {
|
||||
* Append one typed event to the log and synchronously notify observers via
|
||||
* `onAppend`. The hot path never blocks on I/O — persistence plugins buffer
|
||||
* asynchronously.
|
||||
*
|
||||
* @throws if `data` is not losslessly JSON-serializable (BigInt, function,
|
||||
* symbol, undefined, non-finite number, circular ref, or an exotic object
|
||||
* like Map/Set/Date). The event log is the durable source of truth, so this
|
||||
* invariant is enforced at the source — a bad event never enters the log,
|
||||
* keeping `session.events` always equal to what a backend can persist. The
|
||||
* throw surfaces at the buggy caller's append site, not asynchronously in a
|
||||
* backend flush.
|
||||
*/
|
||||
append<T extends SessionEventType>(type: T, data: SessionEventMap[T]): SessionEvent<T> {
|
||||
if (!isJsonValue(data)) {
|
||||
throw new Error(`session event "${type}" carries non-JSON-serializable data`)
|
||||
}
|
||||
const event = { type, seq: this.log.length, time: Date.now(), data } as SessionEvent<T>
|
||||
this.log.push(event)
|
||||
this.onAppend?.(event)
|
||||
@@ -158,15 +199,31 @@ export class SessionStore extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a session. If `seed` is provided, the session is populated with
|
||||
* a copy of those events (replay/fork). The session is a Cordis effect:
|
||||
* disposing the calling fiber stops event notification and removes the
|
||||
* session from the store.
|
||||
* Create a session. `options.seed` populates the session with a copy of
|
||||
* those events (replay/fork); `options.meta` attaches creation metadata
|
||||
* (validated absolute `cwd`, `parentSession` lineage) as the immutable
|
||||
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). The
|
||||
* session is a Cordis effect: disposing the calling fiber stops event
|
||||
* notification and removes the session from the store.
|
||||
*
|
||||
* @throws if a session with `id` already exists, or if `meta.cwd` is a
|
||||
* non-absolute path (storage backends key directories off it).
|
||||
*/
|
||||
create(id?: string, seed?: SessionEvent[]): Session {
|
||||
create(id?: string, options?: CreateSessionOptions): Session {
|
||||
const sessionId = SessionId(id ?? `session-${++this.counter}`)
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const session = new Session(sessionId, seed)
|
||||
const cwd = options?.meta?.cwd
|
||||
if (cwd !== undefined && !isAbsolute(cwd)) {
|
||||
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
|
||||
}
|
||||
const header: SessionHeader = {
|
||||
version: 1,
|
||||
id: sessionId,
|
||||
createdAt: options?.meta?.createdAt ?? Date.now(),
|
||||
...cwd !== undefined ? { cwd } : {},
|
||||
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
|
||||
}
|
||||
const session = new Session(sessionId, options?.seed, header)
|
||||
this.ctx.effect(function* (this: SessionStore) {
|
||||
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
|
||||
this.store.set(sessionId, session)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* JSON-serializability validation for session event data.
|
||||
*
|
||||
* The session event log is the durable source of truth (ADR 0003/0016): every
|
||||
* `event.data` must round-trip losslessly through JSON so any persistence
|
||||
* backend can store and reload it byte-identically. This invariant belongs to
|
||||
* the log itself — `Session.append` enforces it at the source, so a
|
||||
* non-serializable event never enters `session.events` and the live log can
|
||||
* never diverge from what a backend can persist. Backends re-use the same
|
||||
* predicate to validate their own `append(events)` entry point (replay/fork
|
||||
* paths that do not go through a live `Session`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-session/json
|
||||
*/
|
||||
|
||||
/**
|
||||
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
|
||||
* booleans, strings, plain arrays, and plain objects of such values. Rejects
|
||||
* `BigInt`, function, symbol, `undefined`, non-finite numbers (`NaN`/`Infinity`,
|
||||
* which `JSON.stringify` turns into `null`), and exotic objects (`Map`/`Set`/
|
||||
* `Date`/class instances) — anything `JSON.stringify` would drop, throw on, or
|
||||
* convert lossily. Sparse arrays are rejected too: a hole serializes to `null`,
|
||||
* so `[1, , 3]` would not round-trip. Detects circular references (which would
|
||||
* throw) and reports them as non-serializable rather than propagating the throw.
|
||||
*/
|
||||
export function isJsonValue(value: unknown, seen: Set<object> = new Set()): boolean {
|
||||
if (value === null) return true
|
||||
switch (typeof value) {
|
||||
case 'boolean':
|
||||
case 'string':
|
||||
return true
|
||||
case 'number':
|
||||
return Number.isFinite(value)
|
||||
case 'bigint':
|
||||
case 'function':
|
||||
case 'symbol':
|
||||
case 'undefined':
|
||||
return false
|
||||
case 'object':
|
||||
break // handled below
|
||||
}
|
||||
// object
|
||||
if (seen.has(value)) return false // circular
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
||||
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
||||
// lossily. Require every index 0..length-1 to be an OWN property.
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, i)) return false
|
||||
if (!isJsonValue(value[i], seen)) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Plain object only (reject Map/Set/Date/class instances).
|
||||
const proto = Object.getPrototypeOf(value) as unknown
|
||||
if (proto !== Object.prototype && proto !== null) return false
|
||||
return Object.values(value).every(v => isJsonValue(v, seen))
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,68 @@ export function SessionId(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Immutable session metadata — written once at creation and never rewritten.
|
||||
*
|
||||
* Kept SEPARATE from the event log deliberately: format-version, cwd, and
|
||||
* lineage are storage concerns, not conversation events, so they stay out of
|
||||
* {@link SessionEventMap} and never reach `deriveMessages()`. Every reference
|
||||
* system (pi's `version: 3` header, Codex's `SessionMeta`, Claude Code's tail
|
||||
* metadata) writes such a header.
|
||||
*/
|
||||
export interface SessionHeader {
|
||||
/** On-disk format version; a persistence backend rejects unknown versions. */
|
||||
version: number
|
||||
/** The session's id (mirrors the {@link Session}'s id). */
|
||||
id: SessionId
|
||||
/** Unix epoch milliseconds when the session was created. */
|
||||
createdAt: number
|
||||
/** Absolute working directory the session was created in (if any). */
|
||||
cwd?: string
|
||||
/** The session this one was forked from (seed lineage), if any. */
|
||||
parentSession?: SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable session metadata — updateable without touching the append-only log.
|
||||
* A persistence backend stores this beside the log (a sidecar file, a header
|
||||
* row) and rewrites only it on update.
|
||||
*/
|
||||
export interface SessionSummary {
|
||||
/** Unix epoch milliseconds of the last mutation (event append or update). */
|
||||
updatedAt: number
|
||||
/** Human-facing title (derived/edited), if any. */
|
||||
title?: string
|
||||
/** The first user prompt, cached for listing previews. */
|
||||
firstPrompt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Full session metadata: the immutable {@link SessionHeader} merged with the
|
||||
* mutable {@link SessionSummary}. Owned here in `dsh-session` (beside
|
||||
* {@link SessionId}) because `Session.header` is typed by it; the persistence
|
||||
* package imports/re-exports these rather than owning them, which would force
|
||||
* a package cycle.
|
||||
*/
|
||||
export type SessionMeta = SessionHeader & SessionSummary
|
||||
|
||||
/**
|
||||
* Options for creating a {@link Session} via the store. `seed` replays/forks
|
||||
* an existing event log; `meta` carries the caller-supplied storage fields the
|
||||
* store folds into a {@link SessionHeader}.
|
||||
*/
|
||||
export interface CreateSessionOptions {
|
||||
/** Events to seed the new session with (replay/fork). */
|
||||
seed?: SessionEvent[]
|
||||
/**
|
||||
* Creation metadata. The store fills in `version`/`id` and defaults
|
||||
* `createdAt` to now; the caller supplies the storage-level fields (validated
|
||||
* absolute `cwd`, `parentSession` lineage, and — when reconstructing a
|
||||
* persisted session — the original `createdAt` to preserve it).
|
||||
*/
|
||||
meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number }
|
||||
}
|
||||
|
||||
/**
|
||||
* What started a turn.
|
||||
* Merge-extensible sum type (same pattern as MessageSourceMap).
|
||||
@@ -15,6 +77,15 @@ export function SessionId(id: string): SessionId {
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
continuation: { kind: 'continuation' }
|
||||
/**
|
||||
* An out-of-band context injection (`agent.inject()`) made while the agent
|
||||
* was idle. The loop wraps the injected `context/message` in a one-shot turn
|
||||
* (`turn/start` → `context/message` → `turn/end`) so every event in the log
|
||||
* stays turn-enclosed — the durability/replay boundary is the turn, and a
|
||||
* bare event between turns would otherwise be indistinguishable from a crash
|
||||
* tail on reload.
|
||||
*/
|
||||
injection: { kind: 'injection'; source: MessageSource }
|
||||
}
|
||||
|
||||
export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
@@ -41,8 +112,15 @@ export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
|
||||
* Merge-extensible: plugins declare extra event types via declaration merging
|
||||
* (e.g. a compaction plugin adds `'compaction/marker'`).
|
||||
*
|
||||
* TODO(review): this vocabulary needs careful review once the loop and the
|
||||
* first persistence plugin exist side by side.
|
||||
* Durability contract (what a persistence backend relies on): the durable log
|
||||
* persists every event verbatim, INCLUDING `assistant/chunk` — `seq` must stay
|
||||
* contiguous (`seq = log.length`), so chunks cannot be filtered out of the
|
||||
* canonical log. All `event.data` must be JSON-serializable — `Session.append`
|
||||
* (and the seed path in the constructor) enforces this at the source (throwing
|
||||
* on non-serializable data), so a bad event never enters the log and
|
||||
* `session.events` always equals what a backend can persist. Adding a new event
|
||||
* type that carries non-serializable data, or that breaks the turn/step nesting
|
||||
* the invariants plugin checks, is a breaking change to the on-disk format.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
|
||||
@@ -79,8 +79,69 @@ describe('Session', () => {
|
||||
// And a fresh derivation still reflects the original content.
|
||||
expect(session.deriveMessages()[0]!.content).toEqual([{ type: 'text', text: 'original' }])
|
||||
})
|
||||
|
||||
it('rejects non-JSON-serializable event data at the source (incl. sparse arrays)', () => {
|
||||
const session = new Session(SessionId('s5'))
|
||||
const bad = (extra: unknown) => () => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } as never)
|
||||
expect(bad(1n)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(() => 0)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(Symbol('s'))).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(new Map())).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(undefined)).toThrow(/non-JSON-serializable/)
|
||||
expect(bad(Infinity)).toThrow(/non-JSON-serializable/)
|
||||
// A sparse array: `every` skips the hole but JSON.stringify writes it null.
|
||||
// Build the hole without a sparse literal or `delete` (both linted).
|
||||
const sparse: unknown[] = Array(3)
|
||||
sparse[0] = 1
|
||||
sparse[2] = 3 // index 1 stays a hole
|
||||
expect(bad(sparse)).toThrow(/non-JSON-serializable/)
|
||||
// A DENSE array carrying a non-serializable element is rejected too.
|
||||
expect(bad([1, 2n, 3])).toThrow(/non-JSON-serializable/)
|
||||
// A nested non-serializable value (inside a plain object) is rejected.
|
||||
expect(bad({ nested: { deep: () => 0 } })).toThrow(/non-JSON-serializable/)
|
||||
// A circular reference is rejected (the seen-set guard, not a stack blow-up).
|
||||
const cyclic: Record<string, unknown> = { a: 1 }
|
||||
cyclic['self'] = cyclic
|
||||
expect(bad(cyclic)).toThrow(/non-JSON-serializable/)
|
||||
// The rejected appends never entered the log.
|
||||
expect(session.events).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts dense arrays and nested plain objects', () => {
|
||||
const session = new Session(SessionId('s6'))
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: [1, 2, [3, { a: null, b: true }]] } as never)).not.toThrow()
|
||||
expect(session.events).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a non-JSON-serializable seed', () => {
|
||||
// A replay/fork seed must satisfy the SAME invariant as Session.append, or
|
||||
// it builds a live log no backend can persist.
|
||||
const badSeed = [
|
||||
{ type: 'user/message' as const, seq: 0, time: 1, data: { content: [{ type: 'text' as const, text: 'x' }], source: { kind: 'user' as const }, bad: 1n } },
|
||||
] as unknown as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-bad'), badSeed)).toThrow(/non-JSON-serializable/)
|
||||
})
|
||||
|
||||
it('validates seed events: rejects a non-contiguous seq', () => {
|
||||
const gapSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'turn/end' as const, seq: 5, time: 2, data: { turn: 1, reason: { kind: 'completed' as const } } }, // gap: expected seq 1
|
||||
] as SessionEvent[]
|
||||
expect(() => new Session(SessionId('seed-gap'), gapSeed)).toThrow(/contiguous|seq/)
|
||||
})
|
||||
|
||||
it('accepts a well-formed contiguous serializable seed', () => {
|
||||
const goodSeed = [
|
||||
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
|
||||
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
|
||||
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
|
||||
] as SessionEvent[]
|
||||
const session = new Session(SessionId('seed-ok'), goodSeed)
|
||||
expect(session.events).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('SessionStore', () => {
|
||||
it('creates sessions, emits session/created and session/event', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -110,10 +171,49 @@ describe('SessionStore', () => {
|
||||
expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
|
||||
|
||||
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
const forked = ctx.sessions.create('fork', [...a.events])
|
||||
const forked = ctx.sessions.create('fork', { seed: [...a.events] })
|
||||
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
|
||||
})
|
||||
|
||||
it('synthesizes a minimal v1 header for a bare-created session', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create('plain')
|
||||
expect(session.header).toMatchObject({ version: 1, id: 'plain' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
expect(session.header.cwd).toBeUndefined()
|
||||
expect(session.header.parentSession).toBeUndefined()
|
||||
})
|
||||
|
||||
it('attaches cwd and parentSession from meta to the header', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create('child', {
|
||||
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
|
||||
})
|
||||
expect(session.header).toMatchObject({
|
||||
version: 1,
|
||||
id: 'child',
|
||||
cwd: '/work/project',
|
||||
parentSession: 'parent',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a non-absolute meta.cwd', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
|
||||
.toThrow(/cwd must be an absolute path/)
|
||||
// the rejected session was not registered
|
||||
expect(ctx.sessions.get('rel')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a bare Session() constructed without the store still exposes a v1 header', () => {
|
||||
const session = new Session(SessionId('bare'))
|
||||
expect(session.header).toMatchObject({ version: 1, id: 'bare' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
})
|
||||
|
||||
it('detaches sessions when the creating fiber is disposed (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
|
||||
Reference in New Issue
Block a user