From 0731ed374b942e471e541694393efa5e12244a7e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:54:55 +0800 Subject: [PATCH 1/3] feat(session): metadata seam + JSON-serializability invariant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- packages/agent-loop/tests/loop.spec.ts | 2 +- .../agent-loop/tests/review-fixes.spec.ts | 2 +- packages/invariants/tests/invariants.spec.ts | 33 ++++-- packages/session/README.md | 17 +-- packages/session/src/index.ts | 75 +++++++++++-- packages/session/src/json.ts | 63 +++++++++++ packages/session/src/types.ts | 82 +++++++++++++- packages/session/tests/session.spec.ts | 102 +++++++++++++++++- 8 files changed, 349 insertions(+), 27 deletions(-) create mode 100644 packages/session/src/json.ts diff --git a/packages/agent-loop/tests/loop.spec.ts b/packages/agent-loop/tests/loop.spec.ts index f11bfb43cd..1b47fd8706 100644 --- a/packages/agent-loop/tests/loop.spec.ts +++ b/packages/agent-loop/tests/loop.spec.ts @@ -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( diff --git a/packages/agent-loop/tests/review-fixes.spec.ts b/packages/agent-loop/tests/review-fixes.spec.ts index 71c4d7c649..707da04366 100644 --- a/packages/agent-loop/tests/review-fixes.spec.ts +++ b/packages/agent-loop/tests/review-fixes.spec.ts @@ -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()) diff --git a/packages/invariants/tests/invariants.spec.ts b/packages/invariants/tests/invariants.spec.ts index 0a621434ab..bbf93f44dc 100644 --- a/packages/invariants/tests/invariants.spec.ts +++ b/packages/invariants/tests/invariants.spec.ts @@ -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 = { 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) }) }) diff --git a/packages/session/README.md b/packages/session/README.md index 04c4653dac..705e3d5b39 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -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. diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index f7e019ddcb..62cce89c7b 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -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(type: T, data: SessionEventMap[T]): SessionEvent { + 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 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) diff --git a/packages/session/src/json.ts b/packages/session/src/json.ts new file mode 100644 index 0000000000..ebb0c00c64 --- /dev/null +++ b/packages/session/src/json.ts @@ -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 = 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) + } +} diff --git a/packages/session/src/types.ts b/packages/session/src/types.ts index c187fd5cd4..197e251b59 100644 --- a/packages/session/src/types.ts +++ b/packages/session/src/types.ts @@ -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 } diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts index 42e4fa6ec9..39d29872ee 100644 --- a/packages/session/tests/session.spec.ts +++ b/packages/session/tests/session.spec.ts @@ -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 = { 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) From 5299e43bed538d28e856720dadedb8af1711870a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:33:00 +0800 Subject: [PATCH 2/3] fix(session): snapshot seed + appended data at the boundary (review #31) The source-level JSON-serializability invariant was only a preflight: the Session constructor copied the seed array but shared every event/data object with the caller, and append() stored the caller's `data` reference verbatim. A post-create/post-append mutation could rewrite the durable log or reintroduce a non-JSON-serializable value AFTER validation, so session.events could diverge from what was validated / what a backend can persist. - ctor deep-clones each seed event after validation (not just the array). - append() stores structuredClone(data) (serializability already checked, so the clone is safe); the returned event carries the same snapshot. Regression tests: mutating the original seed / the passed append object after the call leaves session.events unchanged. Adapted the dev-freeze invariants test to assert on the logged clone (append no longer freezes the caller's input). Documented isJsonValue's exact scope (own enumerable string keys, matching JSON.stringify) and synced the README create() signature with meta.createdAt. --- packages/invariants/tests/invariants.spec.ts | 11 ++++--- packages/session/README.md | 2 +- packages/session/src/index.ts | 21 +++++++++++-- packages/session/src/json.ts | 8 +++++ packages/session/tests/session.spec.ts | 33 ++++++++++++++++++++ 5 files changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/invariants/tests/invariants.spec.ts b/packages/invariants/tests/invariants.spec.ts index bbf93f44dc..a2e205457f 100644 --- a/packages/invariants/tests/invariants.spec.ts +++ b/packages/invariants/tests/invariants.spec.ts @@ -241,12 +241,15 @@ describe('dev-freeze', () => { // mutable. deepFreeze must descend into the already-frozen object and // freeze the descendant, not short-circuit on the frozen container — // otherwise dev-mode misses exactly the history mutation ADR 0012 catches. + // `append` snapshots `data`, so the freeze applies to the LOGGED clone, not + // the caller's input — read the event back and assert on its data. const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }] const block = Object.freeze({ type: 'tool-result' as const, toolCallId: CallId('c1'), content: innerContent, isError: false }) - session.append('user/message', { content: [block], source: { kind: 'user' } }) - expect(Object.isFrozen(block.content)).toBe(true) - expect(Object.isFrozen(block.content[0])).toBe(true) - expect(() => { block.content.push({ type: 'text', text: 'mutation' }) }).toThrow() + const event = session.append('user/message', { content: [block], source: { kind: 'user' } }) + const logged = event.data.content[0] as { content: { type: 'text'; text: string }[] } + expect(Object.isFrozen(logged.content)).toBe(true) + expect(Object.isFrozen(logged.content[0])).toBe(true) + expect(() => { logged.content.push({ type: 'text', text: 'mutation' }) }).toThrow() }) it('terminates on a cyclic event datum (WeakSet guard)', async () => { diff --git a/packages/session/README.md b/packages/session/README.md index 705e3d5b39..7cf443fa71 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -8,7 +8,7 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall ### Public API -- `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.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): 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` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber. - `ctx.sessions.get(id: string): Session | undefined` - `ctx.sessions.list(): Session[]` diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 62cce89c7b..8fe54cb97b 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -90,7 +90,15 @@ export class Session { throw new Error(`seed event "${event.type}" (seq ${event.seq}) carries non-JSON-serializable data`) } }) - this.log = [...seed] + // Deep-clone each seed event, NOT just the array: the seed events and + // their `data` are still owned by the caller (or the source session of a + // fork), so keeping the references would let a post-create mutation of the + // original rewrite this session's durable log — or reintroduce a + // non-JSON-serializable value AFTER the validation above. Snapshotting at + // the boundary makes `session.events` independent and keeps it equal to + // what was validated. Serializability is guaranteed by the check above, so + // structuredClone can never hit a non-cloneable value here. + this.log = seed.map(event => structuredClone(event)) } this.header = header ?? { version: 1, id, createdAt: Date.now() } } @@ -120,7 +128,16 @@ export class Session { 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 + // Snapshot `data` into the log, NOT the caller's reference: the validation + // above proves it is JSON-serializable AT THIS MOMENT, but the caller still + // owns the object and could mutate it afterwards (before a persistence + // flush, or permanently in the in-memory history) — making `session.events` + // diverge from the value that passed validation, or reintroducing a + // non-serializable value. Cloning here keeps the log equal to what was + // validated. structuredClone is safe because serializability was just + // checked. The returned event carries the SAME snapshot, so a caller reading + // back `event.data` sees the logged value, not its own mutable input. + const event = { type, seq: this.log.length, time: Date.now(), data: structuredClone(data) } as SessionEvent this.log.push(event) this.onAppend?.(event) return event diff --git a/packages/session/src/json.ts b/packages/session/src/json.ts index ebb0c00c64..99fe80ea80 100644 --- a/packages/session/src/json.ts +++ b/packages/session/src/json.ts @@ -22,6 +22,14 @@ * 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. + * + * Scope — matches `JSON.stringify` exactly: only an object's OWN ENUMERABLE + * STRING-keyed properties are inspected (`Object.values`). Symbol-keyed and + * non-enumerable properties are NOT examined, because `JSON.stringify` likewise + * drops them — they never reach the durable form, so a non-serializable value + * hiding under a symbol/non-enumerable key cannot make the round-trip lossy. + * Getters are invoked during the check (again as `JSON.stringify` would), so the + * contract is for plain data records, not objects with side-effecting accessors. */ export function isJsonValue(value: unknown, seen: Set = new Set()): boolean { if (value === null) return true diff --git a/packages/session/tests/session.spec.ts b/packages/session/tests/session.spec.ts index 39d29872ee..50dc9d5907 100644 --- a/packages/session/tests/session.spec.ts +++ b/packages/session/tests/session.spec.ts @@ -139,6 +139,39 @@ describe('Session', () => { const session = new Session(SessionId('seed-ok'), goodSeed) expect(session.events).toHaveLength(3) }) + + it('snapshots the seed: mutating the original after construction does not affect session.events', () => { + const seed = [ + { 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: 'original' }], 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-snapshot'), seed) + // Mutate the ORIGINAL seed objects after construction: a shared reference + // would let this rewrite the forked log (or reintroduce non-serializable + // data past validation). The snapshot must shield session.events. + const um = seed[1]! + ;(um.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED' + ;(um.data as Record)['injected'] = 1n // would have failed validation + const logged = session.events[1]! + expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') + expect((logged.data as Record)['injected']).toBeUndefined() + }) + + it('snapshots append data: mutating the passed object after append does not affect session.events', () => { + const session = new Session(SessionId('append-snapshot')) + const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } } + const event = session.append('user/message', data) + // Mutate the caller's object after append returns. A shared reference would + // make session.events diverge from the value that passed validation. + data.content[0]!.text = 'HACKED' + ;(data as Record)['injected'] = 1n + const logged = session.events[0]! + expect(logged.type === 'user/message' && (logged.data.content[0] as { text: string }).text).toBe('original') + expect((logged.data as Record)['injected']).toBeUndefined() + // The returned event carries the same snapshot, not the caller's input. + expect((event.data.content[0] as { text: string }).text).toBe('original') + }) }) From 3a6ebd1954b803e2196b2dbd4514e4767ed6bdb3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:51:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?docs(session):=20renumber=20session-persist?= =?UTF-8?q?ence=20ADR=20reference=200016=E2=86=920018?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master's pnpm migration claimed ADR 0016 (0016-pnpm-over-yarn), which collides with this stack's session-persistence ADR. Renumbering the session-persistence ADR to 0018 (turn-enclosure stays 0017); update the json.ts module-doc reference accordingly. --- packages/session/src/json.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/session/src/json.ts b/packages/session/src/json.ts index 99fe80ea80..e226becee8 100644 --- a/packages/session/src/json.ts +++ b/packages/session/src/json.ts @@ -1,7 +1,7 @@ /** * JSON-serializability validation for session event data. * - * The session event log is the durable source of truth (ADR 0003/0016): every + * The session event log is the durable source of truth (ADR 0003/0018): 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