migration.spec.ts 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. import { describe, expect, it } from 'vitest'
  2. import { createSessionFormatCatalog, SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
  3. import type { SessionFormatArtifact, SessionFormatEvent, SessionFormatHeader, SessionFormatJsonObject } from '@deepseek-ai/dsh-session-format'
  4. import { releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, sessionFormatV0ToV1 } from '@deepseek-ai/dsh-session-format-v0-to-v1'
  5. import { sessionFormatV1ToV2 } from '@deepseek-ai/dsh-session-format-v1-to-v2'
  6. import { assertReleasedV3Header, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec, restoreReleasedV3Artifact, sessionFormatV2ToV3 } from '../src/index.ts'
  7. function deepFreeze<T>(value: T): T {
  8. if (value !== null && typeof value === 'object') {
  9. for (const child of Object.values(value)) deepFreeze(child)
  10. Object.freeze(value)
  11. }
  12. return value
  13. }
  14. const header: SessionFormatHeader = { version: 2, id: 'identity', createdAt: 1, isSeeded: false, delegationDepth: 0 }
  15. const request = (system?: string) => ({ header: { config: { provider: 'mock', model: 'mock' }, ...(system === undefined ? {} : { system }) }, reason: 'initial' })
  16. const user = (id = 'user') => ({ role: 'user', id, source: { kind: 'user' }, content: [{ type: 'text', text: id }] })
  17. const event = (type: string, data: SessionFormatEvent['data'], surfaceOp?: SessionFormatEvent['surfaceOp']): SessionFormatEvent => ({ type, seq: 0, time: 42, data, ...(surfaceOp === undefined ? {} : { surfaceOp }) })
  18. const dense = (events: readonly SessionFormatEvent[]) => events.map((e, seq) => ({ ...e, seq }))
  19. const opening = () => [event('turn/start', { turn: 1 }), event('step/start', { turn: 1, step: 1 })]
  20. function stage(source = header, sourceCut?: number) {
  21. const target = sessionFormatV2ToV3.migrateHeader(source)
  22. return { target, value: sessionFormatV2ToV3.createStage({ sourceHeader: source, targetHeader: target, sourceInheritedEventCount: sourceCut, sourceKind: 'decoded' }), collector: new SessionFormatEventCollector() }
  23. }
  24. function migrate(events: readonly SessionFormatEvent[], source = header, cut: number | undefined = 0): SessionFormatArtifact {
  25. const h = stage(source, cut)
  26. for (const e of dense(events)) h.value.transformEvent(e, h.collector)
  27. const artifact = { header: h.target, inheritedEventCount: h.value.finish(h.collector), events: h.collector.values }
  28. return restoreReleasedV3Artifact(artifact, new Set(['feedback/message-put', 'feedback/message-delete']))
  29. }
  30. const catalog = createSessionFormatCatalog({
  31. currentVersion: 3,
  32. codecs: [releasedV0SessionFormatCodec, releasedV1SessionFormatCodec, releasedV2SessionFormatCodec, releasedV3SessionFormatCodec],
  33. currentEncoder: releasedV3SessionFormatCodec,
  34. migrations: [sessionFormatV0ToV1, sessionFormatV1ToV2, sessionFormatV2ToV3],
  35. restoreCurrent: artifact => restoreReleasedV3Artifact(artifact, new Set()),
  36. restoreTransformedCurrent: artifact => restoreReleasedV3Artifact(artifact, new Set()),
  37. restoreCurrentHeader(value) { assertReleasedV3Header(value); return value },
  38. })
  39. function requests(events: readonly SessionFormatEvent[], version: 2 | 3) {
  40. const surface: SessionFormatEvent[] = []
  41. let prompt = ''
  42. const result: unknown[] = []
  43. for (const e of events) {
  44. if (e['surfaceOp'] === 'append') surface.push(e)
  45. else if (e['surfaceOp'] !== undefined) {
  46. const op = e['surfaceOp'] as SessionFormatJsonObject
  47. const start = surface.findIndex(x => x.seq === op[version === 2 ? 'start' : 'startSeq'])
  48. const end = surface.findIndex(x => x.seq === op[version === 2 ? 'end' : 'endSeq'])
  49. surface.splice(start, end - start + 1, e)
  50. }
  51. if (e.type === 'request/header') {
  52. const h = (e.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject
  53. prompt = typeof h['system'] === 'string' ? h['system'] : ''
  54. const messages = surface.flatMap((x) => {
  55. const d = x.data as SessionFormatJsonObject
  56. const m = (x.type === 'user/message' ? d : d['message']) as SessionFormatJsonObject
  57. return x.type === 'system/message' && (m['content'] as unknown[]).length === 0 ? [] : [{ role: m['role'], content: m['content'] }]
  58. })
  59. if (version === 2 && prompt !== '') messages.unshift({ role: 'system', content: [{ type: 'text', text: prompt }] })
  60. result.push(messages)
  61. }
  62. }
  63. return result
  64. }
  65. describe('streaming V2 system prompt migration', () => {
  66. it('emits an empty head immediately after first step, preserves chronology, and captures changed and cleared prompts', () => {
  67. const input = dense([...opening(), event('user/message', user(), 'append'), event('request/header', request('first')), event('request/header', request('first')), event('request/header', request('changed')), event('request/header', request()), event('request/header', request(''))])
  68. const h = stage()
  69. h.value.transformEvent(input[0]!, h.collector)
  70. expect(h.collector.values).toHaveLength(1)
  71. h.value.transformEvent(input[1]!, h.collector)
  72. expect(h.collector.values.map(e => e.type)).toEqual(['turn/start', 'step/start', 'system/message'])
  73. for (const e of input.slice(2)) h.value.transformEvent(e, h.collector)
  74. expect(h.value.finish(h.collector)).toBe(0)
  75. const output = migrate(input)
  76. expect(output.events.filter(e => e.type === 'system/message')).toHaveLength(4)
  77. expect(requests(output.events, 3)).toEqual(requests(input, 2))
  78. expect(output.events.filter(e => e.type !== 'system/message').map(e => [e.type, e.time])).toEqual(input.map(e => [e.type, e.time]))
  79. expect(output.events.filter(e => e.type === 'request/header').every(e => !Object.hasOwn((e.data as SessionFormatJsonObject)['header'] as SessionFormatJsonObject, 'system'))).toBe(true)
  80. expect(migrate(input)).toEqual(output)
  81. expect(input[3]!.data).toEqual(request('first'))
  82. })
  83. it('remaps exact local ranges and lists without putting the head inside compaction', () => {
  84. const source = dense([...opening(), event('user/message', user('a'), 'append'), event('request/header', request('sys')), event('user/message', user('b'), 'append'), event('compaction/prune', { shadowedRange: { start: 2, end: 4 }, shadowedSeqs: [2, 4], shadowedTokenCount: 20 }), { ...event('user/message', user('replacement'), { op: 'replace', start: 2, end: 4 }), sourceEventSeqs: [2, 4] }, event('command/run', { commandId: 'c', name: 'x', source: { kind: 'user' } }), event('command/done', { commandId: 'c', kind: 'success', sourceEventSeq: 6 }), event('session/title', { title: 'title', messageSeqs: [2, 4], source: { kind: 'fallback' } })])
  85. const target = migrate(source)
  86. const mapped = target.events.filter(e => e.type !== 'system/message')
  87. const prune = mapped[5]!.data as SessionFormatJsonObject
  88. expect(prune['shadowedSeqs']).toEqual([mapped[2]!.seq, mapped[4]!.seq])
  89. expect(mapped[6]!['sourceEventSeqs']).toEqual(prune['shadowedSeqs'])
  90. expect((mapped[8]!.data as SessionFormatJsonObject)['sourceEventSeq']).toBe(mapped[6]!.seq)
  91. expect((mapped[9]!.data as SessionFormatJsonObject)['messageSeqs']).toEqual(prune['shadowedSeqs'])
  92. expect(() => restoreReleasedV3Artifact({ ...target, events: target.events.map(e => e.type === 'compaction/prune' ? { ...e, data: { ...e.data as SessionFormatJsonObject, shadowedRange: { start: 4, end: 4 }, shadowedSeqs: [4] } } : e) }, new Set())).toThrow(/protected/)
  93. })
  94. it('keeps headerless aborted steps and metadata-only logs without inventing a tail request', () => {
  95. const empty = migrate([])
  96. expect(empty.events).toEqual([])
  97. expect(migrate([...opening(), event('user/message', user(), 'append')]).events.at(-1)?.type).toBe('user/message')
  98. expect(migrate([event('feedback/record', { text: 'metadata' })]).events).toHaveLength(1)
  99. })
  100. it('detects deterministic head and update identity collisions with source messages in either order', () => {
  101. const input = [...opening(), event('user/message', user(), 'append'), event('request/header', request('system'))]
  102. const output = migrate(input)
  103. const ids = output.events.filter(e => e.type === 'system/message').map(e => ((e.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject)['id'] as string)
  104. for (const id of ids) {
  105. const colliding = [...opening(), event('user/message', user(id), 'append'), event('request/header', request('system'))]
  106. expect(() => migrate(colliding)).toThrow(/collides/)
  107. }
  108. const priorInbox = event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [user('placeholder')] })
  109. const shifted = migrate([priorInbox, ...input])
  110. const head = shifted.events.find(e => e.type === 'system/message')!
  111. const id = ((head.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject)['id'] as string
  112. expect(() => migrate([event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [user(id)] }), ...input])).toThrow(/collides/)
  113. })
  114. it('refuses pre-step surfaces and changed prompts outside a step rather than reorder source history', () => {
  115. expect(() => migrate([event('user/message', user(), 'append'), ...opening()])).toThrow(/before first step/)
  116. expect(() => migrate([event('turn/start', { turn: 1 }), event('request/header', request('early')), event('step/start', { turn: 1, step: 1 })])).toThrow(/outside an open step/)
  117. expect(() => migrate([...opening(), event('step/end', { turn: 1, step: 1 }), event('request/header', request('late'))])).toThrow(/outside an open step/)
  118. })
  119. it.each([undefined, 0, 1, 2])('preserves delivery generation coordinates (%s) and validates ownership before promotion', (version) => {
  120. const marker = event('session-log-deepseek/delivery-accepted', { sessionId: header.id, throughSeq: 1, ...(version === undefined ? {} : { sessionFormatVersion: version }) })
  121. const target = migrate([...opening(), marker])
  122. expect(target.events.at(-1)?.data).toEqual(marker.data)
  123. expect(() => migrate([...opening(), { ...marker, data: { ...marker.data as SessionFormatJsonObject, sessionId: 'foreign', sessionFormatVersion: 2 } }])).toThrow(/wrong Session/)
  124. })
  125. it('derives unknown seeded cuts after insertion and isolates simultaneous stages', () => {
  126. const source = { ...header, isSeeded: true, parentSession: 'parent' }
  127. const events = dense([...opening(), event('request/header', request('seed')), event('session-log-deepseek/delivery-accepted', { sessionId: 'parent', throughSeq: 1, sessionFormatVersion: 2 }), event('session/end-seed', { inherited: true })])
  128. const a = stage(source, undefined)
  129. const b = stage()
  130. expect(a.value.headerInheritedEventCount).toBeUndefined()
  131. expect(b.value.headerInheritedEventCount).toBe(0)
  132. for (const e of events) a.value.transformEvent(e, a.collector)
  133. expect(a.value.finish(a.collector)).toBe(6)
  134. expect(b.value.finish(b.collector)).toBe(0)
  135. expect(migrate(events, source, 4).inheritedEventCount).toBe(6)
  136. expect(() => migrate(events, source, 3)).toThrow(/source cut/)
  137. expect(() => migrate([], source, undefined)).toThrow(/inherited/)
  138. expect(() => migrate([event('session/end-seed', { inherited: true })])).toThrow(/inherited/)
  139. })
  140. it('preserves exact TOOL_NOT_STARTED message IDs through coordinate shifts', () => {
  141. const callId = 'call-with-dashes'
  142. const assistant = event('assistant/message', { turn: 1, step: 1, stream: [], message: { id: 'assistant', role: 'assistant', content: [{ type: 'tool-call', id: callId, name: 'test', arguments: '{}' }], source: { kind: 'model', provider: 'mock', model: 'mock' } } }, 'append')
  143. const repair = event('tool/result', { turn: 1, step: 1, error: { name: 'ToolNotStartedError', code: 'TOOL_NOT_STARTED' }, message: { id: 'interrupted-tool-result-' + callId + '-4', role: 'user', source: { kind: 'tool', callId }, content: [{ type: 'tool-result', toolCallId: callId, isError: true, content: [{ type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.' }] }] } }, 'append')
  144. const input = [...opening(), event('request/header', request('system')), assistant, repair, event('step/end', { turn: 1, step: 1 })]
  145. const target = migrate(input)
  146. expect(target.events.find(e => e.type === 'tool/result')?.data).toEqual(repair.data)
  147. expect(target.events.find(e => e.type === 'tool/result')?.seq).toBe(6)
  148. const data = repair.data as SessionFormatJsonObject
  149. const historical = { ...repair, data: { ...data, message: { ...data['message'] as SessionFormatJsonObject, id: 'interrupted-tool-result-' + callId + '-999' } } }
  150. const inherited = migrate([...input.slice(0, 4), historical, ...input.slice(5)])
  151. expect(inherited.events.find(e => e.type === 'tool/result')?.data).toEqual(historical.data)
  152. expect(restoreReleasedV3Artifact(inherited, new Set())).toBe(inherited)
  153. expect(() => migrate([...input.slice(0, 4), { ...repair, data: { ...data, message: { ...data['message'] as SessionFormatJsonObject, id: 'arbitrary-repair-id' } } }])).toThrow(/canonical historical/)
  154. })
  155. it('preserves other-session captures, workflow-local seq, and model input containing source numbers', () => {
  156. const reference = { kind: 'session-reference', form: 'recall', version: 1, references: [{ sessionId: 'other', label: 'other', capturedThroughSeq: 99, capturedFormatVersion: 2, compacted: false, originalMessages: 1, retainedMessages: 1, omittedMessages: 0, omittedBytes: 0, truncated: false, inputIndex: 0 }] }
  157. const input = [...opening(), event('user/message', { ...user(), source: reference }, 'append'), event('tool-workflow/agent-start', { runId: 'run', seq: 99, label: 'child', childId: 'other' }), event('user/message', user('human'), 'append'), event('session/title-llm-request', { titleProvider: 'mock', messageSeqs: [4], route: { provider: 'mock', model: 'mock' }, system: 'title system', messages: [{ ...user('title'), source: { kind: 'plugin', plugin: 'dsh-session-title-llm' }, content: [{ type: 'text', text: 'source seq=4 (preserved model input)' }] }], maxTokens: 20 })]
  158. const target = migrate(input)
  159. const kept = target.events.filter(e => e.type !== 'system/message')
  160. expect(kept[2]?.data).toEqual(input[2]?.data)
  161. expect(kept[3]?.data).toEqual(input[3]?.data)
  162. expect((kept[5]!.data as SessionFormatJsonObject)['messageSeqs']).toEqual([5])
  163. expect((kept[5]!.data as SessionFormatJsonObject)['messages']).toEqual((input[5]!.data as SessionFormatJsonObject)['messages'])
  164. })
  165. it('expands compact input incrementally through independent stage state', () => {
  166. const h = stage()
  167. const input = dense([...opening(), event('request/header', request('run'))])
  168. h.value.transformRun({ runType: 'test', firstSeq: 0, eventCount: 3, *expand() { yield* input } }, h.collector)
  169. expect(h.value.finish(h.collector)).toBe(0)
  170. expect(h.collector.values).toEqual(migrate(input).events)
  171. })
  172. it.each([0, 1])('derives the inherited cut after V%s assistant chunks collapse upstream', (version) => {
  173. const chunks = [
  174. event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hello' } }),
  175. event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'finish', reason: { kind: 'stop' } } }),
  176. ]
  177. const assistant = { ...event('assistant/message', { turn: 1, step: 1, message: { id: 'assistant', role: 'assistant', source: { kind: 'model', provider: 'mock', model: 'mock' }, content: [{ type: 'text', text: 'hello' }] } }, 'append'), sourceEventSeqs: [3, 4] }
  178. const source = dense([...opening(), event('request/header', request('seed')), ...chunks, assistant, event('step/end', { turn: 1, step: 1 }), event('turn/end', { turn: 1, reason: { kind: 'completed' } }), event('session/end-seed', {})])
  179. const restore = catalog.createRestore({ type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0, seedLength: 8 }, { recovery: 'strict', validation: 'current' })
  180. for (const e of source) restore.decodeRow(e)
  181. const output = restore.finish()
  182. expect(output.inheritedEventCount).toBe(8)
  183. expect(output.events.filter(e => e.type === 'assistant/chunk')).toHaveLength(0)
  184. expect(output.events.filter(e => e.type === 'system/message')).toHaveLength(2)
  185. expect(output.events.at(-1)?.seq).toBe(output.inheritedEventCount)
  186. })
  187. it.each([0, 1, 2])('restores seeded V%s through physical codecs and every adjacent stage', (version) => {
  188. const source = dense([...opening(), event('user/message', user(), 'append'), event('request/header', request('seed')), event('step/end', { turn: 1, step: 1 }), event('turn/end', { turn: 1, reason: { kind: 'completed' } }), event('session/end-seed', version === 2 ? { inherited: true } : {})])
  189. const physical = version === 2 ? { type: 'session', ...header, version, isSeeded: true } : { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0, seedLength: 6 }
  190. const restore = catalog.createRestore(physical, { recovery: 'strict', validation: 'current' })
  191. for (const e of source) restore.decodeRow(e)
  192. const output = restore.finish()
  193. expect(output.inheritedEventCount).toBe(8)
  194. expect(requests(output.events, 3)).toEqual(requests(source, 2))
  195. })
  196. it.each([event('external/opaque', { seq: 1 }), { ...event('external/opaque', {}), ignorable: true }, event('request/header', { ...request(), futureRef: 0 }), event('user/message', { ...user(), source: { kind: 'future', seq: 0 } }, 'append')])('rejects unaudited payloads %j', (bad) => {
  197. expect(() => migrate([...opening(), bad])).toThrow(/unclassified|unexpected/)
  198. })
  199. it('rejects invalid references, future generation claims, and unclassified message content', () => {
  200. expect(() => migrate([...opening(), { ...event('user/message', user(), 'append'), sourceEventSeqs: [99] }])).toThrow(/earlier/)
  201. expect(() => migrate([...opening(), event('system/message', {})])).toThrow(/unclassified/)
  202. expect(() => migrate([...opening(), event('session-log-deepseek/delivery-accepted', { sessionId: header.id, throughSeq: 1, sessionFormatVersion: 3 })])).toThrow(/format v3|between/)
  203. expect(() => migrate([...opening(), event('user/message', { ...user(), content: [{ type: 'future-block', seq: 1 }] }, 'append')])).toThrow(/unclassified message content/)
  204. })
  205. it('migrates audited agent relay sources and file blocks verbatim, refusing unclassified members', () => {
  206. const source = { kind: 'agent-message', form: 'relay', senderSessionId: 'other-session' }
  207. const attachment = { attachmentId: 'sha256:content', name: 'poem.txt', bytes: 16 }
  208. const message = { ...user('relay'), source, content: [{ type: 'file', attachment }] }
  209. const inbox = event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message] })
  210. const output = migrate([inbox, ...opening(), event('user/message', message, 'append')])
  211. expect(output.events[0]?.data).toEqual(inbox.data)
  212. expect(output.events.at(-1)?.data).toEqual(message)
  213. const badMessages = [
  214. { ...message, source: { ...source, sourceEventSeq: 0 } },
  215. { ...message, source: { ...source, form: 'notice' } },
  216. { ...message, content: [{ type: 'file', attachment: { ...attachment, seq: 0 } }] },
  217. { ...message, content: [{ type: 'file', attachment: { ...attachment, bytes: -1 } }] },
  218. ]
  219. for (const bad of badMessages) expect(() => migrate([...opening(), event('user/message', bad, 'append')])).toThrow()
  220. })
  221. it.each([0, 1, 2])('restores log-only failed attempts from V%s without invoking the V0 event inventory', (version) => {
  222. const stream = [{ type: 'finish', reason: { kind: 'error', error: { name: 'ProviderError', message: 'failed' } } }]
  223. const attempts = version === 2
  224. ? [event('assistant/attempt', { turn: 1, step: 1, stream })]
  225. : [event('assistant/chunk', { turn: 1, step: 1, chunk: stream[0]! })]
  226. const source = dense([...opening(), ...attempts])
  227. const physical = version === 2 ? { type: 'session', ...header } : { type: 'session', version, id: header.id, createdAt: 1, delegationDepth: 0 }
  228. const restore = catalog.createRestore(physical, { recovery: 'strict', validation: 'current' })
  229. for (const row of source) restore.decodeRow(row)
  230. expect(restore.finish().events.at(-1)?.type).toBe('assistant/attempt')
  231. })
  232. it('rejects malformed V2 attempts and unaudited attempt fields', () => {
  233. const invalid = [{ turn: 0, step: 1, stream: [] }, { turn: 1, step: 1, stream: null }, { turn: 1, step: 1, stream: [], seqRef: 0 }]
  234. for (const data of invalid) {
  235. expect(() => migrate([...opening(), event('assistant/attempt', data)])).toThrow()
  236. }
  237. })
  238. it('preserves message feedback identities and rejects unaudited feedback fields', () => {
  239. const feedback = event('feedback/message-put', { sessionId: 'other', item: { messageId: 'unchanged', rating: 'positive', version: 'opaque', createdAt: 1, updatedAt: 2 } })
  240. expect(migrate([...opening(), feedback]).events.at(-1)?.data).toEqual(feedback.data)
  241. const bad = { ...feedback, data: { ...feedback.data as SessionFormatJsonObject, seq: 2 } }
  242. expect(() => migrate([...opening(), bad])).toThrow(/unexpected/)
  243. })
  244. })
  245. describe('native V3 codec and restorer', () => {
  246. it('round-trips system messages and empty heads, returning no projected user-message substitutes', () => {
  247. const target = migrate([...opening(), event('request/header', request('system'))])
  248. const restore = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(target.header, 0), { recovery: 'strict', validation: 'current' })
  249. for (const e of target.events) restore.decodeRow(releasedV3SessionFormatCodec.encodeEvent(e))
  250. const output = restore.finish()
  251. expect(output).toEqual(target)
  252. expect(restoreReleasedV3Artifact(target, new Set())).toBe(target)
  253. expect(target.events.filter(e => e.type === 'user/message')).toHaveLength(0)
  254. })
  255. it('rejects retired header.system on encode, decode, and logical restore', () => {
  256. const bad = { ...event('request/header', request('retired')), seq: 2 }
  257. expect(() => releasedV3SessionFormatCodec.encodeEvent(bad)).toThrow(/header.system/)
  258. const decoder = releasedV3SessionFormatCodec.createDecoder({ type: 'session', ...header, version: 3 }, 'strict')
  259. const context = new SessionFormatEventCollector()
  260. for (const e of dense(opening())) decoder.decodeRow(e, context)
  261. expect(() =>{ decoder.decodeRow(bad, context) }).toThrow(/header.system/)
  262. const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: [...dense(opening()), bad] }
  263. expect(() => restoreReleasedV3Artifact(artifact, new Set())).toThrow(/header.system/)
  264. })
  265. it('rejects malformed native system payloads and foreign payload members', () => {
  266. const target = migrate(opening())
  267. const system = target.events[2]!
  268. const data = system.data as SessionFormatJsonObject
  269. const message = data['message'] as SessionFormatJsonObject
  270. const invalid = [
  271. { ...system, data: { ...data, extra: 0 } },
  272. { ...system, data: { ...data, message: { ...message, role: 'user' } } },
  273. { ...system, data: { ...data, message: { ...message, source: { kind: 'user' } } } },
  274. { ...system, data: { ...data, message: { ...message, content: [{ type: 'text', text: 12 }] } } },
  275. { ...system, surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
  276. ]
  277. for (const bad of invalid) {
  278. expect(() => restoreReleasedV3Artifact({ ...target, events: [...target.events.slice(0, 2), bad] }, new Set())).toThrow()
  279. }
  280. const decoder = releasedV3SessionFormatCodec.createDecoder({ type: 'session', ...header, version: 3 }, 'recoverable')
  281. const collector = new SessionFormatEventCollector()
  282. for (const e of dense(opening())) decoder.decodeRow(e, collector)
  283. decoder.decodeRow(null, collector)
  284. expect(() =>{ decoder.decodeRow({ ...event('request/header', request('retired')), seq: 2 }, collector) }).toThrow(/header.system/)
  285. })
  286. it('rejects system nodes outside the open step and mixed head replacements', () => {
  287. const target = migrate([...opening(), event('user/message', user(), 'append'), event('request/header', request('system'))])
  288. const systems = target.events.filter(e => e.type === 'system/message')
  289. const wrongStep = target.events.map(e => e === systems[0] ? { ...e, data: { ...e.data as SessionFormatJsonObject, step: 2 } } : e)
  290. expect(() => restoreReleasedV3Artifact({ ...target, events: wrongStep }, new Set())).toThrow(/open step/)
  291. expect(() => restoreReleasedV3Artifact({ ...target, events: target.events.map(e => e === systems[1] ? { ...e, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 3 }, sourceEventSeqs: [2, 3] } : e) }, new Set())).toThrow(/exactly/)
  292. })
  293. it('round-trips in-history system append, replacement, and compaction without shadowing the head', () => {
  294. const base = migrate([...opening(), event('user/message', user(), 'append')])
  295. const system = base.events.find(e => e.type === 'system/message')!
  296. const message = (system.data as SessionFormatJsonObject)['message'] as SessionFormatJsonObject
  297. const append = { ...system, seq: 4, data: { ...system.data as SessionFormatJsonObject, message: { ...message, id: 'tail', source: { kind: 'plugin', plugin: 'context-plugin' }, content: [{ type: 'text', text: 'tail context' }, { type: 'reasoning', text: 'retained content' }] } } }
  298. const replace = { ...system, seq: 5, sourceEventSeqs: [4], surfaceOp: { op: 'replace', startSeq: 4, endSeq: 4 } }
  299. const prune = { ...event('compaction/prune', { shadowedRange: { start: 5, end: 5 }, shadowedSeqs: [5], shadowedTokenCount: 0 }), seq: 6 }
  300. const checkpoint = { ...event('user/message', user('checkpoint'), { op: 'replace', startSeq: 5, endSeq: 5 }), sourceEventSeqs: [5], seq: 7 }
  301. const artifact = { ...base, events: [...base.events, append, replace, prune, checkpoint] }
  302. expect(restoreReleasedV3Artifact(artifact, new Set())).toBe(artifact)
  303. const restore = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(base.header, 0), { recovery: 'strict', validation: 'current' })
  304. for (const e of artifact.events) restore.decodeRow(releasedV3SessionFormatCodec.encodeEvent(e))
  305. expect(restore.finish()).toEqual(artifact)
  306. const protectedPrune = { ...prune, data: { shadowedRange: { start: 2, end: 2 }, shadowedSeqs: [2], shadowedTokenCount: 0 } }
  307. const invalid = { ...base, events: [...base.events, append, replace, protectedPrune] }
  308. expect(() => restoreReleasedV3Artifact(invalid, new Set())).toThrow(/protected/)
  309. })
  310. it('keeps native ordinary payload and message-source extensions distinct from structural migration admission', () => {
  311. const target = migrate([...opening(), event('user/message', user(), 'append')])
  312. const events = target.events.map(e => e.type === 'user/message' ? { ...e, data: { ...e.data as SessionFormatJsonObject, installedExtension: true, source: { kind: 'installed-source', revision: 1 } } } : e)
  313. const native = { ...target, events }
  314. expect(restoreReleasedV3Artifact(native, new Set())).toBe(native)
  315. expect(() => migrate([...opening(), event('user/message', { ...user(), installedExtension: true }, 'append')])).toThrow(/unexpected/)
  316. })
  317. it('round-trips native header extensions while refusing them as unclassified V2 input', () => {
  318. const data = { ...request(), header: { ...request().header, messagePrefix: ['current extension'] } }
  319. const target = migrate(opening())
  320. const extended = { ...event('request/header', data), seq: target.events.length }
  321. const artifact = { ...target, events: [...target.events, extended] }
  322. const restore = catalog.createRestore(releasedV3SessionFormatCodec.encodeHeader(target.header, 0), { recovery: 'strict', validation: 'current' })
  323. for (const row of artifact.events) restore.decodeRow(releasedV3SessionFormatCodec.encodeEvent(row))
  324. expect(restore.finish()).toEqual(artifact)
  325. expect(() => migrate([...opening(), event('request/header', data)])).toThrow(/unexpected/)
  326. expect(() => releasedV3SessionFormatCodec.encodeEvent({ ...extended, data: { ...data, header: { ...data.header, system: 'retired' } } })).toThrow(/header.system/)
  327. })
  328. it('retains equal-generation ignorable events but rejects unknown required events', () => {
  329. const artifact = { header: { ...header, version: 3 }, inheritedEventCount: 0, events: [{ ...event('external/event', null), ignorable: true }] }
  330. expect(restoreReleasedV3Artifact(artifact, new Set())).toBe(artifact)
  331. expect(() => restoreReleasedV3Artifact({ ...artifact, events: [event('external/event', null)] }, new Set())).toThrow(/unknown event/)
  332. })
  333. it.each([null, [], false, { version: 2 }])('rejects non-v3 physical metadata %j', (value) => {
  334. expect(() => releasedV3SessionFormatCodec.decodeHeader(value)).toThrow(/format v3 physical/)
  335. })
  336. })
  337. describe('composed V3 system and PTC migration', () => {
  338. const message = (plugin: string, id = plugin) => ({ ...user(id), source: { kind: 'plugin', plugin } })
  339. const dispatch = { rootCallId: 'tools-code-mode:root', parentCallId: 'tools-code-mode:root', subCallId: 'tools-code-mode:child', name: 'read', arguments: { text: 'tools-code-mode', type: 'tool/code-dispatch' } }
  340. it('renames PTC after system insertion and remaps local references without touching tool JSON or identities', () => {
  341. const input = dense([
  342. ...opening(), event('user/message', message('tools-code-mode', 'tools-code-mode:message'), 'append'),
  343. event('request/header', request('prompt')), event('tool/code-dispatch-start', dispatch),
  344. event('tool/code-dispatch', { ...dispatch, isError: false, content: [{ type: 'text', text: 'tool/code-dispatch tools-code-mode' }] }),
  345. event('command/run', { commandId: 'cmd', name: 'test', source: { kind: 'user' } }),
  346. event('command/done', { commandId: 'cmd', kind: 'success', sourceEventSeq: 5 }),
  347. { ...event('user/message', message('tools-ptc', 'tools-ptc:message'), { op: 'replace', start: 2, end: 2 }), sourceEventSeqs: [2, 5] },
  348. event('request/header', request()),
  349. ])
  350. deepFreeze(input)
  351. const before = JSON.stringify(input)
  352. const output = migrate(input)
  353. expect(output.events.find(e => e.type === 'tool/ptc-dispatch-start')?.data).toEqual(dispatch)
  354. const settle = output.events.find(e => e.type === 'tool/ptc-dispatch')!
  355. expect(settle.data).toEqual(input[5]?.data)
  356. expect(output.events.find(e => e.type === 'command/done')?.data).toMatchObject({ sourceEventSeq: settle.seq })
  357. const users = output.events.filter(e => e.type === 'user/message')
  358. expect(users[0]?.data).toEqual(message('tools-ptc', 'tools-code-mode:message'))
  359. expect(users[1]?.data).toEqual(message('tools-ptc', 'tools-ptc:message'))
  360. expect(users[1]?.['sourceEventSeqs']).toEqual([users[0]?.seq, settle.seq])
  361. expect(requests(output.events, 3)).toEqual(requests(input, 2))
  362. expect(JSON.stringify(input)).toBe(before)
  363. })
  364. it('renames exact attribution slots in inbox and title messages but not similar plugin labels or text', () => {
  365. const old = message('tools-code-mode')
  366. const untouched = message('tools-code-mode-extra')
  367. const input = [
  368. event('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [old, untouched] }),
  369. ...opening(), event('user/message', user('human'), 'append'),
  370. event('session/title-llm-request', { titleProvider: 'mock', messageSeqs: [3], route: { provider: 'mock', model: 'mock' }, system: 'tools-code-mode', messages: [old, untouched], maxTokens: 10 }),
  371. ]
  372. const h = stage()
  373. for (const e of dense(input)) h.value.transformEvent(e, h.collector)
  374. expect((h.collector.values[0]!.data as SessionFormatJsonObject)['inserted']).toEqual([message('tools-ptc', 'tools-code-mode'), untouched])
  375. const title = h.collector.values.at(-1)!.data as SessionFormatJsonObject
  376. expect(title['messages']).toEqual([message('tools-ptc', 'tools-code-mode'), untouched])
  377. expect(title['system']).toBe('tools-code-mode')
  378. })
  379. it.each(['decoded', 'transformed'] as const)('refuses both required and ignorable reserved PTC tags in %s sources', (sourceKind) => {
  380. for (const type of ['tool/ptc-dispatch-start', 'tool/ptc-dispatch']) {
  381. for (const ignorable of [false, true]) {
  382. const value = sessionFormatV2ToV3.createStage({
  383. sourceHeader: header, targetHeader: { ...header, version: 3 }, sourceInheritedEventCount: 0, sourceKind,
  384. })
  385. expect(() => { value.transformEvent(event(type, null, undefined), new SessionFormatEventCollector()) }).toThrow(/unclassified/)
  386. expect(() => migrate([{ ...event(type, null), ...(ignorable ? { ignorable: true } : {}) }])).toThrow(/unclassified/)
  387. }
  388. }
  389. })
  390. it.each(['tool/code-dispatch-start', 'tool/code-dispatch'])('refuses retired required %s after recoverable corruption', (type) => {
  391. const decoder = releasedV3SessionFormatCodec.createDecoder({ type: 'session', ...header, version: 3 }, 'recoverable')
  392. const collector = new SessionFormatEventCollector()
  393. decoder.decodeRow(null, collector)
  394. expect(() => { decoder.decodeRow({ type, seq: 0, time: 1, data: null }, collector) }).toThrow(/unknown event type/)
  395. expect(() => releasedV3SessionFormatCodec.encodeEvent(event(type, null))).toThrow(/unknown event type/)
  396. })
  397. })
  398. describe('v3 PTC event admission and relationships', () => {
  399. const turn = { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }
  400. const dispatch = {
  401. rootCallId: 'tools-code-mode:root', parentCallId: 'tools-code-mode:root', subCallId: 'tools-code-mode:child',
  402. name: 'read', arguments: { path: 'tools-code-mode', nested: [1, { text: 'tool/code-dispatch' }] },
  403. }
  404. const start = { type: 'tool/ptc-dispatch-start', seq: 1, time: 2, data: dispatch }
  405. const settle = {
  406. type: 'tool/ptc-dispatch', seq: 2, time: 3,
  407. data: { ...dispatch, isError: false, content: [{ type: 'text', text: 'tools-code-mode' }] },
  408. }
  409. const artifact = (events: SessionFormatEvent[]) => deepFreeze({
  410. header: { ...header, version: 3 }, inheritedEventCount: 0, events,
  411. })
  412. it('validates a frozen complete PTC lifecycle and returns original names, IDs, and references', () => {
  413. const source = artifact([turn, start, settle, { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }])
  414. const before = JSON.stringify(source)
  415. const restored = restoreReleasedV3Artifact(source, new Set(['tool/ptc-dispatch-start', 'tool/ptc-dispatch']))
  416. expect(restored).toBe(source)
  417. expect(restored.events).toBe(source.events)
  418. for (const [index, event] of source.events.entries()) expect(restored.events[index]).toBe(event)
  419. expect(JSON.stringify(source)).toBe(before)
  420. })
  421. it('accepts an unfinished PTC start without requiring a fabricated settlement', () => {
  422. const source = artifact([turn, start])
  423. expect(restoreReleasedV3Artifact(source, new Set())).toBe(source)
  424. })
  425. it('rejects an orphan PTC settlement', () => {
  426. expect(() => restoreReleasedV3Artifact(artifact([turn, { ...settle, seq: 1 }]), new Set())).toThrow(/no unique start/)
  427. })
  428. it.each([
  429. { name: 'other' }, { arguments: { path: 'different' } }, { subCallId: 'other-child' },
  430. { parentCallId: 'missing-parent' }, { rootCallId: 'different-root' },
  431. ])('rejects a PTC settlement whose identity or input disagrees with its start: %j', (override) => {
  432. expect(() => restoreReleasedV3Artifact(artifact([turn, start, { ...settle, data: { ...settle.data, ...override } }]), new Set()))
  433. .toThrow(/does not match|no unique start|parentCallId|rootCallId/)
  434. })
  435. it.each([start, settle])('rejects $type outside an open turn', (event) => {
  436. expect(() => restoreReleasedV3Artifact(artifact([{ ...event, seq: 0 }]), new Set())).toThrow(/outside an open turn/)
  437. })
  438. it('rejects duplicate PTC starts and settlements', () => {
  439. expect(() => restoreReleasedV3Artifact(artifact([turn, start, { ...start, seq: 2 }]), new Set())).toThrow(/repeats subCallId/)
  440. expect(() => restoreReleasedV3Artifact(artifact([turn, start, settle, { ...settle, seq: 3 }]), new Set())).toThrow(/no unique start/)
  441. })
  442. it.each(['tool/code-dispatch-start', 'tool/code-dispatch'])('rejects required obsolete %s even when installed', (type) => {
  443. expect(() => restoreReleasedV3Artifact(artifact([{ type, seq: 0, time: 1, data: null }]), new Set([type])))
  444. .toThrow(/format v3 contains unknown event type/)
  445. })
  446. it.each(['tool/code-dispatch-start', 'tool/code-dispatch', 'external/future'])('preserves ignorable %s as opaque data outside a turn', (type) => {
  447. const source = artifact([{
  448. type, seq: 0, time: -5, ignorable: true,
  449. data: { source: { kind: 'plugin', plugin: 'tools-code-mode' }, invalidLifecycle: true, content: ['tool/code-dispatch'] },
  450. }])
  451. expect(restoreReleasedV3Artifact(source, new Set())).toBe(source)
  452. expect(source.events[0]?.type).toBe(type)
  453. })
  454. it('does not let an ignorable obsolete start satisfy a current PTC settlement', () => {
  455. const obsolete = { ...start, type: 'tool/code-dispatch-start', ignorable: true }
  456. expect(() => restoreReleasedV3Artifact(artifact([turn, obsolete, settle]), new Set())).toThrow(/no unique start/)
  457. })
  458. })