Files
deepseek-harness/packages/session-persistence-jsonl/tests/jsonl.spec.ts
T
Tianyi Cui 815bac7de9 refactor(session): drop the dead mutable SessionSummary
SessionSummary (updatedAt/title/firstPrompt) and SessionPersistence.update()
were dead state: zero production callers of update(), no production reader of
updatedAt/firstPrompt, and ACP's title comes from a tool-call presenter, not
storage. The live Session.header was already typed SessionHeader, so the
summary only ever existed in the persistence layer, written and read by nothing
but its own contract test.

Delete it entirely (no SessionMeta alias — SessionMeta collapses to
SessionHeader everywhere). This removes the JSONL .summary.json sidecar
machinery, the SQLite title/first_prompt/updated_at columns and per-append
updated_at bump, and the update() method from the abstract service and both
backends. SQLite SCHEMA_VERSION goes 1->2 and openDatabase now rejects any
non-current user_version (older or newer) — no migration, unreleased software.

Net -400 lines, and it erases the JSONL-sidecar-vs-SQLite-column durability
divergence that the upcoming write coordinator would otherwise have to model.

Records the decision in docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md
and migrates the 2026-06-14 session-persistence RFC's facts to current truth.
Adds a standalone AGENTS.md section "Tests document behavior, not golden truth"
(a passing test pins current behavior, not necessarily correct behavior) with
the summary-drop as its worked example, and reinforces the no-migration
pre-release stance.
2026-06-20 01:03:57 +08:00

1149 lines
59 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import { encodeSegment, logPath, scanLog, sessionDir } from '../src/format.ts'
import { runPersistenceContract, meta, oneTurnLog } from '../../session-persistence/tests/contract.ts'
let root: string
const dirs: string[] = []
async function freshRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
dirs.push(dir)
return dir
}
afterEach(async () => {
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
// Run the shared backend contract against the real JSONL backend.
runPersistenceContract('jsonl', async () => {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir })
return {
persistence: ctx.sessionPersistence,
dispose: async () => {
await fiber.dispose()
await rm(dir, { recursive: true, force: true })
},
}
})
describe('SessionPersistenceJsonl: format helpers', () => {
it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
expect(encodeSegment('..')).toBe('~002E~002E')
expect(encodeSegment('.')).toBe('~002E')
expect(encodeSegment('a/b')).toBe('a~002Fb')
expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd')
expect(encodeSegment('a\u0000b')).toBe('a~0000b')
expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through
expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped
})
it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => {
// Distinct lone surrogates must NOT collide (Buffer.from would normalize
// both to U+FFFD; code-unit escaping keeps them distinct).
const hi = encodeSegment(String.fromCharCode(0xD800))
const lo = encodeSegment(String.fromCharCode(0xDC00))
expect(hi).toBe('~D800')
expect(lo).toBe('~DC00')
expect(hi).not.toBe(lo)
// A literal "~002F" input cannot collide with the encoding of "/".
expect(encodeSegment('~002F')).not.toBe(encodeSegment('/'))
})
it('encodeSegment rejects an empty id', () => {
expect(() => encodeSegment('')).toThrow(/empty/)
})
})
describe('SessionPersistenceJsonl: durability and crash semantics', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
})
afterEach(async () => { await ctx.fiber.dispose() })
it('lazy materialization: create() writes no file until the first append', async () => {
const m = meta('lazy', '/work')
await ctx.sessionPersistence.create(m)
// nothing on disk yet
const dir = sessionDir(root, '/work')
await expect(stat(logPath(root, '/work', m.id))).rejects.toThrow()
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
// now materialized
expect((await stat(logPath(root, '/work', m.id))).isFile()).toBe(true)
expect(await ctx.sessionPersistence.has(m.id)).toBe(true)
void dir
})
it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
const m = meta('chunks')
const log: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
{ type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
{ type: 'assistant/message', seq: 4, time: 5, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'hello' }] } },
{ type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
]
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, log)
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
})
it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
const m = meta('crash', '/proj')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5, turn/end at 5
// Simulate a crash mid-second-turn: append raw lines that are NOT closed by
// a turn/end (turn/start + step/start are fully written), plus a final
// partial line with no newline (a torn fragment never fully flushed).
const path = logPath(root, '/proj', m.id)
await writeFile(path, [
JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
'{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
].join('\n'), { flag: 'a' })
// load PRESERVES the interrupted turn's real events (turn/start 6, step/start
// 7) — a turn can be huge, so they must not be truncated — and durably closes
// the orphaned turn with synthetic step/end (8) + turn/end {interrupted} (9).
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
const last = loaded.events.at(-1)!
expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
const stepEnd = loaded.events[8]!
expect(stepEnd.type).toBe('step/end')
// the torn seq-8 chunk fragment did not survive
expect(loaded.events.some(e => e.type === 'assistant/chunk' && e.seq === 8)).toBe(false)
// The next append continues at seq 10 (the balanced length).
const turn3 = [
{ type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
] as SessionEvent[]
await ctx.sessionPersistence.append(m.id, turn3)
const reloaded = await ctx.sessionPersistence.load(m.id)
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
})
it('committed events are never rewritten: only the crash tail is repaired', async () => {
const m = meta('append-only')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const before = await readFile(logPath(root, undefined, m.id), 'utf8')
const committedPrefix = before // the whole committed log
// A crash tail then a repair-append.
await writeFile(logPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
await ctx.sessionPersistence.load(m.id)
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[])
const after = await readFile(logPath(root, undefined, m.id), 'utf8')
// the committed prefix is byte-for-byte intact at the head of the file
expect(after.startsWith(committedPrefix)).toBe(true)
})
it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => {
const m = meta('truncate-retry')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5
const sizeBefore = (await stat(logPath(root, undefined, m.id))).size
// Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
// has already put bytes on disk — simulating an ENOSPC/fsync error
// mid-append. The recovery truncate() also fsyncs, so allow that one.
const handle = await (await import('node:fs/promises')).open(logPath(root, undefined, m.id), 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
let failed = false
const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') }
return realSync.call(this)
})
const turn2 = [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[]
// The append rejects, but the partial bytes are truncated back: the file is
// its pre-append size and the cursor is unchanged.
await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/)
expect((await stat(logPath(root, undefined, m.id))).size).toBe(sizeBefore)
spy.mockRestore()
// The retry now succeeds with NO seq gap — the log is contiguous 0..7.
await ctx.sessionPersistence.append(m.id, turn2)
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('append snapshots its batch: mutating the caller array after the call is ignored', async () => {
const m = meta('snapshot')
await ctx.sessionPersistence.create(m)
const events = oneTurnLog() // seqs 0..5
const p = ctx.sessionPersistence.append(m.id, events)
// Mutate the caller's array immediately after calling append (before the
// queued op runs). The backend must persist the snapshot taken at call time,
// not the mutated array.
events.push({ type: 'turn/start', seq: 6, time: 99, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } })
await p
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5]) // not 0..6
})
it('append deep-snapshots event objects: mutating an event after the call is ignored', async () => {
const m = meta('deep-snapshot')
await ctx.sessionPersistence.create(m)
const events = oneTurnLog()
const userMsg = events[1] // the user/message event
const p = ctx.sessionPersistence.append(m.id, events)
// Mutate an event OBJECT (not just the array) after calling append. The deep
// snapshot taken at call time must shield the persisted data.
if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'MUTATED' }]
await p
const loaded = await ctx.sessionPersistence.load(m.id)
const persisted = JSON.stringify(loaded.events)
expect(persisted).toContain('hi') // original content
expect(persisted).not.toContain('MUTATED')
})
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
const m = meta('meta-copy', '/proj')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const loaded = await ctx.sessionPersistence.load(m.id)
// A consumer mutates the returned meta's cwd. The backend's stored pathing
// metadata must be unaffected, so a later append still finds the right log.
loaded.meta.cwd = '/evil'
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[])
// The append landed in the ORIGINAL /proj log, not beside an /evil path.
const reloaded = await ctx.sessionPersistence.load(m.id)
expect(reloaded.meta.cwd).toBe('/proj')
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('rejects an unknown format version on load', async () => {
const m = meta('v2')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
// Corrupt the header version on disk.
const path = logPath(root, undefined, m.id)
const lines = (await readFile(path, 'utf8')).split('\n')
const header = JSON.parse(lines[0]!) as { version: number }
header.version = 2
lines[0] = JSON.stringify(header)
await writeFile(path, lines.join('\n'))
// Fresh backend (no in-memory state) → must reject on load.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await expect(ctx2.sessionPersistence.load(m.id)).rejects.toThrow(/version/)
await ctx2.fiber.dispose()
})
it('rejects a re-append of an already-stored seq', async () => {
const m = meta('reappend')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow(/seq mismatch/)
})
it('path-traversal session ids are neutralized (no escape from root)', async () => {
const evil = SessionId('../../etc/pwn')
const m = { version: 1, id: evil, createdAt: 1 }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(evil, oneTurnLog())
// The file lives UNDER root, not at ../../etc.
const all: string[] = []
async function walk(dir: string): Promise<void> {
for (const e of await readdir(dir, { withFileTypes: true })) {
const p = join(dir, e.name)
if (e.isDirectory()) await walk(p)
else all.push(p)
}
}
await walk(root)
expect(all.length).toBeGreaterThan(0)
expect(all.every(p => p.startsWith(root))).toBe(true)
})
})
describe('SessionPersistenceJsonl: write path (session/event → flush)', () => {
it('persists a live session driven through the store, surviving reload', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
const session = ctx.sessions.create('live', { meta: { cwd: '/w' } })
for (const e of oneTurnLog()) session.append(e.type, e.data)
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('live'))
expect(loaded.events).toHaveLength(6)
expect(loaded.meta.cwd).toBe('/w')
await ctx.fiber.dispose()
})
it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
const session = ctx.sessions.create('mutate')
const ev = session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } })
// Mutate the live event object AFTER it was buffered.
;(ev.data as { content: { type: 'text'; text: string }[] }).content[0]!.text = 'HACKED'
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('mutate'))
const first = loaded.events[0]
expect(first?.type === 'user/message' && (first.data.content[0] as { text: string }).text).toBe('original')
await ctx.fiber.dispose()
})
it('fork: a seeded new session persists its seed once', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
const seed = oneTurnLog()
// A fork: a brand-new id whose seed came from elsewhere.
const forked = ctx.sessions.create('forked', { seed })
// onCreated persisted the seed asynchronously; wait a tick.
await new Promise(r => setTimeout(r, 10))
const loaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(loaded.events).toEqual(seed)
// A flush with no NEW events must not double-write.
await ctx.parallel('session/flush', forked)
const reloaded = await ctx.sessionPersistence.load(SessionId('forked'))
expect(reloaded.events).toEqual(seed)
await ctx.fiber.dispose()
})
it('concurrent sessions do not cross buffers', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
const a = ctx.sessions.create('sa')
const b = ctx.sessions.create('sb')
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } })
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', a)
await ctx.parallel('session/flush', b)
const la = await ctx.sessionPersistence.load(SessionId('sa'))
const lb = await ctx.sessionPersistence.load(SessionId('sb'))
expect(JSON.stringify(la.events)).toContain('"A"')
expect(JSON.stringify(la.events)).not.toContain('"B"')
expect(JSON.stringify(lb.events)).toContain('"B"')
expect(JSON.stringify(lb.events)).not.toContain('"A"')
await ctx.fiber.dispose()
})
it('HMR: applying the plugin seeds existing live sessions', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
// A session exists BEFORE the persistence plugin is applied.
const session = ctx.sessions.create('pre-existing')
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.plugin(SessionPersistenceJsonl, { root })
// The plugin seeded it on apply; a subsequent flush persists its events.
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('pre-existing'))
expect(loaded.events.length).toBeGreaterThanOrEqual(2)
await ctx.fiber.dispose()
})
it('HMR: dispose drains remaining buffers', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
let session!: Session
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root })
const sessFiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('drain')
}, { inject: ['sessions'] }))
session.append('user/message', { content: [{ type: 'text', text: 'buffered' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// No explicit flush — dispose must drain.
await fiber.dispose()
await sessFiber.dispose()
// A fresh backend reads what the disposed one drained.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
const loaded = await ctx2.sessionPersistence.load(SessionId('drain'))
expect(loaded.events.length).toBeGreaterThanOrEqual(2)
await ctx2.fiber.dispose()
})
it('HMR: reloading the backend adopts a still-live, already-materialized session', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
// The session lives in its OWN fiber so it survives the backend reload.
let session!: Session
await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('hmr-adopt')
}, { inject: ['sessions'] }))
// Backend instance 1 materializes the session on disk.
const backend1 = await ctx.plugin(SessionPersistenceJsonl, { root })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
// Hot-reload the backend: dispose instance 1, plug in instance 2 over the
// SAME root while the session stays live. Instance 2 has an empty states
// map but the log is on disk — it must ADOPT (not reject) so flush keeps
// working. A second turn appended after reload then persists.
await backend1.dispose()
await ctx.plugin(SessionPersistenceJsonl, { root })
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'again' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await expect(ctx.parallel('session/flush', session)).resolves.not.toThrow()
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-adopt'))
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
await ctx.fiber.dispose()
})
it('HMR: adoption persists the live SUFFIX that was ahead of the on-disk prefix', async () => {
root = await freshRoot()
const ctx = new Context()
await ctx.plugin(SessionStore)
let session!: Session
await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('hmr-suffix')
}, { inject: ['sessions'] }))
// Instance 1 flushes turn 1 to disk.
const backend1 = await ctx.plugin(SessionPersistenceJsonl, { root })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
// Append turn 2 to the LIVE session, then dispose instance 1 WITHOUT
// flushing turn 2. Turn 2 is now ONLY in the live session's events; the new
// backend never buffered it via session/event.
await backend1.dispose()
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
// Instance 2 adopts the on-disk prefix (turn 1) and MUST also persist the
// live suffix (turn 2) carried in the session's events — otherwise turn 2 is
// lost and a later flush would mismatch.
await ctx.plugin(SessionPersistenceJsonl, { root })
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-suffix'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3])
expect(loaded.events.filter(e => e.type === 'turn/start')).toHaveLength(2)
await ctx.fiber.dispose()
})
})
describe('SessionPersistenceJsonl: scanLog unit', () => {
it('rejects a header-less / empty log', () => {
expect(() => scanLog(Buffer.from(''))).toThrow()
})
it('rejects a corrupt header line', () => {
expect(() => scanLog(Buffer.from('not json\n'))).toThrow(/header/)
})
it('rejects a non-session first line', () => {
expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
})
it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
const log = [
JSON.stringify({ type: 'session', version: 1, id: 'g', createdAt: 1 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
].join('\n') + '\n'
// No committed turn/end, so the gap is a tolerated crash boundary: scanLog
// PRESERVES the contiguous prefix (turn/start seq 0) — real interrupted-turn
// work, not discarded — and stops at the gap. The orphaned open turn is
// closed by loadCore's synthetic turn/end, not here.
expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
})
it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
const log = [
JSON.stringify({ type: 'session', version: 1, id: 'g2', createdAt: 1 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
].join('\n') + '\n'
// A turn/end exists, so the prefix up to it is committed — but it has a hole.
// Truncating it would silently drop committed data → unloadable.
expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap in committed region/)
})
it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => {
const log = [
JSON.stringify({ type: 'session', version: 1, id: 'c', createdAt: 1 }),
'{not json', // corrupt, sits in the committed region (a turn/end follows)
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
].join('\n') + '\n'
expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
})
it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
const log = JSON.stringify({ type: 'session', version: 1, id: 'h0', createdAt: 1 }) + '\n'
const scanned = scanLog(Buffer.from(log))
expect(scanned.events).toEqual([])
// committedBytes falls back to the header line's end (no preserved events).
expect(scanned.committedBytes).toBe(Buffer.byteLength(log, 'utf8'))
})
it('a corrupt line after the last turn/end bounds the preserved tail', () => {
const log = [
JSON.stringify({ type: 'session', version: 1, id: 'c2', createdAt: 1 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
'{not json', // corrupt crash fragment, no turn/end committed
].join('\n') + '\n'
// The contiguous prefix (turn/start seq 0) is preserved; the corrupt
// fragment after it is the tolerated crash boundary.
expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
})
it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
const log = [
JSON.stringify({ type: 'session', version: 1, id: 't', createdAt: 1 }),
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
].join('\n') + '\n'
const { events } = scanLog(Buffer.from(log))
expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
})
})
describe('SessionPersistenceJsonl: edge cases', () => {
let ctx: Context
beforeEach(async () => {
root = await freshRoot()
ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(SessionPersistenceJsonl, { root })
})
afterEach(async () => { await ctx.fiber.dispose() })
it('load rejects a missing session', async () => {
await expect(ctx.sessionPersistence.load(SessionId('nope'))).rejects.toThrow(/not found/)
})
it('append of an empty batch is a no-op', async () => {
const m = meta('empty-batch')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, [])
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
})
it('append rejects non-JSON-serializable undefined-producing data', async () => {
const m = meta('undef')
await ctx.sessionPersistence.create(m)
// A value whose JSON.stringify yields undefined (a bare function as data).
const bad = [{ type: 'user/message', seq: 0, time: 1, data: (() => 0) as unknown }] as unknown as SessionEvent[]
await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/)
})
it('delete of a non-existent session is a no-op', async () => {
await expect(ctx.sessionPersistence.delete(SessionId('ghost'))).resolves.toBeUndefined()
})
it('an abandoned lazy session (never materialized) releases its id for reuse', async () => {
// A live session is created then disposed BEFORE its first append: cursor 0,
// never materialized, nothing on disk. A new live session reusing the id
// must reclaim it (lazy materialization promises no lingering artifact),
// not wedge on an "already bound" collision until restart.
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let firstSession!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
firstSession = inner.sessions.create('abandoned', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await backend.inits.get(firstSession) // let the lazy create register the state
await firstFiber.dispose() // disposed before any append → never materialized
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create('abandoned', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
// The new session claims the id without error and can persist a turn.
await expect(backend.inits.get(reuse)).resolves.toBeUndefined()
reuse.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
reuse.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', reuse)
const loaded = await ctx.sessionPersistence.load(SessionId('abandoned'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
})
it('does NOT reclaim an id whose abandoned owner still has buffered (unflushed) events', async () => {
// A session that appended events but was disposed BEFORE its first flush is
// not materialized yet but still holds a write-behind buffer. Reusing the id
// must be rejected (not reclaimed), or the stale buffer would drain against
// the new session — persisting old events under the new id or dropping the
// new session's seq-0 events.
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let first!: Session
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.sessions.create('buffered', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await backend.inits.get(first)
// Append a turn but do NOT flush — events sit in the write-behind buffer.
first.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
first.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await firstFiber.dispose() // disposed before flush; not materialized, buffer pending
let reuse!: Session
await ctx.plugin(Object.assign((inner: Context) => {
reuse = inner.sessions.create('buffered', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(reuse)).rejects.toThrow(/already bound to a different live session/)
})
it('create snapshots its meta: mutating the caller object after the call is ignored', async () => {
const m = meta('create-snap', '/orig')
const p = ctx.sessionPersistence.create(m)
// Mutate the caller's meta object immediately after calling create.
m.cwd = '/mutated'
await p
await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
// The log materialized under the ORIGINAL cwd, not the mutated one.
expect((await stat(logPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
await expect(stat(logPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
})
it('list discovers sessions across multiple cwd buckets', async () => {
await ctx.sessionPersistence.create(meta('p1', '/projA'))
await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog())
await ctx.sessionPersistence.create(meta('p2', '/projB'))
await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog())
await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd bucket
await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog())
const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
expect(ids).toEqual(['p1', 'p2', 'p3'])
})
it('list on an empty root returns nothing', async () => {
expect(await ctx.sessionPersistence.list()).toEqual([])
})
it('list skips empty and non-header .jsonl files (metadata-only read)', async () => {
// A real session…
await ctx.sessionPersistence.create(meta('real', '/p'))
await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
// …alongside two junk files in the _no-cwd bucket: an EMPTY file (readFirstLine
// returns undefined) and a file whose first line is not a session header
// (parseHeaderMeta returns undefined). Both are skipped, not listed.
const bucket = join(root, '_no-cwd')
await mkdir(bucket, { recursive: true })
await writeFile(join(bucket, 'empty.jsonl'), '')
await writeFile(join(bucket, 'notheader.jsonl'), '{"type":"turn/start"}\n')
await writeFile(join(bucket, 'badjson.jsonl'), 'not json at all\n')
const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
expect(ids).toEqual(['real'])
})
it('list reads a header line longer than the 8KB read chunk', async () => {
// readFirstLine accumulates across reads when the first line exceeds its
// buffer. Plant a valid header whose line is > 8192 bytes (a long extra
// field is tolerated by the header type guard) and confirm list() reads it.
const bucket = join(root, '_no-cwd')
await mkdir(bucket, { recursive: true })
const bigHeader = JSON.stringify({ type: 'session', version: 1, id: 'big', createdAt: 1, pad: 'x'.repeat(9000) })
await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n')
const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
expect(ids).toContain('big')
})
it('has() finds a session on disk under an unknown cwd (cross-bucket scan)', async () => {
const m = meta('scan-me', '/somewhere')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
// A fresh backend with no in-memory state → has() must scan disk buckets.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
expect(await ctx2.sessionPersistence.has(m.id)).toBe(true)
expect(await ctx2.sessionPersistence.has(SessionId('absent'))).toBe(false)
await ctx2.fiber.dispose()
})
it('resume/adopt: a live session whose id is already on disk continues from the stored length', async () => {
// First lifecycle: persist a session through the store.
const s1 = ctx.sessions.create('resumed', { meta: { cwd: '/r' } })
for (const e of oneTurnLog()) s1.append(e.type, e.data)
await ctx.parallel('session/flush', s1)
// Second lifecycle: a NEW backend + a session re-created with the same id
// and SEEDED with the loaded events (the resume path). onCreated must adopt
// the on-disk log (not re-persist the seed), and a new turn appends at seq 6.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
const loaded = await ctx2.sessionPersistence.load(SessionId('resumed'))
const s2 = ctx2.sessions.create('resumed', { seed: loaded.events, meta: { cwd: '/r' } })
await new Promise(r => setTimeout(r, 10)) // let onCreated adopt
// Append a fresh turn through the live session.
s2.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
s2.append('turn/end', { turn: 2, reason: { kind: 'completed' } })
await ctx2.parallel('session/flush', s2)
const reloaded = await ctx2.sessionPersistence.load(SessionId('resumed'))
// 6 original + 2 new, contiguous, no duplicated seed.
expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
await ctx2.fiber.dispose()
})
it('HMR adoption does not crash-repair an active open turn as interrupted', async () => {
const dir = await freshRoot()
const hmr = new Context()
await hmr.plugin(SessionStore)
const first = await hmr.plugin(SessionPersistenceJsonl, { root: dir })
const session = hmr.sessions.create('hmr-open', { meta: { cwd: '/hmr' } })
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
await hmr.parallel('session/flush', session)
await first.dispose()
await appendFile(logPath(dir, '/hmr', SessionId('hmr-open')), '{"torn":')
const second = await hmr.plugin(SessionPersistenceJsonl, { root: dir })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await hmr.parallel('session/flush', session)
const loaded = await hmr.sessionPersistence.load(SessionId('hmr-open'))
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
await second.dispose()
await hmr.fiber.dispose()
})
it('a NEW live session whose id collides with an on-disk log is rejected, not silently adopted', async () => {
// Persist a session on disk.
const s1 = ctx.sessions.create('collide', { meta: { cwd: '/a' } })
for (const e of oneTurnLog()) s1.append(e.type, e.data)
await ctx.parallel('session/flush', s1)
const before = await readFile(logPath(root, '/a', SessionId('collide')), 'utf8')
// A FRESH backend + a NEW live session with the same id but NO explicit
// load/resume. onCreated must NOT adopt-from-disk (resume is explicit); it
// treats this as a new session and create() rejects because a log already
// exists on disk. The rejection surfaces via the init promise (flush awaits
// it); the on-disk committed log is left byte-for-byte intact.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
const backend = ctx2.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
const s2 = ctx2.sessions.create('collide', { meta: { cwd: '/a' } })
// The init for the new live session rejects (observed via the per-session
// init map and, in production, via flush which awaits the same promise).
await expect(backend.inits.get(s2)).rejects.toThrow(/already has a persisted log on disk/)
// The committed log is untouched (no clobber).
expect(await readFile(logPath(root, '/a', SessionId('collide')), 'utf8')).toBe(before)
await ctx2.fiber.dispose()
})
it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
// Session A materializes a log under id "reuse".
const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
const a = inner.sessions.create('reuse', { meta: { cwd: '/a' } })
for (const e of oneTurnLog()) a.append(e.type, e.data)
}, { inject: ['sessions'] }))
// Drain A, then dispose ITS fiber (the live session A is gone) while the
// backend stays loaded.
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
await sessFiberA.dispose()
// A NEW live Session object reuses id "reuse". The init cache is keyed by
// the Session OBJECT, so this gets its OWN onCreated (not A's stale promise)
// — which detects the on-disk collision and rejects, rather than silently
// appending the new session's events onto A's log under a stale cursor.
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let b!: Session
await ctx.plugin(Object.assign((inner: Context) => {
b = inner.sessions.create('reuse', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
})
it('a live session claims cursor-0 ownerless state created via the public API', async () => {
// create() registers ownerless state with cursor 0 (lazy, nothing persisted
// yet). A live session with that id then arrives and claims it without a
// prefix check (cursor 0 matches trivially), persisting its seed.
await ctx.sessionPersistence.create(meta('lazy-claim', '/a'))
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let live!: Session
await ctx.plugin(Object.assign((inner: Context) => {
live = inner.sessions.create('lazy-claim', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(live)).resolves.toBeUndefined()
live.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
live.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', live)
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-claim'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
})
it('a fresh session reusing a previously-loaded id is rejected (ownerless guard)', async () => {
// Materialize a log, then load() it into the backend's state WITHOUT a live
// session — leaving state.owner undefined and cursor at the persisted length
// (the public preview path).
await ctx.sessionPersistence.create(meta('preview', '/a'))
await ctx.sessionPersistence.append(SessionId('preview'), oneTurnLog())
await ctx.sessionPersistence.load(SessionId('preview'))
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
// A FRESH (empty-seed) live session reusing that id must be rejected: its
// seq 0..cursor-1 events would otherwise be filtered as already-persisted
// and its conversation grafted onto the old log.
let fresh!: Session
await ctx.plugin(Object.assign((inner: Context) => {
fresh = inner.sessions.create('preview', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(fresh)).rejects.toThrow(/do not match this live session|already has a persisted log/)
})
it('a session whose seed matches the loaded prefix claims ownerless state', async () => {
// Materialize a log and load it (ownerless state, cursor = 6).
await ctx.sessionPersistence.create(meta('match', '/a'))
await ctx.sessionPersistence.append(SessionId('match'), oneTurnLog())
await ctx.sessionPersistence.load(SessionId('match'))
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
// A live session SEEDED with the persisted log legitimately continues it —
// its seed reproduces the loaded prefix, so it claims the ownerless state.
let cont!: Session
await ctx.plugin(Object.assign((inner: Context) => {
cont = inner.sessions.create('match', { seed: oneTurnLog(), meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(cont)).resolves.toBeUndefined()
})
it('claiming ownerless state persists the seed suffix beyond the prefix', async () => {
// Materialize a one-turn log and load it (ownerless state, cursor = 6).
await ctx.sessionPersistence.create(meta('suffix-claim', '/a'))
await ctx.sessionPersistence.append(SessionId('suffix-claim'), oneTurnLog())
await ctx.sessionPersistence.load(SessionId('suffix-claim'))
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
// A live session seeded with the prefix PLUS a second turn (seqs 6,7). The
// suffix (constructor seed, never emits session/event) must be persisted on
// claim, not lost.
const seed = [
...oneTurnLog(),
{ type: 'turn/start', seq: 6, time: 7, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 8, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[]
let cont!: Session
await ctx.plugin(Object.assign((inner: Context) => {
cont = inner.sessions.create('suffix-claim', { seed, meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await backend.inits.get(cont)
const loaded = await ctx.sessionPersistence.load(SessionId('suffix-claim'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('claiming cursor-0 ownerless state persists the whole constructor seed', async () => {
// create() registers ownerless state with cursor 0 (lazy, nothing on disk).
await ctx.sessionPersistence.create(meta('lazy-seed', '/a'))
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
// A live session seeded with a full turn claims it; the whole seed (cursor
// is 0) must be persisted.
let cont!: Session
await ctx.plugin(Object.assign((inner: Context) => {
cont = inner.sessions.create('lazy-seed', { seed: oneTurnLog(), meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await backend.inits.get(cont)
const loaded = await ctx.sessionPersistence.load(SessionId('lazy-seed'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5])
})
it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => {
// Materialize and load (ownerless, cursor = 6).
await ctx.sessionPersistence.create(meta('divergent', '/a'))
await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog())
await ctx.sessionPersistence.load(SessionId('divergent'))
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
// A seed that keeps every seq/type/time but mutates a payload must NOT be
// accepted as "the same session" — otherwise drain filters those seqs as
// already persisted and the divergent payload is silently lost.
const tampered = oneTurnLog()
const userMsg = tampered[1]
if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }]
let bad!: Session
await ctx.plugin(Object.assign((inner: Context) => {
bad = inner.sessions.create('divergent', { seed: tampered, meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
})
it('a second live session reusing a bound id is rejected', async () => {
// A live session materializes and owns the id.
const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
const a = inner.sessions.create('bound', { meta: { cwd: '/a' } })
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}, { inject: ['sessions'] }))
for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
await firstFiber.dispose()
const backend = ctx.sessionPersistence as unknown as { inits: Map<Session, Promise<void>> }
let second!: Session
await ctx.plugin(Object.assign((inner: Context) => {
second = inner.sessions.create('bound', { meta: { cwd: '/a' } })
}, { inject: ['sessions'] }))
await expect(backend.inits.get(second))
.rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
})
it('round-trips a header with parentSession (fork lineage)', async () => {
const m: SessionHeader = { version: 1, id: SessionId('forked-child'), createdAt: 1, parentSession: SessionId('the-parent') }
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const loaded = await ctx.sessionPersistence.load(m.id)
expect(loaded.meta.parentSession).toBe('the-parent')
})
it('list returns nothing when the root directory does not exist', async () => {
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root: join(root, 'does-not-exist-yet') })
expect(await ctx2.sessionPersistence.list()).toEqual([])
await ctx2.fiber.dispose()
})
it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => {
// A durable backend must NOT collapse a storage fault to "no sessions". Point
// the root at a regular FILE: readdir then fails with ENOTDIR, which must
// propagate rather than be swallowed as an empty listing.
const filePath = join(root, 'not-a-dir')
await writeFile(filePath, 'x')
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root: filePath })
await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
it('exists() surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
// Same contract on the existence path: a non-ENOENT error from the per-id
// open() must surface, not be collapsed to "not found" (which would let a
// collision check proceed under a false absence assumption). A LAZY session
// (created, never appended) keeps its cwd in state, so has() reaches
// findLog(id, cwd) → exists(logPath). Make that cwd's bucket DIRECTORY a
// regular file: open()ing `bucket/<id>.jsonl` under it then fails ENOTDIR.
const cwd = '/x'
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.sessionPersistence.create(meta('exists-fault', cwd)) // lazy: no bucket yet
await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
await expect(ctx2.sessionPersistence.has(SessionId('exists-fault'))).rejects.toThrow(/ENOTDIR/)
await ctx2.fiber.dispose()
})
it('append() to a disk-only session adopts it and repairs a crash tail', async () => {
// Persist a session, then corrupt its tail, all through ONE backend.
const m = meta('disk-append', '/d')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
await writeFile(logPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
// A FRESH backend with no in-memory state: append directly (no prior load)
// → append must adopt from disk, and the adopt's load schedules a repair
// that the same append then performs before writing.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await ctx2.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
] as SessionEvent[])
const loaded = await ctx2.sessionPersistence.load(m.id)
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
await ctx2.fiber.dispose()
})
it('a header-only log (open turn, no turn/end) preserves the open turn on load and closes it', async () => {
// A session whose only durable content is an unclosed first turn. scanLog
// preserves the turn/start; loadCore closes it with a synthetic
// turn/end {interrupted} so the returned log is balanced.
const m = meta('open-turn', '/h')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
] as SessionEvent[])
const { events } = await ctx.sessionPersistence.load(m.id)
expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
const end = events[1]!
expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
})
it('initFor is idempotent: a re-seeded existing session is not re-initialized', async () => {
const session = ctx.sessions.create('idem', { meta: { cwd: '/i' } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
// Re-emit session/created for the SAME live session (idempotent initFor).
ctx.emit('session/created', session)
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('idem'))
expect(loaded.events).toHaveLength(2) // not doubled
})
it('flush before init resolves with no state uses cursor 0', async () => {
// Drive a fork (seed) flush where the buffer holds the seed; the fresh
// events filter against cursor. Exercises the state-undefined cursor path.
const session = ctx.sessions.create('flush-nostate')
// Append directly to the live session and flush IMMEDIATELY, before the
// async onCreated init has necessarily set state.
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
await ctx.parallel('session/flush', session)
const loaded = await ctx.sessionPersistence.load(SessionId('flush-nostate'))
expect(loaded.events).toHaveLength(2)
})
it('createCore rejects creating an id this backend already tracks', async () => {
await ctx.sessionPersistence.create(meta('dup'))
await expect(ctx.sessionPersistence.create(meta('dup'))).rejects.toThrow(/already exists in this backend/)
})
it('createCore rejects creating an id whose log already exists on disk', async () => {
const m = meta('on-disk', '/od')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
// A fresh backend (no in-memory state) must refuse to create over the log.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await expect(ctx2.sessionPersistence.create(meta('on-disk', '/od'))).rejects.toThrow(/already has a persisted log on disk/)
await ctx2.fiber.dispose()
})
it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => {
// Persist the id under cwd A.
const a = meta('dup-id', '/projA')
await ctx.sessionPersistence.create(a)
await ctx.sessionPersistence.append(a.id, oneTurnLog())
// A fresh backend creating the SAME id under cwd B must still refuse: load/
// has identify by id across all buckets, so a second log would make resume
// nondeterministic. create scans every bucket, not just meta.cwd's.
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
.rejects.toThrow(/already has a persisted log on disk/)
await ctx2.fiber.dispose()
})
it('flush keeps buffered events when the append fails (no silent loss)', async () => {
root = await freshRoot()
const ctx2 = new Context()
await ctx2.plugin(SessionStore)
await ctx2.plugin(SessionPersistenceJsonl, { root })
const session = ctx2.sessions.create('flush-fail')
// A full turn lands in the write-behind buffer.
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Make the durable materialize fail on the next flush.
const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
const origMat = backend.materialize.bind(backend)
backend.materialize = () => Promise.reject(new Error('disk full'))
await expect(ctx2.parallel('session/flush', session)).rejects.toThrow(/disk full/)
// The events are STILL buffered (not silently dropped): a retry persists them.
backend.materialize = origMat
await ctx2.parallel('session/flush', session)
const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
await ctx2.fiber.dispose()
})
it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
const m = meta('serial')
await ctx.sessionPersistence.create(m)
const bad = (extra: unknown) => [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra } }] as unknown as SessionEvent[]
await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(new Map()))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(undefined))).rejects.toThrow(/non-JSON-serializable/)
await expect(ctx.sessionPersistence.append(m.id, bad(Infinity))).rejects.toThrow(/non-JSON-serializable/)
// a circular structure
const circ: Record<string, unknown> = {}
circ.self = circ
await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/)
// The session was never materialized by any of the rejected appends.
expect(await ctx.sessionPersistence.has(m.id)).toBe(false)
})
it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
const m = meta('json-ok')
await ctx.sessionPersistence.create(m)
const ev = [{ type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' }, extra: { a: null, b: true, c: [1, 2, { d: 'nested' }] } } }] as unknown as SessionEvent[]
await ctx.sessionPersistence.append(m.id, ev)
expect(await ctx.sessionPersistence.has(m.id)).toBe(true)
})
it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
const session = ctx.sessions.create('reject-bad')
// Serializability is enforced at the source: Session.append throws on a
// BigInt-bearing event BEFORE it enters session.events, so the durable log
// can never diverge from the live log. The throw surfaces at the caller's
// append site, not asynchronously in a backend flush.
expect(() => {
session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never)
}).toThrow(/non-JSON-serializable/)
// The bad event was rejected, so the log stayed empty.
expect(session.events.length).toBe(0)
})
})