jsonl.spec.ts 62 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274
  1. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
  4. import { tmpdir } from 'node:os'
  5. import { isAbsolute, join, relative, resolve } from 'node:path'
  6. import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
  7. import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
  8. import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
  9. import {
  10. encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, toHeaderLine,
  11. } from '../src/format.ts'
  12. import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
  13. import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
  14. let root: string
  15. const dirs: string[] = []
  16. type MutableSessionHeader = { -readonly [K in keyof SessionHeader]: SessionHeader[K] }
  17. /** Test-only mutable view used to verify that backends detach returned/caller metadata. */
  18. function mutableHeader(header: SessionHeader): MutableSessionHeader {
  19. return header
  20. }
  21. /** Rewrite only a stored header while preserving every event byte below it. */
  22. async function rewriteHeader(path: string, update: (header: Record<string, unknown>) => void): Promise<void> {
  23. const lines = (await readFile(path, 'utf8')).split('\n')
  24. const header = JSON.parse(lines[0] as string) as Record<string, unknown>
  25. update(header)
  26. lines[0] = JSON.stringify(header)
  27. await writeFile(path, lines.join('\n'))
  28. }
  29. async function expectFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
  30. try {
  31. await promise
  32. } catch (error) {
  33. expect(error).toBeInstanceOf(Error)
  34. expect((error as Error).message).toMatch(message)
  35. return
  36. }
  37. throw new Error('expected flush to reject')
  38. }
  39. async function freshRoot(): Promise<string> {
  40. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  41. dirs.push(dir)
  42. return dir
  43. }
  44. function rawLogPath(root: string, cwd: string | undefined, id: SessionId): string {
  45. return logPath(root, cwd, id, 'none')
  46. }
  47. afterEach(async () => {
  48. vi.restoreAllMocks()
  49. for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
  50. })
  51. function appendClosedTurn(session: Session): void {
  52. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  53. session.append('user/message', {
  54. content: [{ type: 'text', text: 'hello' }],
  55. source: { kind: 'user' },
  56. }, { surfaceOp: 'append' })
  57. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  58. }
  59. // Run the shared backend contract against the real JSONL backend.
  60. runPersistenceContract('jsonl-none', async () => {
  61. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
  62. const ctx = new Context()
  63. await ctx.plugin(SessionStore)
  64. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
  65. return {
  66. persistence: ctx.sessionPersistence,
  67. dispose: async () => {
  68. await fiber.dispose()
  69. await rm(dir, { recursive: true, force: true })
  70. },
  71. }
  72. })
  73. // Two mounts share this temp root to exercise reload. `corruptTail` appends a partial,
  74. // newline-less fragment past the committed region so coordinator repair runs on real file bytes.
  75. runCoordinatorContract('jsonl-none', async (): Promise<CoordinatorFixture> => {
  76. const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-coord-'))
  77. return {
  78. mount: async (ctx) => {
  79. const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: dir, compression: 'none' })
  80. return fiber
  81. },
  82. corruptTail: async (id, cwd) => {
  83. // A half-written record with no trailing newline: scanLog treats it as an
  84. // uncommitted crash fragment and reports committedBytes < byteLength, so
  85. // the coordinator sees a tornMarker to truncate.
  86. await appendFile(rawLogPath(dir, cwd, id), '{"type":"assistant/chunk","seq":8,"ti')
  87. },
  88. cleanup: async () => { await rm(dir, { recursive: true, force: true }) },
  89. }
  90. })
  91. describe('SessionPersistenceJsonl: format helpers', () => {
  92. it('encodeSegment neutralizes traversal, separators, and absolute paths', () => {
  93. expect(encodeSegment('..')).toBe('~002E~002E')
  94. expect(encodeSegment('.')).toBe('~002E')
  95. expect(encodeSegment('a/b')).toBe('a~002Fb')
  96. expect(encodeSegment('/etc/passwd')).toBe('~002Fetc~002Fpasswd')
  97. expect(encodeSegment('a\u0000b')).toBe('a~0000b')
  98. expect(encodeSegment('plain-ID_1.2')).toBe('plain-ID_1.2') // safe chars pass through
  99. expect(encodeSegment('a~b')).toBe('a~007Eb') // ~ itself is escaped
  100. })
  101. it('encodeSegment is injective over UTF-16, incl. lone surrogates', () => {
  102. // Distinct lone surrogates must NOT collide (Buffer.from would normalize
  103. // both to U+FFFD; code-unit escaping keeps them distinct).
  104. const hi = encodeSegment(String.fromCharCode(0xD800))
  105. const lo = encodeSegment(String.fromCharCode(0xDC00))
  106. expect(hi).toBe('~D800')
  107. expect(lo).toBe('~DC00')
  108. expect(hi).not.toBe(lo)
  109. // A literal "~002F" input cannot collide with the encoding of "/".
  110. expect(encodeSegment('~002F')).not.toBe(encodeSegment('/'))
  111. })
  112. it('encodeSegment rejects an empty id', () => {
  113. expect(() => encodeSegment('')).toThrow(/empty/)
  114. })
  115. it('projectKey normalizes project paths into bounded readable names', () => {
  116. expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
  117. expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
  118. expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--')
  119. expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--')
  120. expect(projectKey('/')).toBe('--root--')
  121. expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255)
  122. expect(() => projectKey('')).toThrow(/empty project path/)
  123. })
  124. it('resolves a relative custom root before locating a session', async () => {
  125. const absoluteRoot = await freshRoot()
  126. const ctx = new Context()
  127. await ctx.plugin(SessionStore)
  128. const fiber = await ctx.plugin(SessionPersistenceJsonl, {
  129. root: relative(process.cwd(), absoluteRoot),
  130. compression: 'none',
  131. })
  132. const m = meta('relative-location', '/work')
  133. expect(ctx.sessionPersistence.locate(m)).toEqual({
  134. kind: 'jsonl',
  135. path: rawLogPath(resolve(absoluteRoot), '/work', m.id),
  136. })
  137. await fiber.dispose()
  138. })
  139. })
  140. describe('SessionPersistenceJsonl: durability and crash semantics', () => {
  141. let ctx: Context
  142. beforeEach(async () => {
  143. root = await freshRoot()
  144. ctx = new Context()
  145. await ctx.plugin(SessionStore)
  146. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  147. })
  148. afterEach(async () => { await ctx.fiber.dispose() })
  149. it('lazy materialization: create() writes no file until the first append', async () => {
  150. const m = meta('lazy', '/work')
  151. const location = ctx.sessionPersistence.locate(m)
  152. expect(location).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', m.id) })
  153. expect(isAbsolute(location!.path)).toBe(true)
  154. await ctx.sessionPersistence.create(m)
  155. // locate() is a pure target-path calculation: neither it nor create()
  156. // materializes a file before the first append.
  157. const dir = sessionDir(root, '/work', m.id)
  158. await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
  159. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  160. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  161. // now materialized
  162. expect((await stat(dir)).isDirectory()).toBe(true)
  163. expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
  164. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  165. })
  166. it('keeps the same location on resume and gives a fork its own location', async () => {
  167. const parent = meta('location-parent', '/work')
  168. const parentLocation = ctx.sessionPersistence.locate(parent)
  169. await ctx.sessionPersistence.create(parent)
  170. await ctx.sessionPersistence.append(parent.id, oneTurnLog())
  171. const loaded = await ctx.sessionPersistence.load(parent.id)
  172. expect(ctx.sessionPersistence.locate(loaded.meta)).toEqual(parentLocation)
  173. const child = {
  174. ...loaded.meta,
  175. id: SessionId('location-child'),
  176. parentSession: parent.id,
  177. seedLength: loaded.events.length,
  178. }
  179. const childLocation = ctx.sessionPersistence.locate(child)
  180. expect(childLocation?.path).not.toBe(parentLocation?.path)
  181. expect(childLocation).toEqual({ kind: 'jsonl', path: rawLogPath(root, '/work', child.id) })
  182. })
  183. it('round-trip is byte-identical (incl. assistant/chunk verbatim)', async () => {
  184. const m = meta('chunks')
  185. const log: SessionEvent[] = [
  186. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  187. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  188. { type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'he' } } },
  189. { type: 'assistant/chunk', seq: 3, time: 4, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'llo' } } },
  190. { 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] },
  191. { type: 'step/end', seq: 5, time: 6, data: { turn: 1, step: 1 } },
  192. { type: 'turn/end', seq: 6, time: 7, data: { turn: 1, reason: { kind: 'completed' } } },
  193. ]
  194. await ctx.sessionPersistence.create(m)
  195. await ctx.sessionPersistence.append(m.id, log)
  196. const loaded = await ctx.sessionPersistence.load(m.id)
  197. expect(loaded.events).toEqual(log) // chunks preserved, contiguous seqs
  198. })
  199. it('source-qualifies revisions across roots while preserving same-log reopen identity', async () => {
  200. const m = meta('revision-source')
  201. await ctx.sessionPersistence.create(m)
  202. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  203. const revision = (await ctx.sessionPersistence.listSnapshots())[0]?.revision
  204. const reopenedCtx = new Context()
  205. await reopenedCtx.plugin(SessionStore)
  206. await reopenedCtx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  207. expect((await reopenedCtx.sessionPersistence.listSnapshots())[0]?.revision).toBe(revision)
  208. const otherRoot = await freshRoot()
  209. const otherCtx = new Context()
  210. await otherCtx.plugin(SessionStore)
  211. await otherCtx.plugin(SessionPersistenceJsonl, { root: otherRoot, compression: 'none' })
  212. await otherCtx.sessionPersistence.create(m)
  213. await otherCtx.sessionPersistence.append(m.id, oneTurnLog())
  214. expect((await otherCtx.sessionPersistence.listSnapshots())[0]?.revision).not.toBe(revision)
  215. await reopenedCtx.fiber.dispose()
  216. await otherCtx.fiber.dispose()
  217. })
  218. it('omits a snapshot artifact removed after discovery', async () => {
  219. const m = meta('vanishing-snapshot')
  220. await ctx.sessionPersistence.create(m)
  221. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  222. const persistence = ctx.sessionPersistence as unknown as {
  223. listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
  224. }
  225. const listArtifacts = persistence.listArtifacts.bind(persistence)
  226. const discovery = vi.spyOn(persistence, 'listArtifacts').mockImplementation(async () => {
  227. const artifacts = await listArtifacts()
  228. await rm(artifacts[0]!.path)
  229. return artifacts
  230. })
  231. await expect(ctx.sessionPersistence.listSnapshots()).resolves.toEqual([])
  232. discovery.mockRestore()
  233. })
  234. it('surfaces non-ENOENT snapshot stat failures after discovery', async () => {
  235. const persistence = ctx.sessionPersistence as unknown as {
  236. listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>>
  237. }
  238. const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
  239. header: meta('snapshot-stat-failure'),
  240. path: `${root}\0snapshot-stat-failure`,
  241. }])
  242. await expect(ctx.sessionPersistence.listSnapshots()).rejects.toThrow(/null bytes/)
  243. discovery.mockRestore()
  244. })
  245. it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => {
  246. const persistence = ctx.sessionPersistence as unknown as {
  247. listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
  248. }
  249. const started = Promise.withResolvers<AbortSignal>()
  250. const cleanup = Promise.withResolvers<undefined>()
  251. vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => {
  252. if (signal === undefined) throw new Error('expected snapshot-list signal')
  253. started.resolve(signal)
  254. await cleanup.promise
  255. return []
  256. })
  257. const reason = new Error('JSONL snapshot discovery cancelled')
  258. const controller = new AbortController()
  259. const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
  260. expect(await started.promise).toBe(controller.signal)
  261. let settled = false
  262. void pending.then(
  263. () => { settled = true },
  264. () => { settled = true },
  265. )
  266. controller.abort(reason)
  267. await Promise.resolve()
  268. expect(settled).toBe(false)
  269. cleanup.resolve(undefined)
  270. await expect(pending).rejects.toBe(reason)
  271. })
  272. it('checks cancellation after an uncancellable snapshot stat settles', async () => {
  273. const m = meta('snapshot-stat-cancellation')
  274. await ctx.sessionPersistence.create(m)
  275. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  276. const persistence = ctx.sessionPersistence as unknown as {
  277. listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
  278. }
  279. const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
  280. header: m,
  281. path: rawLogPath(root, m.cwd, m.id),
  282. }])
  283. const reason = new Error('JSONL snapshot stat cancelled')
  284. const controller = new AbortController()
  285. const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
  286. queueMicrotask(() => { controller.abort(reason) })
  287. await expect(pending).rejects.toBe(reason)
  288. expect(discovery).toHaveBeenCalledWith(controller.signal)
  289. })
  290. it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
  291. const m = meta('legacy-header-delta', '/legacy')
  292. const path = rawLogPath(root, m.cwd, m.id)
  293. await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
  294. await writeFile(path, [
  295. JSON.stringify(toHeaderLine(m)),
  296. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  297. JSON.stringify({ type: 'request/header-delta', seq: 1, time: 2, data: { config: { model: 'legacy' } } }),
  298. JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  299. '',
  300. ].join('\n'))
  301. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported legacy request\/header-delta event at seq 1/)
  302. })
  303. it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
  304. const m = meta('legacy-header-fallback', '/legacy')
  305. const path = rawLogPath(root, m.cwd, m.id)
  306. await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
  307. await writeFile(path, [
  308. JSON.stringify(toHeaderLine(m)),
  309. JSON.stringify({
  310. type: 'request/header',
  311. seq: 0,
  312. time: 1,
  313. data: { header: { config: { model: 'legacy' } }, reason: 'fallback' },
  314. }),
  315. '',
  316. ].join('\n'))
  317. await expect(ctx.sessionPersistence.load(m.id))
  318. .rejects.toThrow(/unsupported legacy request\/header reason "fallback" at seq 0/)
  319. })
  320. it('persists a forked child seed through the existing session write path', async () => {
  321. const source = ctx.sessions.create(SessionId('persist-parent'), { meta: { cwd: '/workspace' } })
  322. appendClosedTurn(source)
  323. const child = ctx.sessions.fork(source, undefined, SessionId('persist-child'))
  324. await ctx.sessions.flush(child)
  325. const loaded = await ctx.sessionPersistence.load(child.id)
  326. expect(loaded.events).toEqual(source.events)
  327. expect(loaded.meta).toMatchObject({
  328. id: SessionId('persist-child'),
  329. cwd: '/workspace',
  330. parentSession: SessionId('persist-parent'),
  331. seedLength: source.events.length,
  332. })
  333. })
  334. it('crash recovery: load preserves the interrupted turn and closes it with a synthetic turn/end {interrupted}', async () => {
  335. const m = meta('crash', '/proj')
  336. await ctx.sessionPersistence.create(m)
  337. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5, turn/end at 5
  338. // Simulate a crash mid-second-turn: append raw lines that are NOT closed by
  339. // a turn/end (turn/start + step/start are fully written), plus a final
  340. // partial line with no newline (a torn fragment never fully flushed).
  341. const path = rawLogPath(root, '/proj', m.id)
  342. await writeFile(path, [
  343. JSON.stringify({ type: 'turn/start', seq: 6, time: 8, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  344. JSON.stringify({ type: 'step/start', seq: 7, time: 9, data: { turn: 2, step: 1 } }),
  345. '{"type":"assistant/chunk","seq":8,"ti', // truncated partial line (no newline)
  346. ].join('\n'), { flag: 'a' })
  347. // load PRESERVES the interrupted turn's real events (turn/start 6, step/start
  348. // 7) — a turn can be huge, so they must not be truncated — and durably closes
  349. // the orphaned turn with synthetic step/end (8) + turn/end {interrupted} (9).
  350. const loaded = await ctx.sessionPersistence.load(m.id)
  351. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
  352. const last = loaded.events.at(-1)!
  353. expect(last.type === 'turn/end' && last.data.reason).toEqual({ kind: 'interrupted' })
  354. const stepEnd = loaded.events[8]!
  355. expect(stepEnd.type).toBe('step/end')
  356. // the torn seq-8 chunk fragment did not survive
  357. expect(loaded.events.some(e => e.type === 'assistant/chunk' && e.seq === 8)).toBe(false)
  358. // The next append continues at seq 10 (the balanced length).
  359. const turn3 = [
  360. { type: 'turn/start', seq: 10, time: 11, data: { turn: 3, trigger: { kind: 'message', source: { kind: 'user' } } } },
  361. { type: 'turn/end', seq: 11, time: 12, data: { turn: 3, reason: { kind: 'completed' } } },
  362. ] as SessionEvent[]
  363. await ctx.sessionPersistence.append(m.id, turn3)
  364. const reloaded = await ctx.sessionPersistence.load(m.id)
  365. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11])
  366. })
  367. it('committed events are never rewritten: only the crash tail is repaired', async () => {
  368. const m = meta('append-only')
  369. await ctx.sessionPersistence.create(m)
  370. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  371. const before = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
  372. const committedPrefix = before // the whole committed log
  373. // A crash tail then a repair-append.
  374. await writeFile(rawLogPath(root, undefined, m.id), '\n{"partial', { flag: 'a' })
  375. await ctx.sessionPersistence.load(m.id)
  376. await ctx.sessionPersistence.append(m.id, [
  377. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  378. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  379. ] as SessionEvent[])
  380. const after = await readFile(rawLogPath(root, undefined, m.id), 'utf8')
  381. // the committed prefix is byte-for-byte intact at the head of the file
  382. expect(after.startsWith(committedPrefix)).toBe(true)
  383. })
  384. it('a failed appendLines truncates partial bytes so a retry has no seq gap', async () => {
  385. const m = meta('truncate-retry')
  386. await ctx.sessionPersistence.create(m)
  387. await ctx.sessionPersistence.append(m.id, oneTurnLog()) // materialized, seqs 0..5
  388. const sizeBefore = (await stat(rawLogPath(root, undefined, m.id))).size
  389. // Force the NEXT fsync (inside appendLines) to fail once, AFTER writeFile
  390. // has already put bytes on disk — simulating an ENOSPC/fsync error
  391. // mid-append. The recovery truncate() also fsyncs, so allow that one.
  392. const handle = await (await import('node:fs/promises')).open(rawLogPath(root, undefined, m.id), 'r')
  393. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  394. await handle.close()
  395. const realSync = proto.sync
  396. let failed = false
  397. const spy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  398. if (!failed) { failed = true; throw new Error('simulated fsync ENOSPC') }
  399. return realSync.call(this)
  400. })
  401. const turn2 = [
  402. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  403. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  404. ] as SessionEvent[]
  405. // The append rejects, but the partial bytes are truncated back: the file is
  406. // its pre-append size and the cursor is unchanged.
  407. await expect(ctx.sessionPersistence.append(m.id, turn2)).rejects.toThrow(/ENOSPC/)
  408. expect((await stat(rawLogPath(root, undefined, m.id))).size).toBe(sizeBefore)
  409. spy.mockRestore()
  410. // The retry now succeeds with NO seq gap — the log is contiguous 0..7.
  411. await ctx.sessionPersistence.append(m.id, turn2)
  412. const loaded = await ctx.sessionPersistence.load(m.id)
  413. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  414. })
  415. it('reports both the append failure and a failed rollback', async () => {
  416. const m = meta('rollback-failure')
  417. await ctx.sessionPersistence.create(m)
  418. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  419. const path = rawLogPath(root, undefined, m.id)
  420. const handle = await (await import('node:fs/promises')).open(path, 'r')
  421. const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
  422. await handle.close()
  423. const realSync = proto.sync
  424. let failed = false
  425. const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
  426. if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
  427. return realSync.call(this)
  428. })
  429. const backend = ctx.sessionPersistence as unknown as {
  430. rollbackAppend: (path: string, size: number) => Promise<void>
  431. }
  432. const realRollback = backend.rollbackAppend.bind(backend)
  433. backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
  434. try {
  435. await ctx.sessionPersistence.append(m.id, [
  436. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  437. ] as SessionEvent[])
  438. throw new Error('expected append to reject')
  439. } catch (error) {
  440. expect(error).toBeInstanceOf(AggregateError)
  441. const aggregate = error as AggregateError
  442. expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
  443. expect(aggregate.errors).toHaveLength(2)
  444. expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
  445. expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
  446. } finally {
  447. backend.rollbackAppend = realRollback
  448. syncSpy.mockRestore()
  449. }
  450. })
  451. it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {
  452. const m = meta('meta-copy', '/proj')
  453. await ctx.sessionPersistence.create(m)
  454. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  455. const loaded = await ctx.sessionPersistence.load(m.id)
  456. // A consumer mutates the returned meta's cwd. The backend's stored pathing
  457. // metadata must be unaffected, so a later append still finds the right log.
  458. mutableHeader(loaded.meta).cwd = '/evil'
  459. await ctx.sessionPersistence.append(m.id, [
  460. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  461. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  462. ] as SessionEvent[])
  463. // The append landed in the ORIGINAL /proj log, not beside an /evil path.
  464. const reloaded = await ctx.sessionPersistence.load(m.id)
  465. expect(reloaded.meta.cwd).toBe('/proj')
  466. expect(reloaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  467. })
  468. it('rejects a mismatched header before repairing either session log', async () => {
  469. const a = meta('identity-a', '/same')
  470. const b = meta('identity-b', '/same')
  471. await ctx.sessionPersistence.create(a)
  472. await ctx.sessionPersistence.append(a.id, [{
  473. type: 'turn/start',
  474. seq: 0,
  475. time: 1,
  476. data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
  477. }])
  478. await ctx.sessionPersistence.create(b)
  479. await ctx.sessionPersistence.append(b.id, oneTurnLog())
  480. const aPath = rawLogPath(root, a.cwd, a.id)
  481. const bPath = rawLogPath(root, b.cwd, b.id)
  482. await rewriteHeader(aPath, (header) => { header.id = b.id })
  483. const beforeA = await readFile(aPath)
  484. const beforeB = await readFile(bPath)
  485. await expect(ctx.sessionPersistence.load(a.id))
  486. .rejects.toThrow(/requested id "identity-a" does not match header id "identity-b"/)
  487. expect(await readFile(aPath)).toEqual(beforeA)
  488. expect(await readFile(bPath)).toEqual(beforeB)
  489. })
  490. it('rejects a re-append of an already-stored seq', async () => {
  491. const m = meta('reappend')
  492. await ctx.sessionPersistence.create(m)
  493. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  494. await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toThrow(/seq mismatch/)
  495. })
  496. it('path-traversal session ids are neutralized (no escape from root)', async () => {
  497. const evil = SessionId('../../etc/pwn')
  498. const m = { version: 0, id: evil, createdAt: 1 }
  499. await ctx.sessionPersistence.create(m)
  500. await ctx.sessionPersistence.append(evil, oneTurnLog())
  501. // The file lives UNDER root, not at ../../etc.
  502. const all: string[] = []
  503. async function walk(dir: string): Promise<void> {
  504. for (const e of await readdir(dir, { withFileTypes: true })) {
  505. const p = join(dir, e.name)
  506. if (e.isDirectory()) await walk(p)
  507. else all.push(p)
  508. }
  509. }
  510. await walk(root)
  511. expect(all.length).toBeGreaterThan(0)
  512. expect(all.every(p => p.startsWith(root))).toBe(true)
  513. })
  514. })
  515. describe('SessionPersistenceJsonl: write path (session/event → flush)', () => {
  516. it('concurrent sessions do not cross buffers', async () => {
  517. root = await freshRoot()
  518. const ctx = new Context()
  519. await ctx.plugin(SessionStore)
  520. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  521. const a = ctx.sessions.create(SessionId('sa'))
  522. const b = ctx.sessions.create(SessionId('sb'))
  523. a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  524. b.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  525. a.append('user/message', { content: [{ type: 'text', text: 'A' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  526. b.append('user/message', { content: [{ type: 'text', text: 'B' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  527. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  528. b.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  529. await ctx.sessions.flush(a)
  530. await ctx.sessions.flush(b)
  531. const la = await ctx.sessionPersistence.load(SessionId('sa'))
  532. const lb = await ctx.sessionPersistence.load(SessionId('sb'))
  533. expect(JSON.stringify(la.events)).toContain('"A"')
  534. expect(JSON.stringify(la.events)).not.toContain('"B"')
  535. expect(JSON.stringify(lb.events)).toContain('"B"')
  536. expect(JSON.stringify(lb.events)).not.toContain('"A"')
  537. await ctx.fiber.dispose()
  538. })
  539. })
  540. describe('SessionPersistenceJsonl: scanLog unit', () => {
  541. it('rejects a header-less / empty log', () => {
  542. expect(() => scanLog(Buffer.from(''))).toThrow()
  543. })
  544. it('rejects a corrupt header line', () => {
  545. expect(() => scanLog(Buffer.from('not json\n'))).toThrow(/header/)
  546. })
  547. it('rejects a non-session first line', () => {
  548. expect(() => scanLog(Buffer.from('{"type":"event"}\n'))).toThrow(/session header/)
  549. })
  550. it.each([
  551. ['fractional', 1.5],
  552. ['negative', -1],
  553. ['unsafe', Number.MAX_SAFE_INTEGER + 1],
  554. ])('rejects a session header with a %s createdAt', (_label, createdAt) => {
  555. const log = JSON.stringify({
  556. type: 'session',
  557. version: 0,
  558. id: 'invalid-created-at',
  559. createdAt,
  560. delegationDepth: 0,
  561. }) + '\n'
  562. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  563. })
  564. it('rejects a session header with negative-zero createdAt', () => {
  565. const log = '{"type":"session","version":0,"id":"invalid-created-at","createdAt":-0,"delegationDepth":0}\n'
  566. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  567. })
  568. it.each([
  569. ['missing', undefined],
  570. ['a string', '1'],
  571. ['fractional', 1.5],
  572. ['negative', -1],
  573. ])('rejects a session header with %s delegationDepth', (_label, delegationDepth) => {
  574. const log = JSON.stringify({
  575. type: 'session',
  576. version: 0,
  577. id: 'invalid-depth',
  578. createdAt: 1,
  579. ...delegationDepth === undefined ? {} : { delegationDepth },
  580. }) + '\n'
  581. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  582. })
  583. it('rejects a session header with negative-zero delegationDepth', () => {
  584. const log = '{"type":"session","version":0,"id":"invalid-depth","createdAt":1,"delegationDepth":-0}\n'
  585. expect(() => scanLog(Buffer.from(log))).toThrow(/session header/)
  586. })
  587. it('a seq gap after the last turn/end bounds the preserved tail (torn fragment tolerated)', () => {
  588. const log = [
  589. JSON.stringify({ type: 'session', version: 0, id: 'g', createdAt: 1, delegationDepth: 0 }),
  590. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  591. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  592. ].join('\n') + '\n'
  593. // No committed turn/end, so the gap is a tolerated crash boundary: scanLog PRESERVES the
  594. // contiguous prefix (turn/start seq 0) — real interrupted-turn work, not discarded — and
  595. // stops at the gap. `loadCore`, not this scanner, later closes the orphaned turn.
  596. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  597. })
  598. it('rejects a seq gap BEFORE a later committed turn/end (committed data damaged)', () => {
  599. const log = [
  600. JSON.stringify({ type: 'session', version: 0, id: 'g2', createdAt: 1, delegationDepth: 0 }),
  601. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  602. JSON.stringify({ type: 'step/start', seq: 2, time: 2, data: { turn: 1, step: 1 } }), // gap: missing seq 1
  603. JSON.stringify({ type: 'turn/end', seq: 3, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  604. ].join('\n') + '\n'
  605. // A turn/end exists, so the prefix up to it is committed — but it has a hole.
  606. // Truncating it would silently drop committed data → unloadable.
  607. expect(() => scanLog(Buffer.from(log))).toThrow(/seq gap in committed region/)
  608. })
  609. it('rejects a corrupt line BEFORE a later committed turn/end (committed data damaged)', () => {
  610. const log = [
  611. JSON.stringify({ type: 'session', version: 0, id: 'c', createdAt: 1, delegationDepth: 0 }),
  612. '{not json', // corrupt, sits in the committed region (a turn/end follows)
  613. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  614. ].join('\n') + '\n'
  615. expect(() => scanLog(Buffer.from(log))).toThrow(/unparsable committed event/)
  616. })
  617. it('a header-only log (no event lines at all) preserves nothing — committedBytes is the header', () => {
  618. const log = JSON.stringify({ type: 'session', version: 0, id: 'h0', createdAt: 1, delegationDepth: 0 }) + '\n'
  619. const scanned = scanLog(Buffer.from(log))
  620. expect(scanned.events).toEqual([])
  621. // committedBytes falls back to the header line's end (no preserved events).
  622. expect(scanned.committedBytes).toBe(Buffer.byteLength(log, 'utf8'))
  623. })
  624. it('a corrupt line after the last turn/end bounds the preserved tail', () => {
  625. const log = [
  626. JSON.stringify({ type: 'session', version: 0, id: 'c2', createdAt: 1, delegationDepth: 0 }),
  627. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  628. '{not json', // corrupt crash fragment, no turn/end committed
  629. ].join('\n') + '\n'
  630. // The contiguous prefix (turn/start seq 0) is preserved; the corrupt
  631. // fragment after it is the tolerated crash boundary.
  632. expect(scanLog(Buffer.from(log)).events.map(e => e.seq)).toEqual([0])
  633. })
  634. it('tolerates a seq gap AFTER a turn/end (uncommitted tail)', () => {
  635. const log = [
  636. JSON.stringify({ type: 'session', version: 0, id: 't', createdAt: 1, delegationDepth: 0 }),
  637. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  638. JSON.stringify({ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }),
  639. JSON.stringify({ type: 'step/start', seq: 9, time: 3, data: { turn: 2, step: 1 } }), // gap in uncommitted tail
  640. ].join('\n') + '\n'
  641. const { events } = scanLog(Buffer.from(log))
  642. expect(events.map(e => e.seq)).toEqual([0, 1]) // tail dropped
  643. })
  644. })
  645. describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () => {
  646. let ctx: Context
  647. beforeEach(async () => {
  648. root = await freshRoot()
  649. ctx = new Context()
  650. await ctx.plugin(SessionStore)
  651. // compression: 'none' — these tests assert the textual storage-record layout
  652. // (row tags per line); packing is orthogonal to the physical encoding.
  653. await ctx.plugin(SessionPersistenceJsonl, { root, packChunks: true, compression: 'none' })
  654. })
  655. afterEach(async () => { await ctx.fiber.dispose() })
  656. /** A one-turn log whose step streams a five-member text-delta run. */
  657. function chunkRunLog(): SessionEvent[] {
  658. const deltas: SessionEvent[] = Array.from({ length: 5 }, (_, k) => ({
  659. type: 'assistant/chunk',
  660. seq: 2 + k,
  661. time: 3 + k,
  662. data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: `t${k}` } },
  663. }))
  664. return [
  665. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  666. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  667. ...deltas,
  668. { 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] },
  669. { type: 'step/end', seq: 8, time: 9, data: { turn: 1, step: 1 } },
  670. { type: 'turn/end', seq: 9, time: 10, data: { turn: 1, reason: { kind: 'completed' } } },
  671. ]
  672. }
  673. it('writes a delta run as one text-chunks row and loads back identical events', async () => {
  674. const m = meta('packed', '/work')
  675. const log = chunkRunLog()
  676. await ctx.sessionPersistence.create(m)
  677. await ctx.sessionPersistence.append(m.id, log)
  678. const raw = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
  679. const tags = raw.slice(1).map(line => (JSON.parse(line) as { type: string }).type)
  680. expect(tags).toEqual(['turn/start', 'step/start', 'text-chunks', 'assistant/message', 'step/end', 'turn/end'])
  681. const loaded = await ctx.sessionPersistence.load(m.id)
  682. expect(loaded.events).toEqual(log)
  683. })
  684. it('loads a mixed file: verbatim lines from an unpacked writer, then packed appends', async () => {
  685. const m = meta('mixed', '/work')
  686. const log = chunkRunLog()
  687. // First turn written line-per-event by an unpacked-config writer (an old
  688. // file, hand-planted so this packed-config backend adopts it on load).
  689. await mkdir(sessionDir(root, '/work', m.id), { recursive: true })
  690. await writeFile(rawLogPath(root, '/work', m.id), [
  691. JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }),
  692. ...log.map(e => JSON.stringify(e)),
  693. ].join('\n') + '\n')
  694. // Adopt the stored log (cursor = stored length), then append a second turn
  695. // through THIS packed-config backend.
  696. expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(log)
  697. const secondTurn: SessionEvent[] = JSON.parse(JSON.stringify(log)) as SessionEvent[]
  698. for (const [k, e] of secondTurn.entries()) {
  699. ;(e as { seq: number }).seq = 10 + k
  700. ;(e.data as { turn: number }).turn = 2
  701. }
  702. await ctx.sessionPersistence.append(m.id, secondTurn)
  703. const loaded = await ctx.sessionPersistence.load(m.id)
  704. expect(loaded.events).toEqual([...log, ...secondTurn])
  705. // The packed append really packed: the file's tail carries a text-chunks row.
  706. const tags = (await readFile(rawLogPath(root, '/work', m.id), 'utf8')).split('\n').filter(Boolean)
  707. .map(line => (JSON.parse(line) as { type: string }).type)
  708. expect(tags.filter(t => t === 'text-chunks')).toHaveLength(1)
  709. expect(tags.filter(t => t === 'assistant/chunk')).toHaveLength(5)
  710. })
  711. it('scanLog: a packed row advances the seq cursor by its whole run', () => {
  712. const logText = [
  713. JSON.stringify({ type: 'session', version: 0, id: 'rows', createdAt: 1, delegationDepth: 0 }),
  714. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  715. JSON.stringify({ type: 'text-chunks', seq0: 1, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
  716. JSON.stringify({ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } }),
  717. ].join('\n') + '\n'
  718. const { events } = scanLog(Buffer.from(logText))
  719. expect(events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4])
  720. expect(events[2]).toEqual({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'b' } } })
  721. })
  722. it('scanLog: a malformed packed row in the committed region rejects like corrupt JSON', () => {
  723. const logText = [
  724. JSON.stringify({ type: 'session', version: 0, id: 'bad-row', createdAt: 1, delegationDepth: 0 }),
  725. // dt arity mismatch — row validation throws, so the line is a committed hole.
  726. JSON.stringify({ type: 'text-chunks', seq0: 0, time0: 1, data: { turn: 1, step: 1, index: 0, dt: [], texts: ['a', 'b'] } }),
  727. JSON.stringify({ type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }),
  728. ].join('\n') + '\n'
  729. expect(() => scanLog(Buffer.from(logText))).toThrow(/unparsable committed event/)
  730. })
  731. it('scanLog: a packed row with a mid-run seq gap after the last turn/end drops the whole row', () => {
  732. const logText = [
  733. JSON.stringify({ type: 'session', version: 0, id: 'row-gap', createdAt: 1, delegationDepth: 0 }),
  734. JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
  735. // seq0 skips 1 — the run's first member is already a gap; no turn/end follows.
  736. JSON.stringify({ type: 'text-chunks', seq0: 2, time0: 2, data: { turn: 1, step: 1, index: 0, dt: [1, 1], texts: ['a', 'b', 'c'] } }),
  737. ].join('\n') + '\n'
  738. const scanned = scanLog(Buffer.from(logText))
  739. expect(scanned.events.map(e => e.seq)).toEqual([0])
  740. // committedBytes stays on the line boundary BEFORE the dropped row.
  741. const headerAndTurn = logText.split('\n').slice(0, 2).join('\n') + '\n'
  742. expect(scanned.committedBytes).toBe(Buffer.byteLength(headerAndTurn, 'utf8'))
  743. })
  744. it('eventLines(packChunks: false) is byte-identical to the pre-packing layout', () => {
  745. const log = chunkRunLog()
  746. expect(eventLines(log, false)).toBe(log.map(e => JSON.stringify(e)).join('\n'))
  747. })
  748. })
  749. describe('SessionPersistenceJsonl: edge cases', () => {
  750. let ctx: Context
  751. beforeEach(async () => {
  752. root = await freshRoot()
  753. ctx = new Context()
  754. await ctx.plugin(SessionStore)
  755. await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  756. })
  757. afterEach(async () => { await ctx.fiber.dispose() })
  758. it('append rejects non-JSON-serializable undefined-producing data', async () => {
  759. const m = meta('undef')
  760. await ctx.sessionPersistence.create(m)
  761. // A value whose JSON.stringify yields undefined (a bare function as data).
  762. const bad = [{ type: 'user/message', seq: 0, time: 1, data: (() => 0) as unknown }] as unknown as SessionEvent[]
  763. await expect(ctx.sessionPersistence.append(m.id, bad)).rejects.toThrow(/non-JSON-serializable/)
  764. })
  765. it('create snapshots its meta: mutating the caller object after the call is ignored', async () => {
  766. const m = meta('create-snap', '/orig')
  767. const p = ctx.sessionPersistence.create(m)
  768. // Mutate the caller's meta object immediately after calling create.
  769. mutableHeader(m).cwd = '/mutated'
  770. await p
  771. await ctx.sessionPersistence.append(SessionId('create-snap'), oneTurnLog())
  772. // The log materialized under the ORIGINAL cwd, not the mutated one.
  773. expect((await stat(rawLogPath(root, '/orig', SessionId('create-snap')))).isFile()).toBe(true)
  774. await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
  775. })
  776. it('list discovers sessions across multiple project directories', async () => {
  777. await ctx.sessionPersistence.create(meta('p1', '/projA'))
  778. await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog())
  779. await ctx.sessionPersistence.create(meta('p2', '/projB'))
  780. await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog())
  781. await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd project directory
  782. await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog())
  783. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  784. expect(ids).toEqual(['p1', 'p2', 'p3'])
  785. })
  786. it('groups sessions whose cwd paths normalize to the same project directory', async () => {
  787. const first = meta('normalized-first', '/a/b-c')
  788. const second = meta('normalized-second', '/a-b/c')
  789. await ctx.sessionPersistence.create(first)
  790. await ctx.sessionPersistence.append(first.id, oneTurnLog())
  791. await ctx.sessionPersistence.create(second)
  792. await ctx.sessionPersistence.append(second.id, oneTurnLog())
  793. expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd))
  794. expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([
  795. encodeSegment(first.id),
  796. encodeSegment(second.id),
  797. ]))
  798. expect((await ctx.sessionPersistence.list()).map(header => header.id).sort())
  799. .toEqual([first.id, second.id].sort())
  800. })
  801. it('list on an empty root returns nothing', async () => {
  802. expect(await ctx.sessionPersistence.list()).toEqual([])
  803. })
  804. it('keeps the transcript in an extensible session-owned directory', async () => {
  805. const m = meta('owned-directory', '/project')
  806. await ctx.sessionPersistence.create(m)
  807. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  808. const dir = sessionDir(root, m.cwd, m.id)
  809. await writeFile(join(dir, 'metadata.json'), '{}\n')
  810. await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n')
  811. await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true })
  812. expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl']))
  813. expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
  814. expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
  815. })
  816. it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => {
  817. const m = meta('legacy-flat', '/legacy')
  818. const project = projectDir(root, m.cwd)
  819. const path = join(project, `${encodeSegment(m.id)}.jsonl`)
  820. await mkdir(project, { recursive: true })
  821. await writeFile(path, [
  822. JSON.stringify(toHeaderLine(m)),
  823. ...oneTurnLog().map(event => JSON.stringify(event)),
  824. '',
  825. ].join('\n'))
  826. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
  827. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/)
  828. })
  829. it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => {
  830. const m = meta('legacy-compressed-flat', '/legacy')
  831. const project = projectDir(root, m.cwd)
  832. expect(await ctx.sessionPersistence.list()).toEqual([])
  833. await mkdir(project, { recursive: true })
  834. await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy')
  835. await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
  836. })
  837. it('list skips empty and non-header session logs (metadata-only read)', async () => {
  838. // A real session…
  839. await ctx.sessionPersistence.create(meta('real', '/p'))
  840. await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
  841. // …alongside junk session directories whose fixed transcript is empty or
  842. // lacks a header. Both remain unmaterialized and are skipped.
  843. for (const [id, content] of [
  844. ['empty', ''],
  845. ['notheader', '{"type":"turn/start"}\n'],
  846. ['badjson', 'not json at all\n'],
  847. ] as const) {
  848. const path = rawLogPath(root, undefined, SessionId(id))
  849. await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true })
  850. await writeFile(path, content)
  851. }
  852. const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
  853. expect(ids).toEqual(['real'])
  854. })
  855. it('list reads a header line longer than the 8KB read chunk', async () => {
  856. // A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
  857. // `readFirstLine` accumulates chunks before `list()` parses it.
  858. const id = SessionId('big')
  859. await mkdir(sessionDir(root, undefined, id), { recursive: true })
  860. const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) })
  861. await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n')
  862. const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
  863. expect(ids).toContain('big')
  864. })
  865. it('list rejects a header whose cwd does not identify its physical log', async () => {
  866. const m = meta('misplaced', '/stored')
  867. await ctx.sessionPersistence.create(m)
  868. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  869. await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' })
  870. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/)
  871. })
  872. it('accepts an alternate project path only when it identifies the same physical log', async () => {
  873. const m = meta('physical-alias', '/stored')
  874. await ctx.sessionPersistence.create(m)
  875. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  876. const path = rawLogPath(root, m.cwd, m.id)
  877. const aliasCwd = '/alias'
  878. await symlink(
  879. projectDir(root, m.cwd),
  880. projectDir(root, aliasCwd),
  881. process.platform === 'win32' ? 'junction' : 'dir',
  882. )
  883. await rewriteHeader(path, (header) => { header.cwd = aliasCwd })
  884. expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd)
  885. expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
  886. })
  887. it('list rejects a session header whose id cannot name a storage path', async () => {
  888. const dir = join(projectDir(root, undefined), 'invalid-id')
  889. await mkdir(dir, { recursive: true })
  890. await writeFile(join(dir, 'session.jsonl'), JSON.stringify({
  891. type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0,
  892. }) + '\n')
  893. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/)
  894. })
  895. it('load and list reject one id materialized in multiple project directories', async () => {
  896. const id = SessionId('duplicate')
  897. for (const cwd of ['/a', '/b']) {
  898. const m = meta(id, cwd)
  899. await mkdir(sessionDir(root, cwd, id), { recursive: true })
  900. const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n'
  901. await writeFile(rawLogPath(root, cwd, id), content)
  902. }
  903. await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/)
  904. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/)
  905. })
  906. it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
  907. // Session A materializes a log under id "reuse".
  908. const sessFiberA = await ctx.plugin(Object.assign((inner: Context) => {
  909. const a = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  910. appendLog(a, oneTurnLog())
  911. }, { inject: ['sessions'] }))
  912. // Drain A, then dispose ITS fiber (the live session A is gone) while the
  913. // backend stays loaded.
  914. for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
  915. await sessFiberA.dispose()
  916. // A new Session object reuses the id. Object-keyed initialization must run independently,
  917. // detect the disk collision, and reject instead of appending through session A's stale cursor.
  918. let b!: Session
  919. await ctx.plugin(Object.assign((inner: Context) => {
  920. b = inner.sessions.create(SessionId('reuse'), { meta: { cwd: '/a' } })
  921. }, { inject: ['sessions'] }))
  922. await expect(ctx.sessions.flush(b)).rejects.toThrow(/already bound to a different live session|already has a persisted log on disk/)
  923. })
  924. it('a no-cwd live session cannot adopt a same-id log from another cwd', async () => {
  925. // Backend 1: materialize a log under id "x" in the cwd "/w" bucket, then
  926. // dispose the WHOLE backend (so backend 2 mounts with an EMPTY states map —
  927. // the HMR/reload path with no tracked collision state).
  928. await ctx.sessionPersistence.create(meta('x', '/w'))
  929. await ctx.sessionPersistence.append(SessionId('x'), oneTurnLog())
  930. await ctx.fiber.dispose()
  931. // Backend 2 creates a no-cwd session whose id exists only in `/w`. The
  932. // stored cwd check rejects instead of grafting no-cwd events onto that log.
  933. const ctx2 = new Context()
  934. await ctx2.plugin(SessionStore)
  935. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  936. let b!: Session
  937. await ctx2.plugin(Object.assign((inner: Context) => {
  938. b = inner.sessions.create(SessionId('x')) // no cwd
  939. }, { inject: ['sessions'] }))
  940. await expect(ctx2.sessions.flush(b)).rejects.toThrow(/different cwd|id collision/)
  941. // The "/w" log is untouched — no no-cwd events were grafted onto it, and no
  942. // `_no-cwd` log for "x" was created.
  943. const inW = scanLog(await readFile(rawLogPath(root, '/w', SessionId('x'))))
  944. expect(inW.meta.cwd).toBe('/w')
  945. expect(inW.events).toHaveLength(6)
  946. await expect(stat(rawLogPath(root, undefined, SessionId('x')))).rejects.toThrow()
  947. await ctx2.fiber.dispose()
  948. })
  949. it('a seed with matching seq/type/time but DIFFERENT data is rejected (deep prefix compare)', async () => {
  950. // Materialize and load (ownerless, cursor = 6).
  951. await ctx.sessionPersistence.create(meta('divergent', '/a'))
  952. await ctx.sessionPersistence.append(SessionId('divergent'), oneTurnLog())
  953. await ctx.sessionPersistence.load(SessionId('divergent'))
  954. // A seed that keeps every seq/type/time but mutates a payload must NOT be
  955. // accepted as "the same session" — otherwise drain filters those seqs as
  956. // already persisted and the divergent payload is silently lost.
  957. const tampered = oneTurnLog()
  958. const userMsg = tampered[1]
  959. if (userMsg?.type === 'user/message') userMsg.data.content = [{ type: 'text', text: 'DIFFERENT' }]
  960. let bad!: Session
  961. await ctx.plugin(Object.assign((inner: Context) => {
  962. bad = inner.sessions.create(SessionId('divergent'), { seed: tampered, meta: { cwd: '/a' } })
  963. }, { inject: ['sessions'] }))
  964. await expect(ctx.sessions.flush(bad)).rejects.toThrow(/do not match this live session|already has a persisted log/)
  965. })
  966. it('a second live session reusing a bound id is rejected', async () => {
  967. // A live session materializes and owns the id.
  968. const firstFiber = await ctx.plugin(Object.assign((inner: Context) => {
  969. const a = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  970. a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  971. a.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  972. }, { inject: ['sessions'] }))
  973. for (const s of ctx.sessions.list()) await ctx.sessions.flush(s)
  974. await firstFiber.dispose()
  975. let second!: Session
  976. await ctx.plugin(Object.assign((inner: Context) => {
  977. second = inner.sessions.create(SessionId('bound'), { meta: { cwd: '/a' } })
  978. }, { inject: ['sessions'] }))
  979. await expect(ctx.sessions.flush(second))
  980. .rejects.toThrow(/already bound to a different live session|already has a persisted log|do not match/)
  981. })
  982. it('list returns nothing when the root directory does not exist', async () => {
  983. const ctx2 = new Context()
  984. await ctx2.plugin(SessionStore)
  985. await ctx2.plugin(SessionPersistenceJsonl, {
  986. root: join(root, 'does-not-exist-yet'),
  987. compression: 'none',
  988. })
  989. expect(await ctx2.sessionPersistence.list()).toEqual([])
  990. await ctx2.fiber.dispose()
  991. })
  992. it('plugin load rejects an existing root that is not a directory', async () => {
  993. const filePath = join(root, 'not-a-dir')
  994. await writeFile(filePath, 'x')
  995. const ctx2 = new Context()
  996. await ctx2.plugin(SessionStore)
  997. await expect(ctx2.plugin(SessionPersistenceJsonl, { root: filePath, compression: 'none' })).rejects.toThrow(/ENOTDIR/)
  998. await ctx2.fiber.dispose()
  999. })
  1000. it('list surfaces a root that becomes unusable after plugin load', async () => {
  1001. await rm(root, { recursive: true })
  1002. await writeFile(root, 'not a directory')
  1003. await expect(ctx.sessionPersistence.list()).rejects.toThrow(/ENOTDIR/)
  1004. })
  1005. it('per-id lookup surfaces non-ENOENT storage errors', async () => {
  1006. const blocker = join(root, 'not-a-directory')
  1007. await writeFile(blocker, 'x')
  1008. const backend = ctx.sessionPersistence as unknown as { exists(path: string): Promise<boolean> }
  1009. await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
  1010. })
  1011. it('materialization surfaces a project-directory storage fault', async () => {
  1012. const cwd = '/x'
  1013. const ctx2 = new Context()
  1014. await ctx2.plugin(SessionStore)
  1015. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1016. await writeFile(projectDir(root, cwd), 'x') // project path is now a file
  1017. let s!: Session
  1018. await ctx2.plugin(Object.assign((inner: Context) => {
  1019. s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
  1020. appendClosedTurn(s)
  1021. }, { inject: ['sessions'] }))
  1022. await expect(ctx2.sessions.flush(s)).rejects.toThrow(/EEXIST|ENOTDIR/)
  1023. await ctx2.fiber.dispose()
  1024. })
  1025. it('append() to a disk-only session adopts it and repairs a crash tail', async () => {
  1026. // Persist a session, then corrupt its tail, all through ONE backend.
  1027. const m = meta('disk-append', '/d')
  1028. await ctx.sessionPersistence.create(m)
  1029. await ctx.sessionPersistence.append(m.id, oneTurnLog())
  1030. await writeFile(rawLogPath(root, '/d', m.id), '\n{"partial crash', { flag: 'a' })
  1031. // A FRESH backend with no in-memory state: append directly (no prior load)
  1032. // → append must adopt from disk, and the adopt's load schedules a repair
  1033. // that the same append then performs before writing.
  1034. const ctx2 = new Context()
  1035. await ctx2.plugin(SessionStore)
  1036. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1037. await ctx2.sessionPersistence.append(m.id, [
  1038. { type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
  1039. { type: 'turn/end', seq: 7, time: 10, data: { turn: 2, reason: { kind: 'completed' } } },
  1040. ] as SessionEvent[])
  1041. const loaded = await ctx2.sessionPersistence.load(m.id)
  1042. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
  1043. await ctx2.fiber.dispose()
  1044. })
  1045. it('a header-only log (open turn, no turn/end) preserves the open turn on load and closes it', async () => {
  1046. // A session whose only durable content is an unclosed first turn. scanLog
  1047. // preserves the turn/start; loadCore closes it with a synthetic
  1048. // turn/end {interrupted} so the returned log is balanced.
  1049. const m = meta('open-turn', '/h')
  1050. await ctx.sessionPersistence.create(m)
  1051. await ctx.sessionPersistence.append(m.id, [
  1052. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
  1053. ] as SessionEvent[])
  1054. const { events } = await ctx.sessionPersistence.load(m.id)
  1055. expect(events.map(e => e.type)).toEqual(['turn/start', 'turn/end'])
  1056. const end = events[1]!
  1057. expect(end.type === 'turn/end' && end.data.reason).toEqual({ kind: 'interrupted' })
  1058. })
  1059. it('createCore rejects an id already on disk under a different project directory', async () => {
  1060. // Persist the id under cwd A.
  1061. const a = meta('dup-id', '/projA')
  1062. await ctx.sessionPersistence.create(a)
  1063. await ctx.sessionPersistence.append(a.id, oneTurnLog())
  1064. // A fresh backend creating the SAME id under cwd B must still refuse: load
  1065. // identifies by id across all projects, so a second log would make resume
  1066. // nondeterministic. create scans every project, not just meta.cwd's.
  1067. const ctx2 = new Context()
  1068. await ctx2.plugin(SessionStore)
  1069. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1070. await expect(ctx2.sessionPersistence.create(meta('dup-id', '/projB')))
  1071. .rejects.toThrow(/already has a persisted log on disk/)
  1072. await ctx2.fiber.dispose()
  1073. })
  1074. it('flush keeps buffered events when the append fails (no silent loss)', async () => {
  1075. root = await freshRoot()
  1076. const ctx2 = new Context()
  1077. await ctx2.plugin(SessionStore)
  1078. await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
  1079. const session = ctx2.sessions.create(SessionId('flush-fail'))
  1080. // A full turn lands in the write-behind buffer.
  1081. session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
  1082. session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
  1083. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  1084. // Make the durable materialize fail on the next flush.
  1085. const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
  1086. const origMat = backend.materialize.bind(backend)
  1087. backend.materialize = () => Promise.reject(new Error('disk full'))
  1088. await expectFlushError(ctx2.sessions.flush(session), /disk full/)
  1089. // The events are STILL buffered (not silently dropped): a retry persists them.
  1090. backend.materialize = origMat
  1091. await ctx2.sessions.flush(session)
  1092. const loaded = await ctx2.sessionPersistence.load(SessionId('flush-fail'))
  1093. expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2])
  1094. await ctx2.fiber.dispose()
  1095. })
  1096. it('rejects non-JSON event data: BigInt, function, circular, Map, undefined property', async () => {
  1097. const m = meta('serial')
  1098. await ctx.sessionPersistence.create(m)
  1099. 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[]
  1100. await expect(ctx.sessionPersistence.append(m.id, bad(1n))).rejects.toThrow(/non-JSON-serializable/)
  1101. await expect(ctx.sessionPersistence.append(m.id, bad(() => 0))).rejects.toThrow(/non-JSON-serializable/)
  1102. await expect(ctx.sessionPersistence.append(m.id, bad(Symbol('s')))).rejects.toThrow(/non-JSON-serializable/)
  1103. await expect(ctx.sessionPersistence.append(m.id, bad(new Map()))).rejects.toThrow(/non-JSON-serializable/)
  1104. await expect(ctx.sessionPersistence.append(m.id, bad(undefined))).rejects.toThrow(/non-JSON-serializable/)
  1105. await expect(ctx.sessionPersistence.append(m.id, bad(Infinity))).rejects.toThrow(/non-JSON-serializable/)
  1106. // a circular structure
  1107. const circ: Record<string, unknown> = {}
  1108. circ.self = circ
  1109. await expect(ctx.sessionPersistence.append(m.id, bad(circ))).rejects.toThrow(/non-JSON-serializable/)
  1110. // The session was never materialized by any of the rejected appends.
  1111. expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
  1112. })
  1113. it('accepts well-formed JSON values (null, booleans, nested arrays/objects)', async () => {
  1114. const m = meta('json-ok')
  1115. await ctx.sessionPersistence.create(m)
  1116. 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[]
  1117. await ctx.sessionPersistence.append(m.id, ev)
  1118. expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
  1119. })
  1120. it('Session.append rejects a non-serializable event at the source (never enters the log)', () => {
  1121. const session = ctx.sessions.create(SessionId('reject-bad'))
  1122. // Serializability is enforced at the source: Session.append throws on a BigInt-bearing
  1123. // event before it enters session.events, so the durable log can never diverge from the live
  1124. // log. The error therefore surfaces synchronously at append, not later during backend flush.
  1125. expect(() => {
  1126. session.append('user/message', { content: [{ type: 'text', text: 'bad' }], source: { kind: 'user' }, bad: 1n } as never, { surfaceOp: 'append' })
  1127. }).toThrow(/non-JSON-serializable/)
  1128. // The bad event was rejected, so the log stayed empty.
  1129. expect(session.events.length).toBe(0)
  1130. })
  1131. })