format-decoder.spec.ts 39 KB

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