format-decoder.spec.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889
  1. import { afterEach, describe, expect, it, vi } from 'vitest'
  2. import { SessionId } from '@deepseek-ai/dsh-session'
  3. import type { SessionEvent } from '@deepseek-ai/dsh-session'
  4. import {
  5. SessionPersistenceRevision,
  6. SessionPersistenceRevisionConflictError,
  7. } from '../src/revision.ts'
  8. import type {
  9. SessionFormatContext,
  10. SessionFormatStep,
  11. StoredEventReadCompletion,
  12. StoredSessionSource,
  13. } from '../src/format-decoder.ts'
  14. import { sessionFormatVersionRefusal } from '../src/format-decoder.ts'
  15. import { unversionedFormatCompatibility } from '../src/format-v0-compat.ts'
  16. const id = SessionId('format-migration')
  17. function eventLog(): SessionEvent[] {
  18. return [
  19. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  20. { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } },
  21. ]
  22. }
  23. async function collectEvents(events: AsyncIterable<SessionEvent>): Promise<SessionEvent[]> {
  24. const collected: SessionEvent[] = []
  25. for await (const event of events) collected.push(event)
  26. return collected
  27. }
  28. async function decodedFailure(
  29. decoded: ReturnType<typeof import('../src/format-decoder.ts')['decodeStoredSession']>,
  30. ): Promise<Error> {
  31. const completion = decoded.completed.catch((error: unknown) => error)
  32. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  33. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  34. expect(streamFailure).toBe(completionFailure)
  35. expect(streamFailure).toBeInstanceOf(Error)
  36. return streamFailure as Error
  37. }
  38. function storedSource(
  39. version: number,
  40. events: readonly unknown[],
  41. ): { source: StoredSessionSource<never>; reads: number[]; meta: Record<string, unknown> } {
  42. const reads: number[] = []
  43. const meta: Record<string, unknown> = { version, id, createdAt: 1 }
  44. return {
  45. meta,
  46. reads,
  47. source: {
  48. meta,
  49. revision: SessionPersistenceRevision(`format-v${version}`),
  50. readEvents({ fromSeq = 0 } = {}) {
  51. reads.push(fromSeq)
  52. return {
  53. events: (async function* (): AsyncIterable<unknown> {
  54. for (const event of events) {
  55. const seq = typeof event === 'object' && event !== null
  56. ? (event as { seq?: unknown }).seq
  57. : undefined
  58. if (!Number.isSafeInteger(seq) || (seq as number) < 0 || (seq as number) >= fromSeq) {
  59. yield structuredClone(event)
  60. }
  61. }
  62. })(),
  63. completed: Promise.resolve({}),
  64. }
  65. },
  66. },
  67. }
  68. }
  69. function migration(
  70. from: number,
  71. calls: string[],
  72. ): SessionFormatStep {
  73. return {
  74. from,
  75. to: from + 1,
  76. migrateHeader(meta) {
  77. calls.push(`header:${from}`)
  78. return { ...(meta as Record<string, unknown>), version: from + 1 }
  79. },
  80. migrateEvents(events) {
  81. return (async function* (): AsyncIterable<unknown> {
  82. let observedInput = false
  83. for await (const value of events) {
  84. if (!observedInput) {
  85. calls.push(`events:${from}`)
  86. observedInput = true
  87. }
  88. const event = value as SessionEvent
  89. const data = event.data as Record<string, unknown>
  90. const migrationPath = Array.isArray(data['migrationPath'])
  91. ? data['migrationPath'] as unknown[]
  92. : []
  93. yield {
  94. ...event,
  95. data: {
  96. ...data,
  97. [`migratedFrom${from}`]: true,
  98. migrationPath: [...migrationPath, from],
  99. },
  100. }
  101. }
  102. })()
  103. },
  104. }
  105. }
  106. async function configuredDecoder(
  107. currentVersion: number,
  108. migrations: readonly SessionFormatStep[],
  109. calls: string[] = [],
  110. ): Promise<{
  111. decodeStoredSession: typeof import('../src/format-decoder.ts')['decodeStoredSession']
  112. decodeStoredSessionHeader: typeof import('../src/format-decoder.ts')['decodeStoredSessionHeader']
  113. validateHeader: ReturnType<typeof vi.fn>
  114. }> {
  115. vi.resetModules()
  116. const validateHeader = vi.fn((sessionId: SessionId, _seed: unknown, meta: unknown) => {
  117. calls.push('validate-header')
  118. const record = meta as Record<string, unknown>
  119. if (record['version'] !== currentVersion) {
  120. throw new Error(`current header validator received v${String(record['version'])}`)
  121. }
  122. if (record['id'] !== sessionId) throw new Error('current header validator received the wrong id')
  123. if (!Number.isSafeInteger(record['createdAt'])) {
  124. throw new Error('current header validator received invalid createdAt')
  125. }
  126. return { header: Object.freeze(structuredClone(record)) }
  127. })
  128. vi.doMock('@deepseek-ai/dsh-session', async () => {
  129. const actual = await vi.importActual<typeof import('@deepseek-ai/dsh-session')>(
  130. '@deepseek-ai/dsh-session',
  131. )
  132. return {
  133. ...actual,
  134. SESSION_FORMAT_VERSION: currentVersion,
  135. Session: { create: validateHeader },
  136. }
  137. })
  138. vi.doMock('../src/format-migrations/index.ts', () => ({
  139. SESSION_FORMAT_STEPS: migrations,
  140. }))
  141. const decoder = await import('../src/format-decoder.ts')
  142. return {
  143. decodeStoredSession: decoder.decodeStoredSession,
  144. decodeStoredSessionHeader: decoder.decodeStoredSessionHeader,
  145. validateHeader,
  146. }
  147. }
  148. afterEach(() => {
  149. vi.doUnmock('@deepseek-ai/dsh-session')
  150. vi.doUnmock('../src/format-migrations/index.ts')
  151. vi.resetModules()
  152. })
  153. describe('versioned Session format decoder', { concurrent: false }, () => {
  154. it('describes both unsupported format directions', () => {
  155. expect(sessionFormatVersionRefusal(id, 1)).toContain('newer harness')
  156. expect(sessionFormatVersionRefusal(id, -1)).toContain('older than the supported')
  157. })
  158. it('runs a single migration lazily and reads the complete old log before slicing', async () => {
  159. const calls: string[] = []
  160. const step = migration(0, calls)
  161. const { decodeStoredSession, validateHeader } = await configuredDecoder(1, [step], calls)
  162. const originalEvents = eventLog()
  163. const originalSnapshot = structuredClone(originalEvents)
  164. const stored = storedSource(0, originalEvents)
  165. const decoded = decodeStoredSession(stored.source, id, 1)
  166. expect(decoded.sourceVersion).toBe(0)
  167. expect(decoded.meta.version).toBe(1)
  168. expect(calls).toEqual(['header:0', 'validate-header'])
  169. expect(stored.reads).toEqual([])
  170. const migrated = await collectEvents(decoded.events)
  171. await decoded.completed
  172. expect(stored.reads).toEqual([0])
  173. expect(calls).toEqual(['header:0', 'validate-header', 'events:0'])
  174. expect(migrated).toEqual([
  175. {
  176. ...originalEvents[1],
  177. data: { ...originalEvents[1]?.data, migratedFrom0: true, migrationPath: [0] },
  178. },
  179. ])
  180. expect(originalEvents).toEqual(originalSnapshot)
  181. expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 })
  182. expect(validateHeader).toHaveBeenCalledOnce()
  183. })
  184. it('lets an old-format suffix migration use facts from events before fromSeq', async () => {
  185. const step: SessionFormatStep = {
  186. from: 0,
  187. to: 1,
  188. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  189. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  190. let previousSeq: number | undefined
  191. for await (const value of events) {
  192. const event = value as SessionEvent
  193. yield previousSeq === undefined
  194. ? event
  195. : { ...event, data: { ...event.data, previousSeq } }
  196. previousSeq = event.seq
  197. }
  198. })(),
  199. }
  200. const { decodeStoredSession } = await configuredDecoder(1, [step])
  201. const stored = storedSource(0, eventLog())
  202. const decoded = decodeStoredSession(stored.source, id, 1)
  203. const events = await collectEvents(decoded.events)
  204. await decoded.completed
  205. expect(stored.reads).toEqual([0])
  206. expect(events).toEqual([{
  207. ...eventLog()[1],
  208. data: { ...eventLog()[1]?.data, previousSeq: 0 },
  209. }])
  210. })
  211. it('streams migrated events with backpressure instead of buffering the complete log', async () => {
  212. const releaseTail = Promise.withResolvers<undefined>()
  213. const physicalCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
  214. const reads: number[] = []
  215. const source: StoredSessionSource<never> = {
  216. meta: { version: 0, id, createdAt: 1 },
  217. revision: SessionPersistenceRevision('streaming-source'),
  218. readEvents({ fromSeq = 0 } = {}) {
  219. reads.push(fromSeq)
  220. return {
  221. events: (async function* (): AsyncIterable<unknown> {
  222. try {
  223. yield structuredClone(eventLog()[0])
  224. await releaseTail.promise
  225. yield structuredClone(eventLog()[1])
  226. physicalCompletion.resolve({})
  227. } catch (error: unknown) {
  228. physicalCompletion.reject(error)
  229. throw error
  230. }
  231. })(),
  232. completed: physicalCompletion.promise,
  233. }
  234. },
  235. }
  236. const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])])
  237. const decoded = decodeStoredSession(source, id)
  238. const iterator = decoded.events[Symbol.asyncIterator]()
  239. const first = await iterator.next()
  240. expect(first).toMatchObject({ done: false, value: { seq: 0 } })
  241. expect(reads).toEqual([0])
  242. let completed = false
  243. void decoded.completed.then(() => { completed = true })
  244. await Promise.resolve()
  245. expect(completed).toBe(false)
  246. releaseTail.resolve(undefined)
  247. await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { seq: 1 } })
  248. await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined })
  249. await expect(decoded.completed).resolves.toEqual({})
  250. })
  251. it('runs a complete multi-step chain before current header and event validation', async () => {
  252. const calls: string[] = []
  253. const { decodeStoredSession } = await configuredDecoder(
  254. 2,
  255. [migration(0, calls), migration(1, calls)],
  256. calls,
  257. )
  258. const stored = storedSource(0, eventLog())
  259. const decoded = decodeStoredSession(stored.source, id)
  260. expect(decoded.meta.version).toBe(2)
  261. expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
  262. const events = await collectEvents(decoded.events)
  263. await decoded.completed
  264. expect(calls).toEqual([
  265. 'header:0',
  266. 'header:1',
  267. 'validate-header',
  268. 'events:0',
  269. 'events:1',
  270. ])
  271. expect(events[0]?.data).toMatchObject({ migratedFrom0: true, migratedFrom1: true })
  272. expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] })
  273. })
  274. it('plans by version even when registry entries are declared out of order', async () => {
  275. const calls: string[] = []
  276. const { decodeStoredSession } = await configuredDecoder(
  277. 2,
  278. [migration(1, calls), migration(0, calls)],
  279. calls,
  280. )
  281. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  282. const events = await collectEvents(decoded.events)
  283. await decoded.completed
  284. expect(calls.slice(0, 3)).toEqual(['header:0', 'header:1', 'validate-header'])
  285. expect(events[0]?.data).toMatchObject({ migrationPath: [0, 1] })
  286. })
  287. it('starts a multi-version registry at the source version', async () => {
  288. const calls: string[] = []
  289. const { decodeStoredSession } = await configuredDecoder(
  290. 2,
  291. [migration(0, calls), migration(1, calls)],
  292. calls,
  293. )
  294. const stored = storedSource(1, eventLog())
  295. const decoded = decodeStoredSession(stored.source, id, 1)
  296. const events = await collectEvents(decoded.events)
  297. await decoded.completed
  298. expect(calls).toEqual(['header:1', 'validate-header', 'events:1'])
  299. expect(stored.reads).toEqual([0])
  300. expect(events[0]?.data).toMatchObject({ migrationPath: [1] })
  301. })
  302. it('passes the stable stored identity to every header and event step', async () => {
  303. const contexts: SessionFormatContext[] = []
  304. const step: SessionFormatStep = {
  305. from: 0,
  306. to: 1,
  307. migrateHeader(meta, context) {
  308. contexts.push(context)
  309. return { ...(meta as Record<string, unknown>), version: 1 }
  310. },
  311. migrateEvents(events, context) {
  312. contexts.push(context)
  313. return events
  314. },
  315. }
  316. const { decodeStoredSession } = await configuredDecoder(1, [step])
  317. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  318. await collectEvents(decoded.events)
  319. await decoded.completed
  320. expect(contexts).toEqual([{ sessionId: id }, { sessionId: id }])
  321. })
  322. it('migrates and validates a header without requiring an event source', async () => {
  323. const calls: string[] = []
  324. const { decodeStoredSessionHeader } = await configuredDecoder(
  325. 2,
  326. [migration(0, calls), migration(1, calls)],
  327. calls,
  328. )
  329. const header = decodeStoredSessionHeader({ version: 0, id, createdAt: 1 }, id)
  330. expect(header.version).toBe(2)
  331. expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
  332. })
  333. it('applies event migration before the current event vocabulary check', async () => {
  334. const step: SessionFormatStep = {
  335. from: 0,
  336. to: 1,
  337. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  338. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  339. for await (const value of events) {
  340. const event = value as Record<string, unknown>
  341. yield { ...event, type: 'turn/start', data: { turn: 1 } }
  342. }
  343. })(),
  344. }
  345. const { decodeStoredSession } = await configuredDecoder(1, [step])
  346. const stored = storedSource(0, [
  347. { type: 'legacy/turn-begin', seq: 0, time: 1, data: { legacyTurn: 1 } },
  348. ])
  349. const decoded = decodeStoredSession(stored.source, id)
  350. const events = await collectEvents(decoded.events)
  351. await decoded.completed
  352. expect(events).toEqual([
  353. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  354. ])
  355. })
  356. it('uses suffix access directly for the current format', async () => {
  357. const { decodeStoredSession } = await configuredDecoder(2, [])
  358. const stored = storedSource(2, eventLog())
  359. const decoded = decodeStoredSession(stored.source, id, 1)
  360. const events = await collectEvents(decoded.events)
  361. await decoded.completed
  362. expect(stored.reads).toEqual([1])
  363. expect(events).toEqual(eventLog().slice(1))
  364. })
  365. it('buffers a safe current-v0 suffix once without reopening the prefix', async () => {
  366. const { decodeStoredSession } = await configuredDecoder(0, [])
  367. const stored = storedSource(0, eventLog())
  368. const decoded = decodeStoredSession(stored.source, id, 1)
  369. expect(await collectEvents(decoded.events)).toEqual(eventLog().slice(1))
  370. await expect(decoded.completed).resolves.toEqual({})
  371. expect(stored.reads).toEqual([1])
  372. })
  373. it('reopens the complete current-v0 log when a legacy suffix record needs its prefix', async () => {
  374. const { decodeStoredSession } = await configuredDecoder(0, [])
  375. const legacy = [
  376. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  377. {
  378. type: 'steering/message',
  379. seq: 1,
  380. time: 2,
  381. data: { turn: 1, content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } },
  382. },
  383. ]
  384. const stored = storedSource(0, legacy)
  385. const decoded = decodeStoredSession(stored.source, id, 1)
  386. const events = await collectEvents(decoded.events)
  387. await decoded.completed
  388. expect(stored.reads).toEqual([1, 0])
  389. expect(events).toMatchObject([{ type: 'user/message', seq: 1 }])
  390. })
  391. it('observes a failed physical completion after reopening a required v0 prefix', async () => {
  392. const failure = new SessionPersistenceRevisionConflictError('reopened prefix changed')
  393. const fullCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
  394. const source: StoredSessionSource<never> = {
  395. meta: { version: 0, id, createdAt: 1 },
  396. revision: SessionPersistenceRevision('prefix-conflict'),
  397. readEvents({ fromSeq = 0 } = {}) {
  398. if (fromSeq > 0) {
  399. return {
  400. events: (async function* (): AsyncIterable<unknown> {
  401. yield {
  402. type: 'steering/message', seq: 1, time: 2,
  403. data: { turn: 1, content: [], source: { kind: 'user' } },
  404. }
  405. })(),
  406. completed: Promise.resolve({}),
  407. }
  408. }
  409. return {
  410. events: (async function* (): AsyncIterable<unknown> {
  411. fullCompletion.reject(failure)
  412. throw failure
  413. })(),
  414. completed: fullCompletion.promise,
  415. }
  416. },
  417. }
  418. const { decodeStoredSession } = await configuredDecoder(0, [])
  419. const decoded = decodeStoredSession(source, id, 1)
  420. await expect(decodedFailure(decoded)).resolves.toBe(failure)
  421. })
  422. it('classifies every v0 prefix-independent suffix value without assuming a record', () => {
  423. const compatibility = unversionedFormatCompatibility(0)
  424. if (compatibility === undefined) throw new Error('v0 compatibility must be registered')
  425. expect(compatibility.requiresPrefix(null)).toBe(false)
  426. expect(compatibility.requiresPrefix({ type: 'turn/end', data: null })).toBe(false)
  427. expect(compatibility.requiresPrefix({ type: 'user/message', data: { id: 'current', content: [] } })).toBe(false)
  428. expect(compatibility.requiresPrefix({ type: 'user/message', data: { content: [] } })).toBe(true)
  429. expect(compatibility.requiresPrefix({ type: 'assistant/message', data: { content: [] } })).toBe(true)
  430. expect(compatibility.requiresPrefix({ type: 'tool/result', data: { callId: 'call' } })).toBe(true)
  431. })
  432. it('preserves already-canonical v0 turn-end reasons', async () => {
  433. const compatibility = unversionedFormatCompatibility(0)
  434. if (compatibility === undefined) throw new Error('v0 compatibility must be registered')
  435. const events = [
  436. {
  437. type: 'turn/end', seq: 0, time: 1,
  438. data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'disposed' } } },
  439. },
  440. {
  441. type: 'turn/end', seq: 1, time: 2,
  442. data: { turn: 2, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } },
  443. },
  444. ]
  445. const input = (async function* (): AsyncIterable<unknown> {
  446. yield* events
  447. })()
  448. const canonical: unknown[] = []
  449. for await (const event of compatibility.canonicalizeEvents(input, id)) canonical.push(event)
  450. expect(canonical).toEqual(events)
  451. })
  452. it('does not run older registered steps for an already-current source', async () => {
  453. const calls: string[] = []
  454. const { decodeStoredSession } = await configuredDecoder(
  455. 2,
  456. [migration(0, calls), migration(1, calls)],
  457. calls,
  458. )
  459. const stored = storedSource(2, eventLog())
  460. const decoded = decodeStoredSession(stored.source, id, 1)
  461. await collectEvents(decoded.events)
  462. await decoded.completed
  463. expect(calls).toEqual(['validate-header'])
  464. expect(stored.reads).toEqual([1])
  465. })
  466. it('opens a fresh revision-bound reader for each decode of the same source', async () => {
  467. const { decodeStoredSession } = await configuredDecoder(0, [])
  468. const stored = storedSource(0, eventLog())
  469. const first = decodeStoredSession(stored.source, id)
  470. expect(await collectEvents(first.events)).toEqual(eventLog())
  471. await first.completed
  472. const second = decodeStoredSession(stored.source, id)
  473. expect(await collectEvents(second.events)).toEqual(eventLog())
  474. await second.completed
  475. expect(first.revision).toBe(second.revision)
  476. expect(stored.reads).toEqual([0, 0])
  477. })
  478. it('propagates a physical revision conflict unchanged through events and completion', async () => {
  479. const failure = new SessionPersistenceRevisionConflictError('source changed')
  480. const physicalCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
  481. const source: StoredSessionSource<never> = {
  482. meta: { version: 0, id, createdAt: 1 },
  483. revision: SessionPersistenceRevision('conflicting-source'),
  484. readEvents: () => ({
  485. events: (async function* (): AsyncIterable<unknown> {
  486. physicalCompletion.reject(failure)
  487. throw failure
  488. })(),
  489. completed: physicalCompletion.promise,
  490. }),
  491. }
  492. const { decodeStoredSession } = await configuredDecoder(0, [])
  493. const decoded = decodeStoredSession(source, id)
  494. const completion = decoded.completed.catch((error: unknown) => error)
  495. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  496. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  497. expect(streamFailure).toBe(failure)
  498. expect(completionFailure).toBe(failure)
  499. })
  500. it('rejects a missing path and a future source in the correct direction', async () => {
  501. const { decodeStoredSession, validateHeader } = await configuredDecoder(2, [])
  502. const old = storedSource(0, [])
  503. const future = storedSource(3, [])
  504. expect(() => decodeStoredSession(old.source, id))
  505. .toThrow(/missing v0 -> v1/)
  506. expect(() => decodeStoredSession(future.source, id))
  507. .toThrow(/newer harness/)
  508. expect(old.reads).toEqual([])
  509. expect(future.reads).toEqual([])
  510. expect(validateHeader).not.toHaveBeenCalled()
  511. })
  512. it('preserves the raw location in unsupported-format diagnostics', async () => {
  513. const { decodeStoredSession } = await configuredDecoder(0, [])
  514. const stored = storedSource(1, [])
  515. const location = { kind: 'jsonl', path: '/tmp/session.jsonl' }
  516. const source: StoredSessionSource<never> = { ...stored.source, location }
  517. let failure: unknown
  518. try {
  519. decodeStoredSession(source, id)
  520. } catch (error: unknown) {
  521. failure = error
  522. }
  523. expect(failure).toMatchObject({
  524. name: 'SessionFormatUnsupportedError',
  525. location,
  526. })
  527. expect((failure as Error).message).toContain('(raw log: /tmp/session.jsonl)')
  528. })
  529. it('rejects an invalid suffix before validating the header or opening events', async () => {
  530. const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
  531. const stored = storedSource(0, eventLog())
  532. for (const fromSeq of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
  533. expect(() => decodeStoredSession(stored.source, id, fromSeq))
  534. .toThrow(/fromSeq must be a non-negative safe integer/)
  535. }
  536. expect(validateHeader).not.toHaveBeenCalled()
  537. expect(stored.reads).toEqual([])
  538. })
  539. it('validates unknown durable header fields before path selection', async () => {
  540. const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
  541. const cases: Array<{ meta: unknown; message: RegExp }> = [
  542. { meta: null, message: /header is not a lossless JSON record/ },
  543. { meta: { version: '0', id }, message: /invalid format version/ },
  544. { meta: { version: 0, id: 42 }, message: /has no string id/ },
  545. ]
  546. let reads = 0
  547. for (const entry of cases) {
  548. const source: StoredSessionSource<never> = {
  549. meta: entry.meta,
  550. revision: SessionPersistenceRevision('invalid-header'),
  551. readEvents: () => {
  552. reads += 1
  553. return { events: (async function* () {})(), completed: Promise.resolve({}) }
  554. },
  555. }
  556. expect(() => decodeStoredSession(source, id)).toThrow(entry.message)
  557. }
  558. expect(reads).toBe(0)
  559. expect(validateHeader).not.toHaveBeenCalled()
  560. })
  561. it('rejects every malformed current event envelope through the stream and completion', async () => {
  562. const { decodeStoredSession } = await configuredDecoder(1, [])
  563. const cases: Array<{ value: unknown; message: RegExp }> = [
  564. { value: null, message: /non-record event/ },
  565. { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
  566. { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
  567. { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
  568. { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
  569. ]
  570. for (const entry of cases) {
  571. const decoded = decodeStoredSession(storedSource(1, [entry.value]).source, id)
  572. expect((await decodedFailure(decoded)).message).toMatch(entry.message)
  573. }
  574. })
  575. it('rejects a stored event that cannot be represented as JSON', async () => {
  576. const { decodeStoredSession } = await configuredDecoder(1, [])
  577. const decoded = decodeStoredSession(storedSource(1, [undefined]).source, id)
  578. expect((await decodedFailure(decoded)).message).toMatch(/not losslessly JSON-serializable/)
  579. })
  580. it('rejects every malformed v0 event before same-version canonicalization', async () => {
  581. const { decodeStoredSession } = await configuredDecoder(0, [])
  582. const cases: Array<{ value: unknown; message: RegExp }> = [
  583. { value: null, message: /non-record event/ },
  584. { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
  585. { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
  586. { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
  587. { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
  588. ]
  589. for (const entry of cases) {
  590. const decoded = decodeStoredSession(storedSource(0, [entry.value]).source, id)
  591. expect((await decodedFailure(decoded)).message).toMatch(entry.message)
  592. }
  593. })
  594. it('lets a v0 turn/end with opaque data reach current validation unchanged', async () => {
  595. const { decodeStoredSession } = await configuredDecoder(0, [])
  596. const decoded = decodeStoredSession(storedSource(0, [
  597. { type: 'turn/end', seq: 0, time: 1, data: null },
  598. ]).source, id)
  599. await expect(collectEvents(decoded.events)).resolves.toEqual([
  600. { type: 'turn/end', seq: 0, time: 1, data: null },
  601. ])
  602. await expect(decoded.completed).resolves.toEqual({})
  603. })
  604. it('rejects a step that returns the wrong header version', async () => {
  605. const bad: SessionFormatStep = {
  606. from: 0,
  607. to: 1,
  608. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 0 }),
  609. migrateEvents: events => events,
  610. }
  611. const first = await configuredDecoder(1, [bad])
  612. expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
  613. .toThrow(/returned header version 0/)
  614. expect(first.validateHeader).not.toHaveBeenCalled()
  615. const calls: string[] = []
  616. const badSecond: SessionFormatStep = {
  617. from: 1,
  618. to: 2,
  619. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  620. migrateEvents: events => events,
  621. }
  622. const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls)
  623. const stored = storedSource(0, [])
  624. expect(() => second.decodeStoredSession(stored.source, id))
  625. .toThrow(/v1 -> v2 returned header version 1/)
  626. expect(calls).toEqual(['header:0'])
  627. expect(second.validateHeader).not.toHaveBeenCalled()
  628. expect(stored.reads).toEqual([])
  629. })
  630. it('rejects a step that changes the session id or cwd storage identity', async () => {
  631. const changedId: SessionFormatStep = {
  632. from: 0,
  633. to: 1,
  634. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, id: 'other' }),
  635. migrateEvents: events => events,
  636. }
  637. const first = await configuredDecoder(1, [changedId])
  638. expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
  639. .toThrow(/changed session storage identity/)
  640. const changedCwd: SessionFormatStep = {
  641. from: 0,
  642. to: 1,
  643. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, cwd: '/other' }),
  644. migrateEvents: events => events,
  645. }
  646. const second = await configuredDecoder(1, [changedCwd])
  647. const stored = storedSource(0, [])
  648. stored.meta['cwd'] = '/work'
  649. expect(() => second.decodeStoredSession(stored.source, id))
  650. .toThrow(/changed session storage identity/)
  651. })
  652. it('wraps a header migration failure with the failing version step', async () => {
  653. const cause = new Error('bad legacy header')
  654. const step: SessionFormatStep = {
  655. from: 0,
  656. to: 1,
  657. migrateHeader: () => { throw cause },
  658. migrateEvents: events => events,
  659. }
  660. const { decodeStoredSession } = await configuredDecoder(1, [step])
  661. let failure: unknown
  662. try {
  663. decodeStoredSession(storedSource(0, []).source, id)
  664. } catch (error: unknown) {
  665. failure = error
  666. }
  667. expect(failure).toMatchObject({
  668. message: `session "${id}" header migration v0 -> v1 failed`,
  669. cause,
  670. })
  671. })
  672. it('mirrors an event migration failure through the stream and completion promise', async () => {
  673. const cause = new Error('bad legacy event')
  674. const step: SessionFormatStep = {
  675. from: 0,
  676. to: 1,
  677. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  678. migrateEvents: () => (async function* (): AsyncIterable<unknown> {
  679. throw cause
  680. })(),
  681. }
  682. const { decodeStoredSession } = await configuredDecoder(1, [step])
  683. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  684. const completion = decoded.completed.catch((error: unknown) => error)
  685. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  686. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  687. expect(streamFailure).toBe(completionFailure)
  688. expect(streamFailure).toMatchObject({
  689. message: `session "${id}" event migration v0 -> v1 failed`,
  690. cause,
  691. })
  692. })
  693. it('runs current event validation on the migrated output', async () => {
  694. const step: SessionFormatStep = {
  695. from: 0,
  696. to: 1,
  697. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  698. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  699. for await (const value of events) {
  700. const event = value as SessionEvent
  701. yield { ...event, seq: event.seq + 1 }
  702. }
  703. })(),
  704. }
  705. const { decodeStoredSession } = await configuredDecoder(1, [step])
  706. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  707. const completion = decoded.completed.catch((error: unknown) => error)
  708. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  709. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  710. expect(streamFailure).toBe(completionFailure)
  711. expect((streamFailure as Error).message).toMatch(/expected 0, got 1/)
  712. })
  713. it('runs current header validation only after the final header step', async () => {
  714. const calls: string[] = []
  715. const finalStep: SessionFormatStep = {
  716. from: 1,
  717. to: 2,
  718. migrateHeader(meta) {
  719. calls.push('header:1')
  720. const { createdAt: _createdAt, ...rest } = meta as Record<string, unknown>
  721. return { ...rest, version: 2 }
  722. },
  723. migrateEvents: events => events,
  724. }
  725. const { decodeStoredSession } = await configuredDecoder(
  726. 2,
  727. [migration(0, calls), finalStep],
  728. calls,
  729. )
  730. const stored = storedSource(0, eventLog())
  731. expect(() => decodeStoredSession(stored.source, id))
  732. .toThrow(/current header validator received invalid createdAt/)
  733. expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
  734. expect(stored.reads).toEqual([])
  735. })
  736. it('detaches stored header and event objects before a mutating migration runs', async () => {
  737. const originalEvents = eventLog()
  738. const eventSnapshot = structuredClone(originalEvents)
  739. const step: SessionFormatStep = {
  740. from: 0,
  741. to: 1,
  742. migrateHeader(meta) {
  743. const record = meta as Record<string, unknown>
  744. record['version'] = 1
  745. return record
  746. },
  747. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  748. for await (const value of events) {
  749. const event = value as SessionEvent
  750. const data = event.data as Record<string, unknown>
  751. data['mutated'] = true
  752. yield event
  753. }
  754. })(),
  755. }
  756. const { decodeStoredSession } = await configuredDecoder(1, [step])
  757. const stored = storedSource(0, originalEvents)
  758. const decoded = decodeStoredSession(stored.source, id)
  759. const migrated = await collectEvents(decoded.events)
  760. await decoded.completed
  761. expect(migrated.every(event => (event.data as Record<string, unknown>)['mutated'] === true)).toBe(true)
  762. expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 })
  763. expect(originalEvents).toEqual(eventSnapshot)
  764. })
  765. it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => {
  766. const calls: string[] = []
  767. await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)]))
  768. .rejects.toThrow(/duplicate Session format step/)
  769. await expect(configuredDecoder(1, [migration(-1, calls)]))
  770. .rejects.toThrow(/adjacent non-negative version/)
  771. const nonAdjacent: SessionFormatStep = {
  772. ...migration(0, calls),
  773. to: 2,
  774. }
  775. await expect(configuredDecoder(2, [nonAdjacent]))
  776. .rejects.toThrow(/adjacent non-negative version/)
  777. const fractional: SessionFormatStep = {
  778. ...migration(0, calls),
  779. from: 0.5,
  780. to: 1.5,
  781. }
  782. await expect(configuredDecoder(2, [fractional]))
  783. .rejects.toThrow(/adjacent non-negative version/)
  784. await expect(configuredDecoder(1, [migration(1, calls)]))
  785. .rejects.toThrow(/targets a version newer than this build/)
  786. await expect(configuredDecoder(2, [migration(0, calls)]))
  787. .rejects.toThrow(/incomplete path/)
  788. })
  789. })