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:
Tianyi Cui
2026-06-15 17:54:55 +08:00
parent 2df41ee1d3
commit 0731ed374b
8 changed files with 349 additions and 27 deletions
+1 -1
View File
@@ -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())
+26 -7
View File
@@ -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)
})
})
+11 -6
View File
@@ -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.
+66 -9
View File
@@ -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)
+63
View File
@@ -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)
}
}
+80 -2
View File
@@ -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 }
+101 -1
View File
@@ -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)