format-decoder.spec.ts 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906
  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('propagates an upstream revision conflict unchanged through a migration step', async () => {
  501. const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])])
  502. const { SessionPersistenceRevisionConflictError: DecoderRevisionConflictError } = await import('../src/revision.ts')
  503. const failure = new DecoderRevisionConflictError('migrating source changed')
  504. const source: StoredSessionSource<never> = {
  505. meta: { version: 0, id, createdAt: 1 },
  506. revision: SessionPersistenceRevision('conflicting-migration-source'),
  507. readEvents: () => ({
  508. events: (async function* (): AsyncIterable<unknown> {
  509. throw failure
  510. })(),
  511. completed: Promise.reject(failure),
  512. }),
  513. }
  514. await expect(decodedFailure(decodeStoredSession(source, id))).resolves.toBe(failure)
  515. })
  516. it('rejects a missing path and a future source in the correct direction', async () => {
  517. const { decodeStoredSession, validateHeader } = await configuredDecoder(2, [])
  518. const old = storedSource(0, [])
  519. const future = storedSource(3, [])
  520. expect(() => decodeStoredSession(old.source, id))
  521. .toThrow(/missing v0 -> v1/)
  522. expect(() => decodeStoredSession(future.source, id))
  523. .toThrow(/newer harness/)
  524. expect(old.reads).toEqual([])
  525. expect(future.reads).toEqual([])
  526. expect(validateHeader).not.toHaveBeenCalled()
  527. })
  528. it('preserves the raw location in unsupported-format diagnostics', async () => {
  529. const { decodeStoredSession } = await configuredDecoder(0, [])
  530. const stored = storedSource(1, [])
  531. const location = { kind: 'jsonl', path: '/tmp/session.jsonl' }
  532. const source: StoredSessionSource<never> = { ...stored.source, location }
  533. let failure: unknown
  534. try {
  535. decodeStoredSession(source, id)
  536. } catch (error: unknown) {
  537. failure = error
  538. }
  539. expect(failure).toMatchObject({
  540. name: 'SessionFormatUnsupportedError',
  541. location,
  542. })
  543. expect((failure as Error).message).toContain('(raw log: /tmp/session.jsonl)')
  544. })
  545. it('rejects an invalid suffix before validating the header or opening events', async () => {
  546. const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
  547. const stored = storedSource(0, eventLog())
  548. for (const fromSeq of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
  549. expect(() => decodeStoredSession(stored.source, id, fromSeq))
  550. .toThrow(/fromSeq must be a non-negative safe integer/)
  551. }
  552. expect(validateHeader).not.toHaveBeenCalled()
  553. expect(stored.reads).toEqual([])
  554. })
  555. it('validates unknown durable header fields before path selection', async () => {
  556. const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
  557. const cases: Array<{ meta: unknown; message: RegExp }> = [
  558. { meta: null, message: /header is not a lossless JSON record/ },
  559. { meta: { version: '0', id }, message: /invalid format version/ },
  560. { meta: { version: 0, id: 42 }, message: /has no string id/ },
  561. ]
  562. let reads = 0
  563. for (const entry of cases) {
  564. const source: StoredSessionSource<never> = {
  565. meta: entry.meta,
  566. revision: SessionPersistenceRevision('invalid-header'),
  567. readEvents: () => {
  568. reads += 1
  569. return { events: (async function* () {})(), completed: Promise.resolve({}) }
  570. },
  571. }
  572. expect(() => decodeStoredSession(source, id)).toThrow(entry.message)
  573. }
  574. expect(reads).toBe(0)
  575. expect(validateHeader).not.toHaveBeenCalled()
  576. })
  577. it('rejects every malformed current event envelope through the stream and completion', async () => {
  578. const { decodeStoredSession } = await configuredDecoder(1, [])
  579. const cases: Array<{ value: unknown; message: RegExp }> = [
  580. { value: null, message: /non-record event/ },
  581. { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
  582. { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
  583. { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
  584. { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
  585. ]
  586. for (const entry of cases) {
  587. const decoded = decodeStoredSession(storedSource(1, [entry.value]).source, id)
  588. expect((await decodedFailure(decoded)).message).toMatch(entry.message)
  589. }
  590. })
  591. it('rejects a stored event that cannot be represented as JSON', async () => {
  592. const { decodeStoredSession } = await configuredDecoder(1, [])
  593. const decoded = decodeStoredSession(storedSource(1, [undefined]).source, id)
  594. expect((await decodedFailure(decoded)).message).toMatch(/not losslessly JSON-serializable/)
  595. })
  596. it('rejects every malformed v0 event before same-version canonicalization', async () => {
  597. const { decodeStoredSession } = await configuredDecoder(0, [])
  598. const cases: Array<{ value: unknown; message: RegExp }> = [
  599. { value: null, message: /non-record event/ },
  600. { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
  601. { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
  602. { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
  603. { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
  604. ]
  605. for (const entry of cases) {
  606. const decoded = decodeStoredSession(storedSource(0, [entry.value]).source, id)
  607. expect((await decodedFailure(decoded)).message).toMatch(entry.message)
  608. }
  609. })
  610. it('lets a v0 turn/end with opaque data reach current validation unchanged', async () => {
  611. const { decodeStoredSession } = await configuredDecoder(0, [])
  612. const decoded = decodeStoredSession(storedSource(0, [
  613. { type: 'turn/end', seq: 0, time: 1, data: null },
  614. ]).source, id)
  615. await expect(collectEvents(decoded.events)).resolves.toEqual([
  616. { type: 'turn/end', seq: 0, time: 1, data: null },
  617. ])
  618. await expect(decoded.completed).resolves.toEqual({})
  619. })
  620. it('rejects a step that returns the wrong header version', async () => {
  621. const bad: SessionFormatStep = {
  622. from: 0,
  623. to: 1,
  624. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 0 }),
  625. migrateEvents: events => events,
  626. }
  627. const first = await configuredDecoder(1, [bad])
  628. expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
  629. .toThrow(/returned header version 0/)
  630. expect(first.validateHeader).not.toHaveBeenCalled()
  631. const calls: string[] = []
  632. const badSecond: SessionFormatStep = {
  633. from: 1,
  634. to: 2,
  635. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  636. migrateEvents: events => events,
  637. }
  638. const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls)
  639. const stored = storedSource(0, [])
  640. expect(() => second.decodeStoredSession(stored.source, id))
  641. .toThrow(/v1 -> v2 returned header version 1/)
  642. expect(calls).toEqual(['header:0'])
  643. expect(second.validateHeader).not.toHaveBeenCalled()
  644. expect(stored.reads).toEqual([])
  645. })
  646. it('rejects a step that changes the session id or cwd storage identity', async () => {
  647. const changedId: SessionFormatStep = {
  648. from: 0,
  649. to: 1,
  650. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, id: 'other' }),
  651. migrateEvents: events => events,
  652. }
  653. const first = await configuredDecoder(1, [changedId])
  654. expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
  655. .toThrow(/changed session storage identity/)
  656. const changedCwd: SessionFormatStep = {
  657. from: 0,
  658. to: 1,
  659. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, cwd: '/other' }),
  660. migrateEvents: events => events,
  661. }
  662. const second = await configuredDecoder(1, [changedCwd])
  663. const stored = storedSource(0, [])
  664. stored.meta['cwd'] = '/work'
  665. expect(() => second.decodeStoredSession(stored.source, id))
  666. .toThrow(/changed session storage identity/)
  667. })
  668. it('wraps a header migration failure with the failing version step', async () => {
  669. const cause = new Error('bad legacy header')
  670. const step: SessionFormatStep = {
  671. from: 0,
  672. to: 1,
  673. migrateHeader: () => { throw cause },
  674. migrateEvents: events => events,
  675. }
  676. const { decodeStoredSession } = await configuredDecoder(1, [step])
  677. let failure: unknown
  678. try {
  679. decodeStoredSession(storedSource(0, []).source, id)
  680. } catch (error: unknown) {
  681. failure = error
  682. }
  683. expect(failure).toMatchObject({
  684. message: `session "${id}" header migration v0 -> v1 failed`,
  685. cause,
  686. })
  687. })
  688. it('mirrors an event migration failure through the stream and completion promise', async () => {
  689. const cause = new Error('bad legacy event')
  690. const step: SessionFormatStep = {
  691. from: 0,
  692. to: 1,
  693. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  694. migrateEvents: () => (async function* (): AsyncIterable<unknown> {
  695. throw cause
  696. })(),
  697. }
  698. const { decodeStoredSession } = await configuredDecoder(1, [step])
  699. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  700. const completion = decoded.completed.catch((error: unknown) => error)
  701. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  702. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  703. expect(streamFailure).toBe(completionFailure)
  704. expect(streamFailure).toMatchObject({
  705. message: `session "${id}" event migration v0 -> v1 failed`,
  706. cause,
  707. })
  708. })
  709. it('runs current event validation on the migrated output', async () => {
  710. const step: SessionFormatStep = {
  711. from: 0,
  712. to: 1,
  713. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  714. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  715. for await (const value of events) {
  716. const event = value as SessionEvent
  717. yield { ...event, seq: event.seq + 1 }
  718. }
  719. })(),
  720. }
  721. const { decodeStoredSession } = await configuredDecoder(1, [step])
  722. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  723. const completion = decoded.completed.catch((error: unknown) => error)
  724. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  725. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  726. expect(streamFailure).toBe(completionFailure)
  727. expect((streamFailure as Error).message).toMatch(/expected 0, got 1/)
  728. })
  729. it('runs current header validation only after the final header step', async () => {
  730. const calls: string[] = []
  731. const finalStep: SessionFormatStep = {
  732. from: 1,
  733. to: 2,
  734. migrateHeader(meta) {
  735. calls.push('header:1')
  736. const { createdAt: _createdAt, ...rest } = meta as Record<string, unknown>
  737. return { ...rest, version: 2 }
  738. },
  739. migrateEvents: events => events,
  740. }
  741. const { decodeStoredSession } = await configuredDecoder(
  742. 2,
  743. [migration(0, calls), finalStep],
  744. calls,
  745. )
  746. const stored = storedSource(0, eventLog())
  747. expect(() => decodeStoredSession(stored.source, id))
  748. .toThrow(/current header validator received invalid createdAt/)
  749. expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
  750. expect(stored.reads).toEqual([])
  751. })
  752. it('detaches stored header and event objects before a mutating migration runs', async () => {
  753. const originalEvents = eventLog()
  754. const eventSnapshot = structuredClone(originalEvents)
  755. const step: SessionFormatStep = {
  756. from: 0,
  757. to: 1,
  758. migrateHeader(meta) {
  759. const record = meta as Record<string, unknown>
  760. record['version'] = 1
  761. return record
  762. },
  763. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  764. for await (const value of events) {
  765. const event = value as SessionEvent
  766. const data = event.data as Record<string, unknown>
  767. data['mutated'] = true
  768. yield event
  769. }
  770. })(),
  771. }
  772. const { decodeStoredSession } = await configuredDecoder(1, [step])
  773. const stored = storedSource(0, originalEvents)
  774. const decoded = decodeStoredSession(stored.source, id)
  775. const migrated = await collectEvents(decoded.events)
  776. await decoded.completed
  777. expect(migrated.every(event => (event.data as Record<string, unknown>)['mutated'] === true)).toBe(true)
  778. expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 })
  779. expect(originalEvents).toEqual(eventSnapshot)
  780. })
  781. it('rejects duplicate, invalid, future-targeting, and incomplete static registries at initialization', async () => {
  782. const calls: string[] = []
  783. await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)]))
  784. .rejects.toThrow(/duplicate Session format step/)
  785. await expect(configuredDecoder(1, [migration(-1, calls)]))
  786. .rejects.toThrow(/adjacent non-negative version/)
  787. const nonAdjacent: SessionFormatStep = {
  788. ...migration(0, calls),
  789. to: 2,
  790. }
  791. await expect(configuredDecoder(2, [nonAdjacent]))
  792. .rejects.toThrow(/adjacent non-negative version/)
  793. const fractional: SessionFormatStep = {
  794. ...migration(0, calls),
  795. from: 0.5,
  796. to: 1.5,
  797. }
  798. await expect(configuredDecoder(2, [fractional]))
  799. .rejects.toThrow(/adjacent non-negative version/)
  800. await expect(configuredDecoder(1, [migration(1, calls)]))
  801. .rejects.toThrow(/targets a version newer than this build/)
  802. await expect(configuredDecoder(2, [migration(0, calls)]))
  803. .rejects.toThrow(/incomplete path/)
  804. })
  805. })