format-decoder.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979
  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('canonicalizes every historical compact event name without changing its record', async () => {
  453. const { decodeStoredSession } = await configuredDecoder(0, [])
  454. const events = [
  455. {
  456. type: 'compact/start', seq: 0, time: 1,
  457. data: { compactionId: 'legacy', turn: 1 },
  458. surfaceOp: { op: 'retain' },
  459. },
  460. {
  461. type: 'compact/summary', seq: 1, time: 2,
  462. data: { summary: 'old summary', shadowedSeqs: [7, 8] },
  463. durableMetadata: { source: 'historical-v0' },
  464. },
  465. {
  466. type: 'compaction/end', seq: 2, time: 3,
  467. data: { compactionId: 'current', turn: 1 },
  468. },
  469. {
  470. type: 'compact/end', seq: 3, time: 4,
  471. data: { compactionId: 'legacy', turn: 1 },
  472. },
  473. {
  474. type: 'compact/prune', seq: 4, time: 5,
  475. data: {
  476. shadowedRange: { start: 7, end: 8 },
  477. shadowedSeqs: [7, 8],
  478. shadowedTokenCount: 456,
  479. },
  480. },
  481. ]
  482. const stored = storedSource(0, events)
  483. const decoded = decodeStoredSession(stored.source, id)
  484. const canonical = await collectEvents(decoded.events)
  485. await decoded.completed
  486. expect(canonical).toEqual(events.map(event => ({
  487. ...event,
  488. type: event.type.replace(/^compact\//, 'compaction/'),
  489. })))
  490. expect(stored.reads).toEqual([0])
  491. })
  492. it('still rejects other unknown v0 event names after compaction normalization', async () => {
  493. const { decodeStoredSession } = await configuredDecoder(0, [])
  494. const decoded = decodeStoredSession(storedSource(0, [{
  495. type: 'compact/future', seq: 0, time: 1, data: {},
  496. }]).source, id)
  497. const failure = await decodedFailure(decoded)
  498. expect(failure.message).toMatch(/event type "compact\/future".*not marked ignorable/)
  499. })
  500. it('does not run older registered steps for an already-current source', async () => {
  501. const calls: string[] = []
  502. const { decodeStoredSession } = await configuredDecoder(
  503. 2,
  504. [migration(0, calls), migration(1, calls)],
  505. calls,
  506. )
  507. const stored = storedSource(2, eventLog())
  508. const decoded = decodeStoredSession(stored.source, id, 1)
  509. await collectEvents(decoded.events)
  510. await decoded.completed
  511. expect(calls).toEqual(['validate-header'])
  512. expect(stored.reads).toEqual([1])
  513. })
  514. it('opens a fresh revision-bound reader for each decode of the same source', async () => {
  515. const { decodeStoredSession } = await configuredDecoder(0, [])
  516. const stored = storedSource(0, eventLog())
  517. const first = decodeStoredSession(stored.source, id)
  518. expect(await collectEvents(first.events)).toEqual(eventLog())
  519. await first.completed
  520. const second = decodeStoredSession(stored.source, id)
  521. expect(await collectEvents(second.events)).toEqual(eventLog())
  522. await second.completed
  523. expect(first.revision).toBe(second.revision)
  524. expect(stored.reads).toEqual([0, 0])
  525. })
  526. it('propagates a physical revision conflict unchanged through events and completion', async () => {
  527. const failure = new SessionPersistenceRevisionConflictError('source changed')
  528. const physicalCompletion = Promise.withResolvers<StoredEventReadCompletion<never>>()
  529. const source: StoredSessionSource<never> = {
  530. meta: { version: 0, id, createdAt: 1 },
  531. revision: SessionPersistenceRevision('conflicting-source'),
  532. readEvents: () => ({
  533. events: (async function* (): AsyncIterable<unknown> {
  534. physicalCompletion.reject(failure)
  535. throw failure
  536. })(),
  537. completed: physicalCompletion.promise,
  538. }),
  539. }
  540. const { decodeStoredSession } = await configuredDecoder(0, [])
  541. const decoded = decodeStoredSession(source, id)
  542. const completion = decoded.completed.catch((error: unknown) => error)
  543. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  544. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  545. expect(streamFailure).toBe(failure)
  546. expect(completionFailure).toBe(failure)
  547. })
  548. it('propagates an upstream revision conflict unchanged through a migration step', async () => {
  549. const { decodeStoredSession } = await configuredDecoder(1, [migration(0, [])])
  550. const { SessionPersistenceRevisionConflictError: DecoderRevisionConflictError } = await import('../src/revision.ts')
  551. const failure = new DecoderRevisionConflictError('migrating source changed')
  552. const source: StoredSessionSource<never> = {
  553. meta: { version: 0, id, createdAt: 1 },
  554. revision: SessionPersistenceRevision('conflicting-migration-source'),
  555. readEvents: () => ({
  556. events: (async function* (): AsyncIterable<unknown> {
  557. throw failure
  558. })(),
  559. completed: Promise.reject(failure),
  560. }),
  561. }
  562. await expect(decodedFailure(decodeStoredSession(source, id))).resolves.toBe(failure)
  563. })
  564. it('rejects a missing path and a future source in the correct direction', async () => {
  565. const { decodeStoredSession, validateHeader } = await configuredDecoder(2, [])
  566. const old = storedSource(0, [])
  567. const future = storedSource(3, [])
  568. expect(() => decodeStoredSession(old.source, id))
  569. .toThrow(/missing v0 -> v1/)
  570. expect(() => decodeStoredSession(future.source, id))
  571. .toThrow(/newer harness/)
  572. expect(old.reads).toEqual([])
  573. expect(future.reads).toEqual([])
  574. expect(validateHeader).not.toHaveBeenCalled()
  575. })
  576. it('preserves the raw location in unsupported-format diagnostics', async () => {
  577. const { decodeStoredSession } = await configuredDecoder(0, [])
  578. const stored = storedSource(1, [])
  579. const location = { kind: 'jsonl', path: '/tmp/session.jsonl' }
  580. const source: StoredSessionSource<never> = { ...stored.source, location }
  581. let failure: unknown
  582. try {
  583. decodeStoredSession(source, id)
  584. } catch (error: unknown) {
  585. failure = error
  586. }
  587. expect(failure).toMatchObject({
  588. name: 'SessionFormatUnsupportedError',
  589. location,
  590. })
  591. expect((failure as Error).message).toContain('(raw log: /tmp/session.jsonl)')
  592. })
  593. it('rejects an invalid suffix before validating the header or opening events', async () => {
  594. const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
  595. const stored = storedSource(0, eventLog())
  596. for (const fromSeq of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
  597. expect(() => decodeStoredSession(stored.source, id, fromSeq))
  598. .toThrow(/fromSeq must be a non-negative safe integer/)
  599. }
  600. expect(validateHeader).not.toHaveBeenCalled()
  601. expect(stored.reads).toEqual([])
  602. })
  603. it('validates unknown durable header fields before path selection', async () => {
  604. const { decodeStoredSession, validateHeader } = await configuredDecoder(0, [])
  605. const cases: Array<{ meta: unknown; message: RegExp }> = [
  606. { meta: null, message: /header is not a lossless JSON record/ },
  607. { meta: { version: '0', id }, message: /invalid format version/ },
  608. { meta: { version: 0, id: 42 }, message: /has no string id/ },
  609. ]
  610. let reads = 0
  611. for (const entry of cases) {
  612. const source: StoredSessionSource<never> = {
  613. meta: entry.meta,
  614. revision: SessionPersistenceRevision('invalid-header'),
  615. readEvents: () => {
  616. reads += 1
  617. return { events: (async function* () {})(), completed: Promise.resolve({}) }
  618. },
  619. }
  620. expect(() => decodeStoredSession(source, id)).toThrow(entry.message)
  621. }
  622. expect(reads).toBe(0)
  623. expect(validateHeader).not.toHaveBeenCalled()
  624. })
  625. it('rejects every malformed current event envelope through the stream and completion', async () => {
  626. const { decodeStoredSession } = await configuredDecoder(1, [])
  627. const cases: Array<{ value: unknown; message: RegExp }> = [
  628. { value: null, message: /non-record event/ },
  629. { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
  630. { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
  631. { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
  632. { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
  633. ]
  634. for (const entry of cases) {
  635. const decoded = decodeStoredSession(storedSource(1, [entry.value]).source, id)
  636. expect((await decodedFailure(decoded)).message).toMatch(entry.message)
  637. }
  638. })
  639. it('rejects a stored event that cannot be represented as JSON', async () => {
  640. const { decodeStoredSession } = await configuredDecoder(1, [])
  641. const decoded = decodeStoredSession(storedSource(1, [undefined]).source, id)
  642. expect((await decodedFailure(decoded)).message).toMatch(/not losslessly JSON-serializable/)
  643. })
  644. it('rejects every malformed v0 event before same-version canonicalization', async () => {
  645. const { decodeStoredSession } = await configuredDecoder(0, [])
  646. const cases: Array<{ value: unknown; message: RegExp }> = [
  647. { value: null, message: /non-record event/ },
  648. { value: { seq: 0, time: 1, data: {} }, message: /without a string type/ },
  649. { value: { type: 'turn/start', seq: -1, time: 1, data: {} }, message: /invalid seq -1/ },
  650. { value: { type: 'turn/start', seq: 0, time: 'now', data: {} }, message: /invalid time/ },
  651. { value: { type: 'turn/start', seq: 0, time: 1 }, message: /without data/ },
  652. ]
  653. for (const entry of cases) {
  654. const decoded = decodeStoredSession(storedSource(0, [entry.value]).source, id)
  655. expect((await decodedFailure(decoded)).message).toMatch(entry.message)
  656. }
  657. })
  658. it('lets a v0 turn/end with opaque data reach current validation unchanged', async () => {
  659. const { decodeStoredSession } = await configuredDecoder(0, [])
  660. const decoded = decodeStoredSession(storedSource(0, [
  661. { type: 'turn/end', seq: 0, time: 1, data: null },
  662. ]).source, id)
  663. await expect(collectEvents(decoded.events)).resolves.toEqual([
  664. { type: 'turn/end', seq: 0, time: 1, data: null },
  665. ])
  666. await expect(decoded.completed).resolves.toEqual({})
  667. })
  668. it('rejects a step that returns the wrong header version', async () => {
  669. const bad: SessionFormatStep = {
  670. from: 0,
  671. to: 1,
  672. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 0 }),
  673. migrateEvents: events => events,
  674. }
  675. const first = await configuredDecoder(1, [bad])
  676. expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
  677. .toThrow(/returned header version 0/)
  678. expect(first.validateHeader).not.toHaveBeenCalled()
  679. const calls: string[] = []
  680. const badSecond: SessionFormatStep = {
  681. from: 1,
  682. to: 2,
  683. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  684. migrateEvents: events => events,
  685. }
  686. const second = await configuredDecoder(2, [migration(0, calls), badSecond], calls)
  687. const stored = storedSource(0, [])
  688. expect(() => second.decodeStoredSession(stored.source, id))
  689. .toThrow(/v1 -> v2 returned header version 1/)
  690. expect(calls).toEqual(['header:0'])
  691. expect(second.validateHeader).not.toHaveBeenCalled()
  692. expect(stored.reads).toEqual([])
  693. })
  694. it('rejects a step that changes the session id or cwd storage identity', async () => {
  695. const changedId: SessionFormatStep = {
  696. from: 0,
  697. to: 1,
  698. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, id: 'other' }),
  699. migrateEvents: events => events,
  700. }
  701. const first = await configuredDecoder(1, [changedId])
  702. expect(() => first.decodeStoredSession(storedSource(0, []).source, id))
  703. .toThrow(/changed session storage identity/)
  704. const changedCwd: SessionFormatStep = {
  705. from: 0,
  706. to: 1,
  707. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1, cwd: '/other' }),
  708. migrateEvents: events => events,
  709. }
  710. const second = await configuredDecoder(1, [changedCwd])
  711. const stored = storedSource(0, [])
  712. stored.meta['cwd'] = '/work'
  713. expect(() => second.decodeStoredSession(stored.source, id))
  714. .toThrow(/changed session storage identity/)
  715. })
  716. it('wraps a header migration failure with the failing version step', async () => {
  717. const cause = new Error('bad legacy header')
  718. const step: SessionFormatStep = {
  719. from: 0,
  720. to: 1,
  721. migrateHeader: () => { throw cause },
  722. migrateEvents: events => events,
  723. }
  724. const { decodeStoredSession } = await configuredDecoder(1, [step])
  725. let failure: unknown
  726. try {
  727. decodeStoredSession(storedSource(0, []).source, id)
  728. } catch (error: unknown) {
  729. failure = error
  730. }
  731. expect(failure).toMatchObject({
  732. message: `session "${id}" header migration v0 -> v1 failed`,
  733. cause,
  734. })
  735. })
  736. it('mirrors an event migration failure through the stream and completion promise', async () => {
  737. const cause = new Error('bad legacy event')
  738. const step: SessionFormatStep = {
  739. from: 0,
  740. to: 1,
  741. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  742. migrateEvents: () => (async function* (): AsyncIterable<unknown> {
  743. throw cause
  744. })(),
  745. }
  746. const { decodeStoredSession } = await configuredDecoder(1, [step])
  747. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  748. const completion = decoded.completed.catch((error: unknown) => error)
  749. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  750. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  751. expect(streamFailure).toBe(completionFailure)
  752. expect(streamFailure).toMatchObject({
  753. message: `session "${id}" event migration v0 -> v1 failed`,
  754. cause,
  755. })
  756. })
  757. it('runs current event validation on the migrated output', async () => {
  758. const step: SessionFormatStep = {
  759. from: 0,
  760. to: 1,
  761. migrateHeader: meta => ({ ...(meta as Record<string, unknown>), version: 1 }),
  762. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  763. for await (const value of events) {
  764. const event = value as SessionEvent
  765. yield { ...event, seq: event.seq + 1 }
  766. }
  767. })(),
  768. }
  769. const { decodeStoredSession } = await configuredDecoder(1, [step])
  770. const decoded = decodeStoredSession(storedSource(0, eventLog()).source, id)
  771. const completion = decoded.completed.catch((error: unknown) => error)
  772. const consumption = collectEvents(decoded.events).catch((error: unknown) => error)
  773. const [streamFailure, completionFailure] = await Promise.all([consumption, completion])
  774. expect(streamFailure).toBe(completionFailure)
  775. expect((streamFailure as Error).message).toMatch(/expected 0, got 1/)
  776. })
  777. it('runs current header validation only after the final header step', async () => {
  778. const calls: string[] = []
  779. const finalStep: SessionFormatStep = {
  780. from: 1,
  781. to: 2,
  782. migrateHeader(meta) {
  783. calls.push('header:1')
  784. const { createdAt: _createdAt, ...rest } = meta as Record<string, unknown>
  785. return { ...rest, version: 2 }
  786. },
  787. migrateEvents: events => events,
  788. }
  789. const { decodeStoredSession } = await configuredDecoder(
  790. 2,
  791. [migration(0, calls), finalStep],
  792. calls,
  793. )
  794. const stored = storedSource(0, eventLog())
  795. expect(() => decodeStoredSession(stored.source, id))
  796. .toThrow(/current header validator received invalid createdAt/)
  797. expect(calls).toEqual(['header:0', 'header:1', 'validate-header'])
  798. expect(stored.reads).toEqual([])
  799. })
  800. it('detaches stored header and event objects before a mutating migration runs', async () => {
  801. const originalEvents = eventLog()
  802. const eventSnapshot = structuredClone(originalEvents)
  803. const step: SessionFormatStep = {
  804. from: 0,
  805. to: 1,
  806. migrateHeader(meta) {
  807. const record = meta as Record<string, unknown>
  808. record['version'] = 1
  809. return record
  810. },
  811. migrateEvents: events => (async function* (): AsyncIterable<unknown> {
  812. for await (const value of events) {
  813. const event = value as SessionEvent
  814. const data = event.data as Record<string, unknown>
  815. data['mutated'] = true
  816. yield event
  817. }
  818. })(),
  819. }
  820. const { decodeStoredSession } = await configuredDecoder(1, [step])
  821. const stored = storedSource(0, originalEvents)
  822. const decoded = decodeStoredSession(stored.source, id)
  823. const migrated = await collectEvents(decoded.events)
  824. await decoded.completed
  825. expect(migrated.every(event => (event.data as Record<string, unknown>)['mutated'] === true)).toBe(true)
  826. expect(stored.meta).toEqual({ version: 0, id, createdAt: 1 })
  827. expect(originalEvents).toEqual(eventSnapshot)
  828. })
  829. it('rejects duplicate, invalid, and future-targeting static registries at initialization', async () => {
  830. const calls: string[] = []
  831. await expect(configuredDecoder(1, [migration(0, calls), migration(0, calls)]))
  832. .rejects.toThrow(/duplicate Session format step/)
  833. await expect(configuredDecoder(1, [migration(-1, calls)]))
  834. .rejects.toThrow(/adjacent non-negative version/)
  835. const nonAdjacent: SessionFormatStep = {
  836. ...migration(0, calls),
  837. to: 2,
  838. }
  839. await expect(configuredDecoder(2, [nonAdjacent]))
  840. .rejects.toThrow(/adjacent non-negative version/)
  841. const fractional: SessionFormatStep = {
  842. ...migration(0, calls),
  843. from: 0.5,
  844. to: 1.5,
  845. }
  846. await expect(configuredDecoder(2, [fractional]))
  847. .rejects.toThrow(/adjacent non-negative version/)
  848. await expect(configuredDecoder(1, [migration(1, calls)]))
  849. .rejects.toThrow(/targets a version newer than this build/)
  850. })
  851. it('initializes with a gapped registry and refuses only sessions at or below the gap', async () => {
  852. const calls: string[] = []
  853. const { decodeStoredSession } = await configuredDecoder(
  854. 3,
  855. [migration(0, calls), migration(2, calls)],
  856. calls,
  857. )
  858. const current = storedSource(3, [])
  859. const pastGap = storedSource(2, eventLog())
  860. const atGap = storedSource(1, eventLog())
  861. const belowGap = storedSource(0, eventLog())
  862. expect(decodeStoredSession(current.source, id).meta.version).toBe(3)
  863. const decoded = decodeStoredSession(pastGap.source, id)
  864. expect(decoded.sourceVersion).toBe(2)
  865. expect(decoded.meta.version).toBe(3)
  866. expect(() => decodeStoredSession(atGap.source, id))
  867. .toThrow(/missing v1 -> v2/)
  868. expect(() => decodeStoredSession(belowGap.source, id))
  869. .toThrow(/missing v1 -> v2/)
  870. expect(pastGap.reads).toEqual([])
  871. })
  872. })