mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
1301 lines
62 KiB
TypeScript
1301 lines
62 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { isAbsolute, join, relative, resolve } 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, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, toHeaderLine,
|
|
} from '../src/format.ts'
|
|
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
|
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
|
|
|
let root: string
|
|
const dirs: string[] = []
|
|
|
|
type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
|
|
|
|
/** Test-only mutable view used to verify that backends detach returned/caller metadata. */
|
|
function mutableHeader(header: SessionHeader): MutableSessionHeader {
|
|
return header
|
|
}
|
|
|
|
/** Rewrite only a stored header while preserving every event byte below it. */
|
|
async function rewriteHeader(path: string, update: (header: Record<string, unknown>) => void): Promise<void> {
|
|
const lines = (await readFile(path, 'utf8')).split('\n')
|
|
const header = JSON.parse(lines[0] as string) as Record<string, unknown>
|
|
update(header)
|
|
lines[0] = JSON.stringify(header)
|
|
await writeFile(path, lines.join('\n'))
|
|
}
|
|
|
|
async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
|
|
try {
|
|
await promise
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(Error)
|
|
expect((error as Error).message).toMatch(message)
|
|
return
|
|
}
|
|
throw new Error('expected flush to reject')
|
|
}
|
|
|
|
async function freshRoot(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
|
|
dirs.push(dir)
|
|
return dir
|
|
}
|
|
|
|
function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
|
|
return logPath(root, cwd, id, 'none')
|
|
}
|
|
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks()
|
|
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
|
|
})
|
|
|
|
function appendClosedTurn(session: Session): void {
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('user/message', {
|
|
content: [{ type: 'text', text: 'hello' }],
|
|
source: { kind: 'user' },
|
|
}, { surfaceOp: 'append' })
|
|
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
}
|
|
|
|
// Run the shared backend contract against the real JSONL backend.
|
|
runPersistenceContract('jsonl-none', 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, compression: 'none' })
|
|
return {
|
|
persistence: ctx.sessionPersistence,
|
|
dispose: async () => {
|
|
await fiber.dispose()
|
|
await rm(dir, { recursive: true, force: true })
|
|
},
|
|
}
|
|
})
|
|
|
|
// Two mounts share this temp root to exercise reload. `corruptTail` appends a partial,
|
|
// newline-less fragment past the committed region so coordinator repair runs on real file bytes.
|
|
runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
|
|
return {
|
|
mount: async (ctx) => {
|
|
const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
|
|
return fiber
|
|
},
|
|
corruptTail: async (id, cwd) => {
|
|
// A half-written record with no trailing newline: scanLog treats it as an
|
|
// uncommitted crash fragment and reports committedBytes < byteLength, so
|
|
// the coordinator sees a tornMarker to truncate.
|
|
await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
|
|
},
|
|
cleanup: async () => { 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/)
|
|
})
|
|
|
|
it('projectKey normalizes project paths into bounded readable names', () => {
|
|
expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
|
|
expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
|
|
expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--')
|
|
expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--')
|
|
expect(projectKey('/')).toBe('--root--')
|
|
expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255)
|
|
expect(() => projectKey('')).toThrow(/empty project path/)
|
|
})
|
|
|
|
it('resolves a relative custom root before locating a session', async () => {
|
|
const absoluteRoot = await freshRoot()
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
const fiber = await ctx.plugin(SessionPersistenceJsonl, {
|
|
root: relative(process.cwd(), absoluteRoot),
|
|
compression: 'none',
|
|
})
|
|
const m = meta('relative-location', '/work')
|
|
expect(ctx.sessionPersistence.locate(m)).toEqual({
|
|
kind: 'jsonl',
|
|
path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
|
|
})
|
|
await fiber.dispose()
|
|
})
|
|
})
|
|
|
|
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, compression: 'none' })
|
|
})
|
|
afterEach(async () => { await ctx.fiber.dispose() })
|
|
|
|
it('lazy materialization: create() writes no file until the first append', async () => {
|
|
const m = meta('lazy', '/work')
|
|
const location = ctx.sessionPersistence.locate(m)
|
|
expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) })
|
|
expect(isAbsolute(location!.path)).toBe(true)
|
|
|
|
await ctx.sessionPersistence.create(m)
|
|
// locate() is a pure target-path calculation: neither it nor create()
|
|
// materializes a file before the first append.
|
|
const dir = sessionDir(root, '/work', m.id)
|
|
await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
|
|
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
|
|
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
// now materialized
|
|
expect((await stat(dir)).isDirectory()).toBe(true)
|
|
expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
|
|
expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
|
|
})
|
|
|
|
it('keeps the same location on resume and gives a fork its own location', async () => {
|
|
const parent = meta('location-parent', '/work')
|
|
const parentLocation = ctx.sessionPersistence.locate(parent)
|
|
await ctx.sessionPersistence.create(parent)
|
|
await ctx.sessionPersistence.append(parent.id, oneTurnLog())
|
|
|
|
const loaded = await ctx.sessionPersistence.load(parent.id)
|
|
expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation)
|
|
|
|
const child = {
|
|
...loaded.meta,
|
|
id: SessionId('location-child'),
|
|
parentSession: parent.id,
|
|
seedLength: loaded.events.length,
|
|
}
|
|
const childLocation = ctx.sessionPersistence.locate(child)
|
|
expect(childLocation?.path).not.toBe(parentLocation?.path)
|
|
expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) })
|
|
})
|
|
|
|
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' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append', sourceEventSeqs: [2, 3] },
|
|
{ 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('source-qualifies revisions across roots while preserving same-log reopen identity', async () => {
|
|
const m = meta('revision-source')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision
|
|
|
|
const reopenedCtx = new Context()
|
|
await reopenedCtx.plugin(SessionStore)
|
|
await reopenedCtx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision)
|
|
|
|
const otherRoot = await freshRoot()
|
|
const otherCtx = new Context()
|
|
await otherCtx.plugin(SessionStore)
|
|
await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot, compression: 'none' })
|
|
await otherCtx.sessionPersistence.create(m)
|
|
await otherCtx.sessionPersistence.append(m.id, oneTurnLog())
|
|
expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision)
|
|
|
|
await reopenedCtx.fiber.dispose()
|
|
await otherCtx.fiber.dispose()
|
|
})
|
|
|
|
it('omits a snapshot artifact removed after discovery', async () => {
|
|
const m = meta('vanishing-snapshot')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const persistence = ctx.sessionPersistence as unknown as {
|
|
listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
|
|
}
|
|
const listArtifacts = persistence.listArtifacts.bind(persistence)
|
|
const discovery = vi.spyOn(persistence, 'listArtifacts').mockImplementation(async () => {
|
|
const artifacts = await listArtifacts()
|
|
await rm(artifacts[0]!.path)
|
|
return artifacts
|
|
})
|
|
|
|
await expect(ctx.sessionPersistence.listSnapshots()).resolves.toEqual([])
|
|
discovery.mockRestore()
|
|
})
|
|
|
|
it('surfaces non-ENOENT snapshot stat failures after discovery', async () => {
|
|
const persistence = ctx.sessionPersistence as unknown as {
|
|
listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
|
|
}
|
|
const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
|
|
header: meta('snapshot-stat-failure'),
|
|
path: `${root}\0snapshot-stat-failure`,
|
|
}])
|
|
|
|
await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/)
|
|
discovery.mockRestore()
|
|
})
|
|
|
|
it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => {
|
|
const persistence = ctx.sessionPersistence as unknown as {
|
|
listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
|
|
}
|
|
const started = Promise.withResolvers<AbortSignal>()
|
|
const cleanup = Promise.withResolvers<undefined>()
|
|
vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => {
|
|
if (signal === undefined) throw new Error('expected snapshot-list signal')
|
|
started.resolve(signal)
|
|
await cleanup.promise
|
|
return []
|
|
})
|
|
const reason = new Error('JSONL snapshot discovery cancelled')
|
|
const controller = new AbortController()
|
|
const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
|
|
expect(await started.promise).toBe(controller.signal)
|
|
let settled = false
|
|
void pending.then(
|
|
() => { settled = true },
|
|
() => { settled = true },
|
|
)
|
|
|
|
controller.abort(reason)
|
|
await Promise.resolve()
|
|
expect(settled).toBe(false)
|
|
|
|
cleanup.resolve(undefined)
|
|
await expect(pending).rejects.toBe(reason)
|
|
})
|
|
|
|
it('checks cancellation after an uncancellable snapshot stat settles', async () => {
|
|
const m = meta('snapshot-stat-cancellation')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const persistence = ctx.sessionPersistence as unknown as {
|
|
listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
|
|
}
|
|
const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
|
|
header: m,
|
|
path: rawLogPath(root, m.cwd, m.id),
|
|
}])
|
|
const reason = new Error('JSONL snapshot stat cancelled')
|
|
const controller = new AbortController()
|
|
const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
|
|
queueMicrotask(() => { controller.abort(reason) })
|
|
|
|
await expect(pending).rejects.toBe(reason)
|
|
expect(discovery).toHaveBeenCalledWith(controller.signal)
|
|
})
|
|
|
|
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
|
|
const m = meta('legacy-header-delta', '/legacy')
|
|
const path = rawLogPath(root, m.cwd, m.id)
|
|
await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
|
|
await writeFile(path, [
|
|
JSON.stringify(toHeaderLine(m)),
|
|
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
|
JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
|
|
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
|
'',
|
|
].join('\n'))
|
|
|
|
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
|
|
})
|
|
|
|
it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
|
|
const m = meta('legacy-header-fallback', '/legacy')
|
|
const path = rawLogPath(root, m.cwd, m.id)
|
|
await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
|
|
await writeFile(path, [
|
|
JSON.stringify(toHeaderLine(m)),
|
|
JSON.stringify({
|
|
type: 'request/header',
|
|
seq: 0,
|
|
time: 1,
|
|
data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
|
|
}),
|
|
'',
|
|
].join('\n'))
|
|
|
|
await expect(ctx.sessionPersistence.load(m.id))
|
|
.rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
|
|
})
|
|
|
|
it('persists a forked child seed through the existing session write path', async () => {
|
|
const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
|
|
appendClosedTurn(source)
|
|
|
|
const child = ctx.sessions.fork(source, undefined, SessionId('persist-child'))
|
|
await ctx.sessions.flush(child)
|
|
const loaded = await ctx.sessionPersistence.load(child.id)
|
|
|
|
expect(loaded.events).toEqual(source.events)
|
|
expect(loaded.meta).toMatchObject({
|
|
id: SessionId('persist-child'),
|
|
cwd: '/workspace',
|
|
parentSession: SessionId('persist-parent'),
|
|
seedLength: source.events.length,
|
|
})
|
|
})
|
|
|
|
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 = rawLogPath(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(rawLogPath(root, undefined, m.id), 'utf8')
|
|
const committedPrefix = before // the whole committed log
|
|
|
|
// A crash tail then a repair-append.
|
|
await writeFile(rawLogPath(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(rawLogPath(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(rawLogPath(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(rawLogPath(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(rawLogPath(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('reports both the append failure and a failed rollback', async () => {
|
|
const m = meta('rollback-failure')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
|
|
const path = rawLogPath(root, undefined, m.id)
|
|
const handle = await (await import('node:fs/promises')).open(path, 'r')
|
|
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
|
|
await handle.close()
|
|
const realSync = proto.sync
|
|
let failed = false
|
|
const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
|
|
if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
|
|
return realSync.call(this)
|
|
})
|
|
const backend = ctx.sessionPersistence as unknown as {
|
|
rollbackAppend: (path: string, size: number) => Promise<void>
|
|
}
|
|
const realRollback = backend.rollbackAppend.bind(backend)
|
|
backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
|
|
|
|
try {
|
|
await ctx.sessionPersistence.append(m.id, [
|
|
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
|
|
] as SessionEvent[])
|
|
throw new Error('expected append to reject')
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(AggregateError)
|
|
const aggregate = error as AggregateError
|
|
expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
|
|
expect(aggregate.errors).toHaveLength(2)
|
|
expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
|
|
expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
|
|
} finally {
|
|
backend.rollbackAppend = realRollback
|
|
syncSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
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.
|
|
mutableHeader(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 a mismatched header before repairing either session log', async () => {
|
|
const a = meta('identity-a', '/same')
|
|
const b = meta('identity-b', '/same')
|
|
await ctx.sessionPersistence.create(a)
|
|
await ctx.sessionPersistence.append(a.id, [{
|
|
type: 'turn/start',
|
|
seq: 0,
|
|
time: 1,
|
|
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
|
}])
|
|
await ctx.sessionPersistence.create(b)
|
|
await ctx.sessionPersistence.append(b.id, oneTurnLog())
|
|
|
|
const aPath = rawLogPath(root, a.cwd, a.id)
|
|
const bPath = rawLogPath(root, b.cwd, b.id)
|
|
await rewriteHeader(aPath, (header) => { header.id = b.id })
|
|
const beforeA = await readFile(aPath)
|
|
const beforeB = await readFile(bPath)
|
|
|
|
await expect(ctx.sessionPersistence.load(a.id))
|
|
.rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/)
|
|
expect(await readFile(aPath)).toEqual(beforeA)
|
|
expect(await readFile(bPath)).toEqual(beforeB)
|
|
})
|
|
|
|
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: 0, 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('concurrent sessions do not cross buffers', async () => {
|
|
root = await freshRoot()
|
|
const ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
|
|
const a = ctx.sessions.create(SessionId('sa'))
|
|
const b = ctx.sessions.create(SessionId('sb'))
|
|
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
|
await ctx.sessions.flush(a)
|
|
await ctx.sessions.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()
|
|
})
|
|
|
|
})
|
|
|
|
|
|
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.each([
|
|
['fractional', 1.5],
|
|
['negative', -1],
|
|
['unsafe', Number.MAX_SAFE_INTEGER + 1],
|
|
])('rejects a session header with a %s createdAt', (_label, createdAt) => {
|
|
const log = JSON.stringify({
|
|
type: 'session',
|
|
version: 0,
|
|
id: 'invalid-created-at',
|
|
createdAt,
|
|
delegationDepth: 0,
|
|
}) + '\n'
|
|
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
|
|
})
|
|
|
|
it('rejects a session header with negative-zero createdAt', () => {
|
|
const log = '{"type":"session","version":0,"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n'
|
|
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
|
|
})
|
|
|
|
it.each([
|
|
['missing', undefined],
|
|
['a string', '1'],
|
|
['fractional', 1.5],
|
|
['negative', -1],
|
|
])('rejects a session header with %s delegationDepth', (_label, delegationDepth) => {
|
|
const log = JSON.stringify({
|
|
type: 'session',
|
|
version: 0,
|
|
id: 'invalid-depth',
|
|
createdAt: 1,
|
|
...delegationDepth === undefined ? {} : { delegationDepth },
|
|
}) + '\n'
|
|
expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
|
|
})
|
|
|
|
it('rejects a session header with negative-zero delegationDepth', () => {
|
|
const log = '{"type":"session","version":0,"id":"invalid-depth","createdAt":1,"delegationDepth":-0}\n'
|
|
expect(() => scanLog(Buffer.from(log))).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: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),
|
|
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. `loadCore`, not this scanner, later closes the orphaned turn.
|
|
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: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
|
|
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: 0, id: 'c', createdAt: 1, delegationDepth: 0 }),
|
|
'{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: 0, id: 'h0', createdAt: 1, delegationDepth: 0 }) + '\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: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }),
|
|
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: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
|
|
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: default packed chunk rows', () => {
|
|
let ctx: Context
|
|
beforeEach(async () => {
|
|
root = await freshRoot()
|
|
ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
// compression: 'none' — these tests assert the textual storage-record layout
|
|
// (row tags per line); packing is orthogonal to the physical encoding.
|
|
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
})
|
|
afterEach(async () => { await ctx.fiber.dispose() })
|
|
|
|
/** A one-turn log whose step streams a five-member text-delta run. */
|
|
function chunkRunLog(): SessionEvent[] {
|
|
const deltas: SessionEvent[] = Array.from({ length: 5 }, (_, k) => ({
|
|
type: 'assistant/chunk',
|
|
seq: 2 + k,
|
|
time: 3 + k,
|
|
data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } },
|
|
}))
|
|
return [
|
|
{ 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 } },
|
|
...deltas,
|
|
{ type: 'assistant/message', seq: 7, time: 8, data: { turn: 1, step: 1, content: [{ type: 'text', text: 't0t1t2t3t4' }], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append', sourceEventSeqs: [2, 3, 4, 5, 6] },
|
|
{ type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
|
|
{ type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
|
|
]
|
|
}
|
|
|
|
it('writes a delta run as one text-chunks row by default and loads back identical events', async () => {
|
|
const m = meta('packed', '/work')
|
|
const log = chunkRunLog()
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, log)
|
|
|
|
const raw = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
|
|
const tags = raw.slice(1).map(line => (JSON.parse(line) as { type: string }).type)
|
|
expect(tags).toEqual(['turn/start', 'step/start', 'text-chunks', 'assistant/message', 'step/end', 'turn/end'])
|
|
|
|
const loaded = await ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.events).toEqual(log)
|
|
})
|
|
|
|
it('packChunks: false writes one event per line and still loads identical events', async () => {
|
|
const unpackedRoot = await freshRoot()
|
|
const unpacked = new Context()
|
|
await unpacked.plugin(SessionStore)
|
|
await unpacked.plugin(SessionPersistenceJsonl, {
|
|
root: unpackedRoot,
|
|
packChunks: false,
|
|
compression: 'none',
|
|
})
|
|
try {
|
|
const m = meta('unpacked', '/work')
|
|
const log = chunkRunLog()
|
|
await unpacked.sessionPersistence.create(m)
|
|
await unpacked.sessionPersistence.append(m.id, log)
|
|
|
|
const records = (await readFile(rawLogPath(unpackedRoot, '/work', m.id), 'utf8'))
|
|
.split('\n').filter(Boolean).slice(1)
|
|
.map(line => JSON.parse(line) as { type: string })
|
|
expect(records.filter(record => record.type === 'assistant/chunk')).toHaveLength(5)
|
|
expect(records.some(record => record.type === 'text-chunks')).toBe(false)
|
|
expect((await unpacked.sessionPersistence.load(m.id)).events).toEqual(log)
|
|
} finally {
|
|
await unpacked.fiber.dispose()
|
|
}
|
|
})
|
|
|
|
it('loads a mixed file: verbatim lines from an unpacked writer, then packed appends', async () => {
|
|
const m = meta('mixed', '/work')
|
|
const log = chunkRunLog()
|
|
// First turn written line-per-event by an unpacked-config writer (an old
|
|
// file, hand-planted so this packed-config backend adopts it on load).
|
|
await mkdir(sessionDir(root, '/work', m.id), { recursive: true })
|
|
await writeFile(rawLogPath(root, '/work', m.id), [
|
|
JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }),
|
|
...log.map(e => JSON.stringify(e)),
|
|
].join('\n') + '\n')
|
|
// Adopt the stored log (cursor = stored length), then append a second turn
|
|
// through THIS packed-config backend.
|
|
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(log)
|
|
const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[]
|
|
for (const [k, e] of secondTurn.entries()) {
|
|
;(e as { seq: number }).seq = 10 + k
|
|
;(e.data as { turn: number }).turn = 2
|
|
}
|
|
await ctx.sessionPersistence.append(m.id, secondTurn)
|
|
|
|
const loaded = await ctx.sessionPersistence.load(m.id)
|
|
expect(loaded.events).toEqual([...log, ...secondTurn])
|
|
// The packed append really packed: the file's tail carries a text-chunks row.
|
|
const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
|
|
.map(line => (JSON.parse(line) as { type: string }).type)
|
|
expect(tags.filter(t => t === 'text-chunks')).toHaveLength(1)
|
|
expect(tags.filter(t => t === 'assistant/chunk')).toHaveLength(5)
|
|
})
|
|
|
|
it('scanLog: a packed row advances the seq cursor by its whole run', () => {
|
|
const logText = [
|
|
JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
|
|
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
|
JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
|
JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
|
|
].join('\n') + '\n'
|
|
const { events } = scanLog(Buffer.from(logText))
|
|
expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
|
|
expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } })
|
|
})
|
|
|
|
it('scanLog: a malformed packed row in the committed region rejects like corrupt JSON', () => {
|
|
const logText = [
|
|
JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }),
|
|
// dt arity mismatch — row validation throws, so the line is a committed hole.
|
|
JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
|
|
JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
|
|
].join('\n') + '\n'
|
|
expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/)
|
|
})
|
|
|
|
it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
|
|
const logText = [
|
|
JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }),
|
|
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
|
// seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
|
|
JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
|
|
].join('\n') + '\n'
|
|
const scanned = scanLog(Buffer.from(logText))
|
|
expect(scanned.events.map(e => e.seq)).toEqual([0])
|
|
// committedBytes stays on the line boundary BEFORE the dropped row.
|
|
const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n'
|
|
expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8'))
|
|
})
|
|
|
|
it('eventLines(packChunks: false) is byte-identical to the pre-packing layout', () => {
|
|
const log = chunkRunLog()
|
|
expect(eventLines(log, false)).toBe(log.map(e => JSON.stringify(e)).join('\n'))
|
|
})
|
|
})
|
|
|
|
describe('SessionPersistenceJsonl: edge cases', () => {
|
|
let ctx: Context
|
|
beforeEach(async () => {
|
|
root = await freshRoot()
|
|
ctx = new Context()
|
|
await ctx.plugin(SessionStore)
|
|
await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
})
|
|
afterEach(async () => { await ctx.fiber.dispose() })
|
|
|
|
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('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.
|
|
mutableHeader(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(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
|
|
await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
|
|
})
|
|
|
|
it('list discovers sessions across multiple project directories', 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 project directory
|
|
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('groups sessions whose cwd paths normalize to the same project directory', async () => {
|
|
const first = meta('normalized-first', '/a/b-c')
|
|
const second = meta('normalized-second', '/a-b/c')
|
|
await ctx.sessionPersistence.create(first)
|
|
await ctx.sessionPersistence.append(first.id, oneTurnLog())
|
|
await ctx.sessionPersistence.create(second)
|
|
await ctx.sessionPersistence.append(second.id, oneTurnLog())
|
|
|
|
expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd))
|
|
expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([
|
|
encodeSegment(first.id),
|
|
encodeSegment(second.id),
|
|
]))
|
|
expect((await ctx.sessionPersistence.list()).map(header => header.id).sort())
|
|
.toEqual([first.id, second.id].sort())
|
|
})
|
|
|
|
it('list on an empty root returns nothing', async () => {
|
|
expect(await ctx.sessionPersistence.list()).toEqual([])
|
|
})
|
|
|
|
it('keeps the transcript in an extensible session-owned directory', async () => {
|
|
const m = meta('owned-directory', '/project')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const dir = sessionDir(root, m.cwd, m.id)
|
|
await writeFile(join(dir, 'metadata.json'), '{}\n')
|
|
await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n')
|
|
await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true })
|
|
|
|
expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl']))
|
|
expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
|
|
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
|
|
})
|
|
|
|
it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => {
|
|
const m = meta('legacy-flat', '/legacy')
|
|
const project = projectDir(root, m.cwd)
|
|
const path = join(project, `${encodeSegment(m.id)}.jsonl`)
|
|
await mkdir(project, { recursive: true })
|
|
await writeFile(path, [
|
|
JSON.stringify(toHeaderLine(m)),
|
|
...oneTurnLog().map(event => JSON.stringify(event)),
|
|
'',
|
|
].join('\n'))
|
|
|
|
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
|
|
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/)
|
|
})
|
|
|
|
it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => {
|
|
const m = meta('legacy-compressed-flat', '/legacy')
|
|
const project = projectDir(root, m.cwd)
|
|
expect(await ctx.sessionPersistence.list()).toEqual([])
|
|
await mkdir(project, { recursive: true })
|
|
await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy')
|
|
|
|
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
|
|
})
|
|
|
|
it('list skips empty and non-header session logs (metadata-only read)', async () => {
|
|
// A real session…
|
|
await ctx.sessionPersistence.create(meta('real', '/p'))
|
|
await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
|
|
// …alongside junk session directories whose fixed transcript is empty or
|
|
// lacks a header. Both remain unmaterialized and are skipped.
|
|
for (const [id, content] of [
|
|
['empty', ''],
|
|
['notheader', '{"type":"turn/start"}\n'],
|
|
['badjson', 'not json at all\n'],
|
|
] as const) {
|
|
const path = rawLogPath(root, undefined, SessionId(id))
|
|
await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true })
|
|
await writeFile(path, content)
|
|
}
|
|
|
|
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 () => {
|
|
// A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
|
|
// `readFirstLine` accumulates chunks before `list()` parses it.
|
|
const id = SessionId('big')
|
|
await mkdir(sessionDir(root, undefined, id), { recursive: true })
|
|
const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) })
|
|
await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n')
|
|
const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
|
|
expect(ids).toContain('big')
|
|
})
|
|
|
|
it('list rejects a header whose cwd does not identify its physical log', async () => {
|
|
const m = meta('misplaced', '/stored')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' })
|
|
|
|
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/)
|
|
})
|
|
|
|
it('accepts an alternate project path only when it identifies the same physical log', async () => {
|
|
const m = meta('physical-alias', '/stored')
|
|
await ctx.sessionPersistence.create(m)
|
|
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
|
const path = rawLogPath(root, m.cwd, m.id)
|
|
const aliasCwd = '/alias'
|
|
await symlink(
|
|
projectDir(root, m.cwd),
|
|
projectDir(root, aliasCwd),
|
|
process.platform === 'win32' ? 'junction' : 'dir',
|
|
)
|
|
await rewriteHeader(path, (header) => { header.cwd = aliasCwd })
|
|
|
|
expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd)
|
|
expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
|
|
})
|
|
|
|
it('list rejects a session header whose id cannot name a storage path', async () => {
|
|
const dir = join(projectDir(root, undefined), 'invalid-id')
|
|
await mkdir(dir, { recursive: true })
|
|
await writeFile(join(dir, 'session.jsonl'), JSON.stringify({
|
|
type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0,
|
|
}) + '\n')
|
|
|
|
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/)
|
|
})
|
|
|
|
it('load and list reject one id materialized in multiple project directories', async () => {
|
|
const id = SessionId('duplicate')
|
|
for (const cwd of ['/a', '/b']) {
|
|
const m = meta(id, cwd)
|
|
await mkdir(sessionDir(root, cwd, id), { recursive: true })
|
|
const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n'
|
|
await writeFile(rawLogPath(root, cwd, id), content)
|
|
}
|
|
|
|
await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/)
|
|
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/)
|
|
})
|
|
|
|
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(SessionId('reuse'), { meta: { cwd: '/a' } })
|
|
appendLog(a, oneTurnLog())
|
|
}, { 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.sessions.flush(s)
|
|
await sessFiberA.dispose()
|
|
|
|
// A new Session object reuses the id. Object-keyed initialization must run independently,
|
|
// detect the disk collision, and reject instead of appending through session A's stale cursor.
|
|
let b!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
|
|
}, { inject: ['sessions'] }))
|
|
await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
|
|
})
|
|
|
|
it('a no-cwd live session cannot adopt a same-id log from another cwd', async () => {
|
|
// Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then
|
|
// dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map —
|
|
// the HMR/reload path with no tracked collision state).
|
|
await ctx.sessionPersistence.create(meta('x', '/w'))
|
|
await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
|
|
await ctx.fiber.dispose()
|
|
|
|
// Backend 2 creates a no-cwd session whose id exists only in `/w`. The
|
|
// stored cwd check rejects instead of grafting no-cwd events onto that log.
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
let b!: Session
|
|
await ctx2.plugin(Object.assign((inner: Context) => {
|
|
b = inner.sessions.create(SessionId('x')) // no cwd
|
|
}, { inject: ['sessions'] }))
|
|
await expect(ctx2.sessions.flush(b)).rejects.toThrow(/different cwd|id collision/)
|
|
|
|
// The "/w" log is untouched — no no-cwd events were grafted onto it, and no
|
|
// `_no-cwd` log for "x" was created.
|
|
const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x'))))
|
|
expect(inW.meta.cwd).toBe('/w')
|
|
expect(inW.events).toHaveLength(6)
|
|
await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow()
|
|
await ctx2.fiber.dispose()
|
|
})
|
|
|
|
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'))
|
|
|
|
// 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(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
|
|
}, { inject: ['sessions'] }))
|
|
await expect(ctx.sessions.flush(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(SessionId('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.sessions.flush(s)
|
|
await firstFiber.dispose()
|
|
|
|
let second!: Session
|
|
await ctx.plugin(Object.assign((inner: Context) => {
|
|
second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
|
|
}, { inject: ['sessions'] }))
|
|
await expect(ctx.sessions.flush(second))
|
|
.rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
|
|
})
|
|
|
|
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'),
|
|
compression: 'none',
|
|
})
|
|
expect(await ctx2.sessionPersistence.list()).toEqual([])
|
|
await ctx2.fiber.dispose()
|
|
})
|
|
|
|
it('plugin load rejects an existing root that is not a directory', async () => {
|
|
const filePath = join(root, 'not-a-dir')
|
|
await writeFile(filePath, 'x')
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
await expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
|
|
await ctx2.fiber.dispose()
|
|
})
|
|
|
|
it('list surfaces a root that becomes unusable after plugin load', async () => {
|
|
await rm(root, { recursive: true })
|
|
await writeFile(root, 'not a directory')
|
|
|
|
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
|
|
})
|
|
|
|
it('per-id lookup surfaces non-ENOENT storage errors', async () => {
|
|
const blocker = join(root, 'not-a-directory')
|
|
await writeFile(blocker, 'x')
|
|
const backend = ctx.sessionPersistence as unknown as { exists(path: string): Promise<boolean> }
|
|
|
|
await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
|
|
})
|
|
|
|
it('materialization surfaces a project-directory storage fault', async () => {
|
|
const cwd = '/x'
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
await writeFile(projectDir(root, cwd), 'x') // project path is now a file
|
|
let s!: Session
|
|
await ctx2.plugin(Object.assign((inner: Context) => {
|
|
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
|
|
appendClosedTurn(s)
|
|
}, { inject: ['sessions'] }))
|
|
await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|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(rawLogPath(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, compression: 'none' })
|
|
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('createCore rejects an id already on disk under a different project directory', 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
|
|
// identifies by id across all projects, so a second log would make resume
|
|
// nondeterministic. create scans every project, not just meta.cwd's.
|
|
const ctx2 = new Context()
|
|
await ctx2.plugin(SessionStore)
|
|
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
|
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, compression: 'none' })
|
|
const session = ctx2.sessions.create(SessionId('flush-fail'))
|
|
// A full turn lands in the write-behind buffer.
|
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
|
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
|
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 expectFlushError(ctx2.sessions.flush(session), /disk full/)
|
|
// The events are STILL buffered (not silently dropped): a retry persists them.
|
|
backend.materialize = origMat
|
|
await ctx2.sessions.flush(session)
|
|
const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
|
|
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2])
|
|
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.list()).map(h => h.id)).not.toContain(m.id)
|
|
})
|
|
|
|
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.list()).map(h => h.id)).toContain(m.id)
|
|
})
|
|
|
|
it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
|
|
const session = ctx.sessions.create(SessionId('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 error therefore surfaces synchronously at append, not later during backend flush.
|
|
expect(() => {
|
|
session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
|
|
}).toThrow(/non-JSON-serializable/)
|
|
// The bad event was rejected, so the log stayed empty.
|
|
expect(session.events.length).toBe(0)
|
|
})
|
|
|
|
})
|