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)