jsonl.spec.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
  4. import type { FileHandle } from 'node:fs/promises'
  5. import { tmpdir } from 'node:os'
  6. import { isAbsolute, join, relative, resolve } from 'node:path'
  7. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  8. import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  9. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  10. import { encodeSegment, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
  11. import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
  12. import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
  13. let root: string
  14. const dirs: string[] = []
  15. type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
  16. /** Test-only mutable view used to verify that backends detach returned/caller metadata. */
  17. function mutableHeader(header: SessionHeader): MutableSessionHeader {
  18. return header
  19. }
  20. async function expectParallelFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
  21. try {
  22. await promise
  23. } catch (error) {
  24. expect(error).toBeInstanceOf(AggregateError)
  25. const [cause] = (error as AggregateError).errors as unknown[]
  26. expect(cause).toBeInstanceOf(Error)
  27. expect((cause as Error).message).toMatch(message)
  28. return
  29. }
  30. throw new Error('expected parallel flush to reject')
  31. }
  32. async function freshRoot(): Promise<string> {
  33. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  34. dirs.push(dir)
  35. return dir
  36. }
  37. function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
  38. return logPath(root, cwd, id, 'none')
  39. }
  40. afterEach(async () => {
  41. vi.restoreAllMocks()
  42. for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
  43. })
  44. async function rejectDirectorySync(code: string): Promise<void> {
  45. const handle = await open(root, 'r')
  46. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  47. await handle.close()
  48. const realSync = proto.sync
  49. vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
  50. if ((await this.stat()).isDirectory()) {
  51. const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
  52. error.code = code
  53. throw error
  54. }
  55. return realSync.call(this)
  56. })
  57. }
  58. function appendClosedTurn(session: Session): void {
  59. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  60. session.append('user/message', {
  61. content: [{ type: 'text', text: 'hello' }],
  62. source: { kind: 'user' },
  63. }, { surfaceOp: 'append' })
  64. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  65. }
  66. // Run the shared backend contract against the real JSONL backend.
  67. runPersistenceContract('jsonl-none', async () => {
  68. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  69. const ctx = new Context()
  70. await ctx.plugin(SessionStore)
  71. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
  72. return {
  73. persistence: ctx.sessionPersistence,
  74. dispose: async () => {
  75. await fiber.dispose()
  76. await rm(dir, { recursive: true, force: true })
  77. },
  78. }
  79. })
  80. // Two mounts share this temp root to exercise reload. `corruptTail` appends a partial,
  81. // newline-less fragment past the committed region so coordinator repair runs on real file bytes.
  82. runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
  83. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
  84. return {
  85. mount: async (ctx) => {
  86. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
  87. return fiber
  88. },
  89. corruptTail: async (id, cwd) => {
  90. // A half-written record with no trailing newline: scanLog treats it as an
  91. // uncommitted crash fragment and reports committedBytes < byteLength, so
  92. // the coordinator sees a tornMarker to truncate.
  93. await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
  94. },
  95. cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
  96. }
  97. })
  98. describe('SessionPersistenceJsonl: format helpers', () => {
  99. it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
  100. expect(encodeSegment('..')).toBe('~002E~002E')
  101. expect(encodeSegment('.')).toBe('~002E')
  102. expect(encodeSegment('a/b')).toBe('a~002Fb')
  103. expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd')
  104. expect(encodeSegment('a\u0000b')).toBe('a~0000b')
  105. expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through
  106. expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped
  107. })
  108. it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => {
  109. // Distinct lone surrogates must NOT collide (Buffer.from would normalize
  110. // both to U+FFFD; code-unit escaping keeps them distinct).
  111. const hi = encodeSegment(String.fromCharCode(0xD800))
  112. const lo = encodeSegment(String.fromCharCode(0xDC00))
  113. expect(hi).toBe('~D800')
  114. expect(lo).toBe('~DC00')
  115. expect(hi).not.toBe(lo)
  116. // A literal "~002F" input cannot collide with the encoding of "/".
  117. expect(encodeSegment('~002F')).not.toBe(encodeSegment('/'))
  118. })
  119. it('encodeSegment rejects an empty id', () => {
  120. expect(() => encodeSegment('')).toThrow(/empty/)
  121. })
  122. it('resolves a relative custom root before locating a session', async () => {
  123. const absoluteRoot = await freshRoot()
  124. const ctx = new Context()
  125. await ctx.plugin(SessionStore)
  126. const fiber = await ctx.plugin(SessionPersistenceJsonl, {
  127. root: relative(process.cwd(), absoluteRoot),
  128. compression: 'none',
  129. })
  130. const m = meta('relative-location', '/work')
  131. expect(ctx.sessionPersistence.locate(m)).toEqual({
  132. kind: 'jsonl',
  133. path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
  134. })
  135. await fiber.dispose()
  136. })
  137. })
  138. describe('SessionPersistenceJsonl: durability and crash semantics', () => {
  139. let ctx: Context
  140. beforeEach(async () => {
  141. root = await freshRoot()
  142. ctx = new Context()
  143. await ctx.plugin(SessionStore)
  144. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  145. })
  146. afterEach(async () => { await ctx.fiber.dispose() })
  147. it('lazy materialization: create() writes no file until the first append', async () => {
  148. const m = meta('lazy', '/work')
  149. const location = ctx.sessionPersistence.locate(m)
  150. expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) })
  151. expect(isAbsolute(location!.path)).toBe(true)
  152. await ctx.sessionPersistence.create(m)
  153. // locate() is a pure target-path calculation: neither it nor create()
  154. // materializes a file before the first append.
  155. const dir = sessionDir(root, '/work')
  156. await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
  157. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  158. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  159. // now materialized
  160. expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
  161. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  162. void dir
  163. })
  164. it('keeps the same location on resume and gives a fork its own location', async () => {
  165. const parent = meta('location-parent', '/work')
  166. const parentLocation = ctx.sessionPersistence.locate(parent)
  167. await ctx.sessionPersistence.create(parent)
  168. await ctx.sessionPersistence.append(parent.id, oneTurnLog())
  169. const loaded = await ctx.sessionPersistence.load(parent.id)
  170. expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation)
  171. const child = {
  172. ...loaded.meta,
  173. id: SessionId('location-child'),
  174. parentSession: parent.id,
  175. seedLength: loaded.events.length,
  176. }
  177. const childLocation = ctx.sessionPersistence.locate(child)
  178. expect(childLocation?.path).not.toBe(parentLocation?.path)
  179. expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) })
  180. })
  181. it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
  182. const m = meta('chunks')
  183. const log: SessionEvent[] = [
  184. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  185. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  186. { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
  187. { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
  188. { 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] },
  189. { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
  190. { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
  191. ]
  192. await ctx.sessionPersistence.create(m)
  193. await ctx.sessionPersistence.append(m.id, log)
  194. const loaded = await ctx.sessionPersistence.load(m.id)
  195. expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
  196. })
  197. it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
  198. const m = meta('legacy-header-delta', '/legacy')
  199. const path = rawLogPath(root, m.cwd, m.id)
  200. await mkdir(sessionDir(root, m.cwd), { recursive: true })
  201. await writeFile(path, [
  202. JSON.stringify(toHeaderLine(m)),
  203. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  204. JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
  205. JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  206. '',
  207. ].join('\n'))
  208. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
  209. })
  210. it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
  211. const m = meta('legacy-header-fallback', '/legacy')
  212. const path = rawLogPath(root, m.cwd, m.id)
  213. await mkdir(sessionDir(root, m.cwd), { recursive: true })
  214. await writeFile(path, [
  215. JSON.stringify(toHeaderLine(m)),
  216. JSON.stringify({
  217. type: 'request/header',
  218. seq: 0,
  219. time: 1,
  220. data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
  221. }),
  222. '',
  223. ].join('\n'))
  224. await expect(ctx.sessionPersistence.load(m.id))
  225. .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
  226. })
  227. it('persists a forked child seed through the existing session write path', async () => {
  228. const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
  229. appendClosedTurn(source)
  230. const child = ctx.sessions.fork(source, undefined, SessionId('persist-child'))
  231. await ctx.parallel('session/flush', child)
  232. const loaded = await ctx.sessionPersistence.load(child.id)
  233. expect(loaded.events).toEqual(source.events)
  234. expect(loaded.meta).toMatchObject({
  235. id: SessionId('persist-child'),
  236. cwd: '/workspace',
  237. parentSession: SessionId('persist-parent'),
  238. seedLength: source.events.length,
  239. })
  240. })
  241. it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
  242. const m = meta('crash', '/proj')
  243. await ctx.sessionPersistence.create(m)
  244. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5, turn/end at 5
  245. // Simulate a crash mid-second-turn: append raw lines that are NOT closed by
  246. // a turn/end (turn/start + step/start are fully written), plus a final
  247. // partial line with no newline (a torn fragment never fully flushed).
  248. const path = rawLogPath(root, '/proj', m.id)
  249. await writeFile(path, [
  250. JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  251. JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
  252. '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
  253. ].join('\n'), { flag: 'a' })
  254. // load PRESERVES the interrupted turn's real events (turn/start 6, step/start
  255. // 7) — a turn can be huge, so they must not be truncated — and durably closes
  256. // the orphaned turn with synthetic step/end (8) + turn/end {interrupted} (9).
  257. const loaded = await ctx.sessionPersistence.load(m.id)
  258. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  259. const last = loaded.events.at(-1)!
  260. expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
  261. const stepEnd = loaded.events[8]!
  262. expect(stepEnd.type).toBe('step/end')
  263. // the torn seq-8 chunk fragment did not survive
  264. expect(loaded.events.some(e => e.type === 'assistant/chunk' && e.seq === 8)).toBe(false)
  265. // The next append continues at seq 10 (the balanced length).
  266. const turn3 = [
  267. { type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
  268. { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
  269. ] as SessionEvent[]
  270. await ctx.sessionPersistence.append(m.id, turn3)
  271. const reloaded = await ctx.sessionPersistence.load(m.id)
  272. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
  273. })
  274. it('committed events are never rewritten: only the crash tail is repaired', async () => {
  275. const m = meta('append-only')
  276. await ctx.sessionPersistence.create(m)
  277. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  278. const before = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
  279. const committedPrefix = before // the whole committed log
  280. // A crash tail then a repair-append.
  281. await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
  282. await ctx.sessionPersistence.load(m.id)
  283. await ctx.sessionPersistence.append(m.id, [
  284. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  285. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  286. ] as SessionEvent[])
  287. const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
  288. // the committed prefix is byte-for-byte intact at the head of the file
  289. expect(after.startsWith(committedPrefix)).toBe(true)
  290. })
  291. it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => {
  292. const m = meta('truncate-retry')
  293. await ctx.sessionPersistence.create(m)
  294. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5
  295. const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size
  296. // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
  297. // has already put bytes on disk — simulating an ENOSPC/fsync error
  298. // mid-append. The recovery truncate() also fsyncs, so allow that one.
  299. const handle = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r')
  300. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  301. await handle.close()
  302. const realSync = proto.sync
  303. let failed = false
  304. const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  305. if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') }
  306. return realSync.call(this)
  307. })
  308. const turn2 = [
  309. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  310. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  311. ] as SessionEvent[]
  312. // The append rejects, but the partial bytes are truncated back: the file is
  313. // its pre-append size and the cursor is unchanged.
  314. await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/)
  315. expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore)
  316. spy.mockRestore()
  317. // The retry now succeeds with NO seq gap — the log is contiguous 0..7.
  318. await ctx.sessionPersistence.append(m.id, turn2)
  319. const loaded = await ctx.sessionPersistence.load(m.id)
  320. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  321. })
  322. it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
  323. await rejectDirectorySync('EPERM')
  324. const backend = ctx.sessionPersistence as SessionPersistenceJsonl
  325. backend.internals.platform = 'win32'
  326. const m = meta('windows-directory-sync')
  327. await ctx.sessionPersistence.create(m)
  328. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
  329. expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
  330. })
  331. it.each([
  332. ['linux', 'EPERM'],
  333. ['win32', 'EIO'],
  334. ] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
  335. await rejectDirectorySync(code)
  336. const backend = ctx.sessionPersistence as SessionPersistenceJsonl
  337. backend.internals.platform = platform
  338. const m = meta(`directory-sync-${platform}-${code}`)
  339. await ctx.sessionPersistence.create(m)
  340. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
  341. })
  342. it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
  343. const m = meta('meta-copy', '/proj')
  344. await ctx.sessionPersistence.create(m)
  345. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  346. const loaded = await ctx.sessionPersistence.load(m.id)
  347. // A consumer mutates the returned meta's cwd. The backend's stored pathing
  348. // metadata must be unaffected, so a later append still finds the right log.
  349. mutableHeader(loaded.meta).cwd = '/evil'
  350. await ctx.sessionPersistence.append(m.id, [
  351. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  352. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  353. ] as SessionEvent[])
  354. // The append landed in the ORIGINAL /proj log, not beside an /evil path.
  355. const reloaded = await ctx.sessionPersistence.load(m.id)
  356. expect(reloaded.meta.cwd).toBe('/proj')
  357. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  358. })
  359. it('rejects a re-append of an already-stored seq', async () => {
  360. const m = meta('reappend')
  361. await ctx.sessionPersistence.create(m)
  362. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  363. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow(/seq mismatch/)
  364. })
  365. it('path-traversal session ids are neutralized (no escape from root)', async () => {
  366. const evil = SessionId('../../etc/pwn')
  367. const m = { version: 0, id: evil, createdAt: 1 }
  368. await ctx.sessionPersistence.create(m)
  369. await ctx.sessionPersistence.append(evil, oneTurnLog())
  370. // The file lives UNDER root, not at ../../etc.
  371. const all: string[] = []
  372. async function walk(dir: string): Promise<void> {
  373. for (const e of await readdir(dir, { withFileTypes: true })) {
  374. const p = join(dir, e.name)
  375. if (e.isDirectory()) await walk(p)
  376. else all.push(p)
  377. }
  378. }
  379. await walk(root)
  380. expect(all.length).toBeGreaterThan(0)
  381. expect(all.every(p => p.startsWith(root))).toBe(true)
  382. })
  383. })
  384. describe('SessionPersistenceJsonl: write path (session/event → flush)', () => {
  385. it('concurrent sessions do not cross buffers', async () => {
  386. root = await freshRoot()
  387. const ctx = new Context()
  388. await ctx.plugin(SessionStore)
  389. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  390. const a = ctx.sessions.create(SessionId('sa'))
  391. const b = ctx.sessions.create(SessionId('sb'))
  392. a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  393. b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  394. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  395. b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  396. await ctx.parallel('session/flush', a)
  397. await ctx.parallel('session/flush', b)
  398. const la = await ctx.sessionPersistence.load(SessionId('sa'))
  399. const lb = await ctx.sessionPersistence.load(SessionId('sb'))
  400. expect(JSON.stringify(la.events)).toContain('"A"')
  401. expect(JSON.stringify(la.events)).not.toContain('"B"')
  402. expect(JSON.stringify(lb.events)).toContain('"B"')
  403. expect(JSON.stringify(lb.events)).not.toContain('"A"')
  404. await ctx.fiber.dispose()
  405. })
  406. })
  407. describe('SessionPersistenceJsonl: scanLog unit', () => {
  408. it('rejects a header-less / empty log', () => {
  409. expect(() => scanLog(Buffer.from(''))).toThrow()
  410. })
  411. it('rejects a corrupt header line', () => {
  412. expect(() => scanLog(Buffer.from('not json\n'))).toThrow(/header/)
  413. })
  414. it('rejects a non-session first line', () => {
  415. expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
  416. })
  417. it.each([
  418. ['missing', undefined],
  419. ['a string', '1'],
  420. ['fractional', 1.5],
  421. ['negative', -1],
  422. ])('rejects a session header with %s delegationDepth', (_label, delegationDepth) => {
  423. const log = JSON.stringify({
  424. type: 'session',
  425. version: 0,
  426. id: 'invalid-depth',
  427. createdAt: 1,
  428. ...delegationDepth === undefined ? {} : { delegationDepth },
  429. }) + '\n'
  430. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  431. })
  432. it('rejects a session header with negative-zero delegationDepth', () => {
  433. const log = '{"type":"session","version":0,"id":"invalid-depth","createdAt":1,"delegationDepth":-0}\n'
  434. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  435. })
  436. it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
  437. const log = [
  438. JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),
  439. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  440. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  441. ].join('\n') + '\n'
  442. // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
  443. // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and
  444. // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn.
  445. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  446. })
  447. it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
  448. const log = [
  449. JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
  450. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  451. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  452. JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  453. ].join('\n') + '\n'
  454. // A turn/end exists, so the prefix up to it is committed — but it has a hole.
  455. // Truncating it would silently drop committed data → unloadable.
  456. expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap in committed region/)
  457. })
  458. it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => {
  459. const log = [
  460. JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }),
  461. '{not json', // corrupt, sits in the committed region (a turn/end follows)
  462. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  463. ].join('\n') + '\n'
  464. expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
  465. })
  466. it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
  467. const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1, delegationDepth: 0 }) + '\n'
  468. const scanned = scanLog(Buffer.from(log))
  469. expect(scanned.events).toEqual([])
  470. // committedBytes falls back to the header line's end (no preserved events).
  471. expect(scanned.committedBytes).toBe(Buffer.byteLength(log, 'utf8'))
  472. })
  473. it('a corrupt line after the last turn/end bounds the preserved tail', () => {
  474. const log = [
  475. JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }),
  476. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  477. '{not json', // corrupt crash fragment, no turn/end committed
  478. ].join('\n') + '\n'
  479. // The contiguous prefix (turn/start seq 0) is preserved; the corrupt
  480. // fragment after it is the tolerated crash boundary.
  481. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  482. })
  483. it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
  484. const log = [
  485. JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
  486. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  487. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  488. JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
  489. ].join('\n') + '\n'
  490. const { events } = scanLog(Buffer.from(log))
  491. expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
  492. })
  493. })
  494. describe('SessionPersistenceJsonl: edge cases', () => {
  495. let ctx: Context
  496. beforeEach(async () => {
  497. root = await freshRoot()
  498. ctx = new Context()
  499. await ctx.plugin(SessionStore)
  500. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  501. })
  502. afterEach(async () => { await ctx.fiber.dispose() })
  503. it('append rejects non-JSON-serializable undefined-producing data', async () => {
  504. const m = meta('undef')
  505. await ctx.sessionPersistence.create(m)
  506. // A value whose JSON.stringify yields undefined (a bare function as data).
  507. const bad = [{ type: 'user/message', seq: 0, time: 1, data: (() => 0) as unknown }] as unknown as SessionEvent[]
  508. await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/)
  509. })
  510. it('create snapshots its meta: mutating the caller object after the call is ignored', async () => {
  511. const m = meta('create-snap', '/orig')
  512. const p = ctx.sessionPersistence.create(m)
  513. // Mutate the caller's meta object immediately after calling create.
  514. mutableHeader(m).cwd = '/mutated'
  515. await p
  516. await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
  517. // The log materialized under the ORIGINAL cwd, not the mutated one.
  518. expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
  519. await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
  520. })
  521. it('list discovers sessions across multiple cwd buckets', async () => {
  522. await ctx.sessionPersistence.create(meta('p1', '/projA'))
  523. await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog())
  524. await ctx.sessionPersistence.create(meta('p2', '/projB'))
  525. await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog())
  526. await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd bucket
  527. await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog())
  528. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  529. expect(ids).toEqual(['p1', 'p2', 'p3'])
  530. })
  531. it('list on an empty root returns nothing', async () => {
  532. expect(await ctx.sessionPersistence.list()).toEqual([])
  533. })
  534. it('list skips empty and non-header .jsonl files (metadata-only read)', async () => {
  535. // A real session…
  536. await ctx.sessionPersistence.create(meta('real', '/p'))
  537. await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
  538. // …alongside two junk files in the _no-cwd bucket: an EMPTY file (readFirstLine
  539. // returns undefined) and a file whose first line is not a session header
  540. // (parseHeaderMeta returns undefined). Both are skipped, not listed.
  541. const bucket = join(root, '_no-cwd')
  542. await mkdir(bucket, { recursive: true })
  543. await writeFile(join(bucket, 'empty.jsonl'), '')
  544. await writeFile(join(bucket, 'notheader.jsonl'), '{"type":"turn/start"}\n')
  545. await writeFile(join(bucket, 'badjson.jsonl'), 'not json at all\n')
  546. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  547. expect(ids).toEqual(['real'])
  548. })
  549. it('list reads a header line longer than the 8KB read chunk', async () => {
  550. // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
  551. // `readFirstLine` accumulates chunks before `list()` parses it.
  552. const bucket = join(root, '_no-cwd')
  553. await mkdir(bucket, { recursive: true })
  554. const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) })
  555. await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n')
  556. const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
  557. expect(ids).toContain('big')
  558. })
  559. it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
  560. // Session A materializes a log under id "reuse".
  561. const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
  562. const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  563. appendLog(a, oneTurnLog())
  564. }, { inject: ['sessions'] }))
  565. // Drain A, then dispose ITS fiber (the live session A is gone) while the
  566. // backend stays loaded.
  567. for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
  568. await sessFiberA.dispose()
  569. // A new Session object reuses the id. Object-keyed initialization must run independently,
  570. // detect the disk collision, and reject instead of appending through session A's stale cursor.
  571. let b!: Session
  572. await ctx.plugin(Object.assign((inner: Context) => {
  573. b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  574. }, { inject: ['sessions'] }))
  575. await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
  576. })
  577. it('a NO-CWD live session does NOT cross-cwd-adopt a same-id log from a real cwd bucket (loadLive is scope-exact)', async () => {
  578. // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then
  579. // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map —
  580. // the HMR/reload path where onCreated goes through loadLive, not a tracked
  581. // collision).
  582. await ctx.sessionPersistence.create(meta('x', '/w'))
  583. await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
  584. await ctx.fiber.dispose()
  585. // Backend 2 creates a no-cwd session whose id exists only in `/w`. Exact `loadLive(id,
  586. // undefined)` must not adopt across buckets; the any-cwd collision check then rejects instead
  587. // of grafting no-cwd events onto a log with mismatched cwd.
  588. const ctx2 = new Context()
  589. await ctx2.plugin(SessionStore)
  590. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  591. let b!: Session
  592. await ctx2.plugin(Object.assign((inner: Context) => {
  593. b = inner.sessions.create(SessionId('x')) // no cwd
  594. }, { inject: ['sessions'] }))
  595. await expect(ctx2.sessions.flush(b)).rejects.toThrow(/already has a persisted log on disk/)
  596. // The "/w" log is untouched — no no-cwd events were grafted onto it, and no
  597. // `_no-cwd` log for "x" was created.
  598. const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x'))))
  599. expect(inW.meta.cwd).toBe('/w')
  600. expect(inW.events).toHaveLength(6)
  601. await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow()
  602. await ctx2.fiber.dispose()
  603. })
  604. it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => {
  605. // Materialize and load (ownerless, cursor = 6).
  606. await ctx.sessionPersistence.create(meta('divergent', '/a'))
  607. await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog())
  608. await ctx.sessionPersistence.load(SessionId('divergent'))
  609. // A seed that keeps every seq/type/time but mutates a payload must NOT be
  610. // accepted as "the same session" — otherwise drain filters those seqs as
  611. // already persisted and the divergent payload is silently lost.
  612. const tampered = oneTurnLog()
  613. const userMsg = tampered[1]
  614. if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }]
  615. let bad!: Session
  616. await ctx.plugin(Object.assign((inner: Context) => {
  617. bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
  618. }, { inject: ['sessions'] }))
  619. await expect(ctx.sessions.flush(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
  620. })
  621. it('a second live session reusing a bound id is rejected', async () => {
  622. // A live session materializes and owns the id.
  623. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  624. const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  625. a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  626. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  627. }, { inject: ['sessions'] }))
  628. for (const s of ctx.sessions.list()) await ctx.parallel('session/flush', s)
  629. await firstFiber.dispose()
  630. let second!: Session
  631. await ctx.plugin(Object.assign((inner: Context) => {
  632. second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  633. }, { inject: ['sessions'] }))
  634. await expect(ctx.sessions.flush(second))
  635. .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
  636. })
  637. it('list returns nothing when the root directory does not exist', async () => {
  638. const ctx2 = new Context()
  639. await ctx2.plugin(SessionStore)
  640. await ctx2.plugin(SessionPersistenceJsonl, {
  641. root: join(root, 'does-not-exist-yet'),
  642. compression: 'none',
  643. })
  644. expect(await ctx2.sessionPersistence.list()).toEqual([])
  645. await ctx2.fiber.dispose()
  646. })
  647. it('list surfaces a non-ENOENT root error (ENOTDIR) instead of reporting no sessions', async () => {
  648. // A durable backend must not collapse a storage fault to "no sessions". Making the root a
  649. // regular file forces ENOTDIR from `readdir`, which must propagate.
  650. const filePath = join(root, 'not-a-dir')
  651. await writeFile(filePath, 'x')
  652. const ctx2 = new Context()
  653. await ctx2.plugin(SessionStore)
  654. await ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })
  655. await expect(ctx2.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
  656. await ctx2.fiber.dispose()
  657. })
  658. it('loadLive surfaces a non-ENOENT lookup error (ENOTDIR) instead of reporting absent', async () => {
  659. // A non-ENOENT per-id open error must surface rather than become "not found" and permit false
  660. // live adoption. Making the cwd bucket a regular file forces ENOTDIR for its child log path.
  661. const cwd = '/x'
  662. const ctx2 = new Context()
  663. await ctx2.plugin(SessionStore)
  664. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  665. await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
  666. let s!: Session
  667. await ctx2.plugin(Object.assign((inner: Context) => {
  668. s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
  669. }, { inject: ['sessions'] }))
  670. await expect(ctx2.sessions.flush(s)).rejects.toThrow(/ENOTDIR/)
  671. await ctx2.fiber.dispose()
  672. })
  673. it('append() to a disk-only session adopts it and repairs a crash tail', async () => {
  674. // Persist a session, then corrupt its tail, all through ONE backend.
  675. const m = meta('disk-append', '/d')
  676. await ctx.sessionPersistence.create(m)
  677. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  678. await writeFile(rawLogPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
  679. // A FRESH backend with no in-memory state: append directly (no prior load)
  680. // → append must adopt from disk, and the adopt's load schedules a repair
  681. // that the same append then performs before writing.
  682. const ctx2 = new Context()
  683. await ctx2.plugin(SessionStore)
  684. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  685. await ctx2.sessionPersistence.append(m.id, [
  686. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  687. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  688. ] as SessionEvent[])
  689. const loaded = await ctx2.sessionPersistence.load(m.id)
  690. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  691. await ctx2.fiber.dispose()
  692. })
  693. it('a header-only log (open turn, no turn/end) preserves the open turn on load and closes it', async () => {
  694. // A session whose only durable content is an unclosed first turn. scanLog
  695. // preserves the turn/start; loadCore closes it with a synthetic
  696. // turn/end {interrupted} so the returned log is balanced.
  697. const m = meta('open-turn', '/h')
  698. await ctx.sessionPersistence.create(m)
  699. await ctx.sessionPersistence.append(m.id, [
  700. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  701. ] as SessionEvent[])
  702. const { events } = await ctx.sessionPersistence.load(m.id)
  703. expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
  704. const end = events[1]!
  705. expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
  706. })
  707. it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => {
  708. // Persist the id under cwd A.
  709. const a = meta('dup-id', '/projA')
  710. await ctx.sessionPersistence.create(a)
  711. await ctx.sessionPersistence.append(a.id, oneTurnLog())
  712. // A fresh backend creating the SAME id under cwd B must still refuse: load
  713. // identifies by id across all buckets, so a second log would make resume
  714. // nondeterministic. create scans every bucket, not just meta.cwd's.
  715. const ctx2 = new Context()
  716. await ctx2.plugin(SessionStore)
  717. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  718. await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
  719. .rejects.toThrow(/already has a persisted log on disk/)
  720. await ctx2.fiber.dispose()
  721. })
  722. it('flush keeps buffered events when the append fails (no silent loss)', async () => {
  723. root = await freshRoot()
  724. const ctx2 = new Context()
  725. await ctx2.plugin(SessionStore)
  726. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  727. const session = ctx2.sessions.create(SessionId('flush-fail'))
  728. // A full turn lands in the write-behind buffer.
  729. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  730. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  731. // Make the durable materialize fail on the next flush.
  732. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
  733. const origMat = backend.materialize.bind(backend)
  734. backend.materialize = () => Promise.reject(new Error('disk full'))
  735. await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/)
  736. // The events are STILL buffered (not silently dropped): a retry persists them.
  737. backend.materialize = origMat
  738. await ctx2.parallel('session/flush', session)
  739. const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
  740. expect(loaded.events.map(e => e.seq)).toEqual([0, 1])
  741. await ctx2.fiber.dispose()
  742. })
  743. it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
  744. const m = meta('serial')
  745. await ctx.sessionPersistence.create(m)
  746. 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[]
  747. await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
  748. await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
  749. await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)
  750. await expect(ctx.sessionPersistence.append(m.id, bad(new Map()))).rejects.toThrow(/non-JSON-serializable/)
  751. await expect(ctx.sessionPersistence.append(m.id, bad(undefined))).rejects.toThrow(/non-JSON-serializable/)
  752. await expect(ctx.sessionPersistence.append(m.id, bad(Infinity))).rejects.toThrow(/non-JSON-serializable/)
  753. // a circular structure
  754. const circ: Record<string, unknown> = {}
  755. circ.self = circ
  756. await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/)
  757. // The session was never materialized by any of the rejected appends.
  758. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  759. })
  760. it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
  761. const m = meta('json-ok')
  762. await ctx.sessionPersistence.create(m)
  763. 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[]
  764. await ctx.sessionPersistence.append(m.id, ev)
  765. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  766. })
  767. it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
  768. const session = ctx.sessions.create(SessionId('reject-bad'))
  769. // Serializability is enforced at the source: Session.append throws on a BigInt-bearing
  770. // event before it enters session.events, so the durable log can never diverge from the live
  771. // log. The error therefore surfaces synchronously at append, not later during backend flush.
  772. expect(() => {
  773. session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
  774. }).toThrow(/non-JSON-serializable/)
  775. // The bad event was rejected, so the log stayed empty.
  776. expect(session.events.length).toBe(0)
  777. })
  778. })