catalog.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionFormatEvent } from '@deepseek-ai/dsh-session-format'
  3. import { sessionFormatCatalog } from '../src/index.ts'
  4. function deepFreeze<T>(value: T): T {
  5. if (value !== null && typeof value === 'object') {
  6. for (const child of Object.values(value)) deepFreeze(child)
  7. Object.freeze(value)
  8. }
  9. return value
  10. }
  11. describe('first-party Session format catalog', () => {
  12. it('statically owns the complete adjacent v0 to v3 chain', () => {
  13. const header = {
  14. type: 'session',
  15. version: 0,
  16. id: 'catalog',
  17. createdAt: 1,
  18. seedLength: 0,
  19. delegationDepth: 0,
  20. }
  21. expect(sessionFormatCatalog.currentVersion).toBe(3)
  22. expect(sessionFormatCatalog.readHeader(header)).toEqual({
  23. status: 'migration-required',
  24. storedVersion: 0,
  25. targetVersion: 3,
  26. header: {
  27. version: 3,
  28. id: 'catalog',
  29. createdAt: 1,
  30. isSeeded: true,
  31. delegationDepth: 0,
  32. },
  33. })
  34. const v1Header = { ...header, version: 1 }
  35. const restore = sessionFormatCatalog.createRestore(v1Header, {
  36. recovery: 'strict', validation: 'current',
  37. })
  38. restore.decodeRow({ type: 'turn/start', seq: 0, time: 2, data: { turn: 1 } })
  39. expect(restore.finish()).toMatchObject({
  40. header: { version: 3, id: 'catalog' },
  41. })
  42. })
  43. it('restores the installed current vocabulary without freezing ordinary payload additions', () => {
  44. const header = {
  45. type: 'session', version: 3, id: 'current-growth', createdAt: 1, isSeeded: false, delegationDepth: 0,
  46. }
  47. const restore = (rows: readonly unknown[]) => {
  48. const current = sessionFormatCatalog.createRestore(header, {
  49. recovery: 'strict', validation: 'current',
  50. })
  51. for (const row of rows) current.decodeRow(row)
  52. return current.finish()
  53. }
  54. const extended = restore([{
  55. type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
  56. }])
  57. expect(extended.events).toEqual([{
  58. type: 'turn/start', seq: 0, time: 1, data: { turn: 1, postReleaseMember: true },
  59. }])
  60. expect(() => restore([{
  61. type: 'ordinary/not-installed', seq: 0, time: 1, data: 'future',
  62. }])).toThrow(/unknown event type/)
  63. const extension = restore([{
  64. type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,
  65. }])
  66. expect(extension.events).toEqual([{
  67. type: 'ordinary/external', seq: 0, time: 1, data: null, ignorable: true,
  68. }])
  69. })
  70. it.each([0, 1])('restores v%i empty and non-empty inherited prefixes through every adjacent edge', (version) => {
  71. for (const seedLength of [0, 1]) {
  72. const sourceHeader = {
  73. type: 'session', version, id: 'seed-chain', createdAt: 1,
  74. parentSession: 'parent', seedLength, delegationDepth: 0,
  75. }
  76. const restore = sessionFormatCatalog.createRestore(sourceHeader, { recovery: 'strict', validation: 'current' })
  77. if (seedLength > 0) {
  78. restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'inherited' } })
  79. }
  80. const artifact = restore.finish()
  81. expect(artifact.header.version).toBe(3)
  82. expect(artifact.inheritedEventCount).toBe(seedLength)
  83. expect(artifact.events.at(-1)).toEqual({
  84. type: 'session/end-seed', seq: seedLength, time: 1, data: { inherited: true },
  85. })
  86. expect(sourceHeader.version).toBe(version)
  87. }
  88. })
  89. it.each([false, true])('inserts the v3 system head and remaps the inherited cut (seeded=%s)', (isSeeded) => {
  90. const header = { type: 'session', version: 2, id: 'v2-identity', createdAt: 1, isSeeded, delegationDepth: 0 }
  91. const rows = [
  92. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  93. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  94. { type: 'feedback/record', seq: 2, time: 3, data: { text: 'unchanged' } },
  95. ...(isSeeded ? [{ type: 'session/end-seed', seq: 3, time: 4, data: { inherited: true } }] : []),
  96. ]
  97. const before = JSON.stringify({ header, rows })
  98. const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation: 'current' })
  99. for (const row of rows) restore.decodeRow(row)
  100. expect(restore.finish()).toEqual({
  101. header: { version: 3, id: 'v2-identity', createdAt: 1, isSeeded, delegationDepth: 0 },
  102. inheritedEventCount: isSeeded ? 4 : 0,
  103. events: [
  104. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  105. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
  106. {
  107. type: 'system/message', seq: 2, time: 2, surfaceOp: 'append',
  108. data: {
  109. turn: 1, step: 1,
  110. message: {
  111. id: 'v2-to-v3-system-9673c4ed630de6c21ea6bd6b573094ea8e5e216843a1b572a68657499ad9667b',
  112. role: 'system', source: { kind: 'plugin', plugin: '@deepseek-ai/dsh-system-prompt' }, content: [],
  113. },
  114. },
  115. },
  116. { type: 'feedback/record', seq: 3, time: 3, data: { text: 'unchanged' } },
  117. ...(isSeeded ? [{ type: 'session/end-seed', seq: 4, time: 4, data: { inherited: true } }] : []),
  118. ],
  119. })
  120. expect(JSON.stringify({ header, rows })).toBe(before)
  121. })
  122. it.each(['current', 'transformed'] as const)('refuses unclassified ignorable v2 events (%s)', (validation) => {
  123. const header = { type: 'session', version: 2, id: 'v2-unknown', createdAt: 1, isSeeded: false, delegationDepth: 0 }
  124. const row = { type: 'external/event', seq: 0, time: 1, data: { extra: ['unchanged'] }, ignorable: true }
  125. const before = JSON.stringify({ header, row })
  126. const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
  127. expect(() => { restore.decodeRow(row) }).toThrow(/cannot safely transform unclassified event external\/event/)
  128. expect(JSON.stringify({ header, row })).toBe(before)
  129. })
  130. it.each([0, 1, 2])('migrates frozen v%i PTC records and reopens the actual current representation without rewriting IDs', (version) => {
  131. const sourceHeader = deepFreeze({
  132. type: 'session', version, id: 'tools-code-mode:session', createdAt: 1, delegationDepth: 0,
  133. ...(version === 2 ? { isSeeded: false } : {}),
  134. })
  135. const message = (id: string) => ({
  136. id, role: 'user', source: { kind: 'plugin', plugin: 'tools-code-mode' },
  137. content: [{ type: 'text', text: 'tool/code-dispatch-start tools-code-mode tools-ptc' }],
  138. })
  139. const oldId = 'tools-code-mode:message'
  140. const newId = 'tools-ptc:message'
  141. const rootId = 'tools-code-mode:root'
  142. const childId = 'tools-code-mode:child'
  143. const dispatch = {
  144. rootCallId: rootId, parentCallId: rootId, subCallId: childId, name: 'read',
  145. arguments: { text: 'tools-code-mode', nested: { type: 'tool/code-dispatch' } },
  146. }
  147. const rows: SessionFormatEvent[] = deepFreeze([
  148. { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } },
  149. { type: 'step/start', seq: 1, time: 10, data: { turn: 1, step: 1 } },
  150. { type: 'user/message', seq: 2, time: 11, data: message(oldId), surfaceOp: 'append' },
  151. { type: 'user/message', seq: 3, time: 12, data: message(newId), surfaceOp: 'append' },
  152. { type: 'agent/inbox/spliced', seq: 4, time: 13, data: { target: 'next-turn', start: 0, inserted: [message(oldId), message(newId)] } },
  153. { type: 'tool/code-dispatch-start', seq: 5, time: 14, data: dispatch },
  154. { type: 'tool/code-dispatch', seq: 6, time: 15, data: { ...dispatch, isError: false, content: [{ type: 'text', text: childId }] } },
  155. { type: 'user/message', seq: 7, time: 16, data: message('tools-code-mode:replacement'), sourceEventSeqs: [2, 3], surfaceOp: { op: 'replace', start: 2, end: 3 } },
  156. { type: 'step/end', seq: 8, time: 17, data: { turn: 1, step: 1 } },
  157. { type: 'turn/end', seq: 9, time: 17, data: { turn: 1, reason: { kind: 'completed' } } },
  158. ])
  159. const before = JSON.stringify({ sourceHeader, rows })
  160. const restore = sessionFormatCatalog.createRestore(sourceHeader, { recovery: 'strict', validation: 'current' })
  161. for (const row of rows) restore.decodeRow(row)
  162. const artifact = restore.finish()
  163. const renamedMessage = (id: string) => ({ ...message(id), source: { kind: 'plugin', plugin: 'tools-ptc' } })
  164. const expected = [
  165. rows[0], rows[1],
  166. expect.objectContaining({ type: 'system/message', seq: 2 }),
  167. { ...rows[2], seq: 3, data: renamedMessage(oldId) },
  168. { ...rows[3], seq: 4, data: renamedMessage(newId) },
  169. { ...rows[4], seq: 5, data: { target: 'next-turn', start: 0, inserted: [renamedMessage(oldId), renamedMessage(newId)] } },
  170. { ...rows[5], seq: 6, type: 'tool/ptc-dispatch-start' },
  171. { ...rows[6], seq: 7, type: 'tool/ptc-dispatch' },
  172. { ...rows[7], seq: 8, sourceEventSeqs: [3, 4], surfaceOp: { op: 'replace', startSeq: 3, endSeq: 4 }, data: renamedMessage('tools-code-mode:replacement') },
  173. { ...rows[8], seq: 9 }, { ...rows[9], seq: 10 },
  174. ]
  175. expect(artifact).toEqual({
  176. header: { version: 3, id: sourceHeader.id, createdAt: 1, isSeeded: false, delegationDepth: 0 },
  177. inheritedEventCount: 0, events: expected,
  178. })
  179. const currentHeader = deepFreeze(sessionFormatCatalog.encodeCurrentHeader(artifact.header, artifact.inheritedEventCount))
  180. const currentRows = deepFreeze(artifact.events.map(event => sessionFormatCatalog.encodeCurrentEvent(event)))
  181. const encodedBefore = JSON.stringify({ currentHeader, currentRows })
  182. const reopened = sessionFormatCatalog.createRestore(currentHeader, { recovery: 'strict', validation: 'current' })
  183. for (const row of currentRows) reopened.decodeRow(row)
  184. expect(reopened.finish()).toEqual(artifact)
  185. expect(JSON.stringify({ sourceHeader, rows })).toBe(before)
  186. expect(JSON.stringify({ currentHeader, currentRows })).toBe(encodedBefore)
  187. })
  188. it.each(['current', 'transformed'] as const)('rejects native v3 obsolete required tags with %s validation and retains ignorable tags', (validation) => {
  189. const header = deepFreeze({ type: 'session', version: 3, id: 'native-ptc', createdAt: 1, isSeeded: false, delegationDepth: 0 })
  190. for (const type of ['tool/code-dispatch-start', 'tool/code-dispatch']) {
  191. const required = deepFreeze({ type, seq: 0, time: 1, data: null })
  192. const rejected = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
  193. expect(() => { rejected.decodeRow(required) }).toThrow(/unknown event type/)
  194. const ignorable = deepFreeze({ ...required, ignorable: true, data: { text: 'tools-code-mode', source: { kind: 'plugin', plugin: 'tools-code-mode' } } })
  195. const accepted = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
  196. accepted.decodeRow(ignorable)
  197. expect(accepted.finish().events).toEqual([ignorable])
  198. }
  199. })
  200. it.each(['current', 'transformed'] as const)('refuses V3 dispatch tag collisions in V2 input with %s validation', (validation) => {
  201. const header = deepFreeze({ type: 'session', version: 2, id: 'v2-collision', createdAt: 1, isSeeded: false, delegationDepth: 0 })
  202. for (const type of ['tool/ptc-dispatch-start', 'tool/ptc-dispatch']) {
  203. for (const ignorable of [false, true]) {
  204. const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
  205. const row = deepFreeze({ type, seq: 0, time: 1, data: null, ...(ignorable ? { ignorable: true } : {}) })
  206. expect(() => { restore.decodeRow(row) }).toThrow(/format v2.*ptc-dispatch/)
  207. }
  208. }
  209. })
  210. it.each(['current', 'transformed'] as const)('renames V2-owned ignorable dispatch tags with %s validation', (validation) => {
  211. const header = deepFreeze({ type: 'session', version: 2, id: 'v2-owned', createdAt: 1, isSeeded: false, delegationDepth: 0 })
  212. const data = { rootCallId: 'root', parentCallId: 'root', subCallId: 'child', name: 'read', arguments: {} }
  213. const rows = deepFreeze([
  214. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  215. { type: 'tool/code-dispatch-start', seq: 1, time: 2, data, ignorable: true },
  216. { type: 'tool/code-dispatch', seq: 2, time: 3, data: { ...data, isError: false, content: [] }, ignorable: true },
  217. ])
  218. const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
  219. for (const row of rows) restore.decodeRow(row)
  220. expect(restore.finish().events).toEqual([
  221. rows[0], { ...rows[1], type: 'tool/ptc-dispatch-start' }, { ...rows[2], type: 'tool/ptc-dispatch' },
  222. ])
  223. })
  224. it.each(['current', 'transformed'] as const)('refuses a v3 delivery marker in v2 input (%s)', (validation) => {
  225. const header = { type: 'session', version: 2, id: 'future-delivery', createdAt: 1, isSeeded: false, delegationDepth: 0 }
  226. const restore = sessionFormatCatalog.createRestore(header, { recovery: 'strict', validation })
  227. restore.decodeRow({ type: 'feedback/record', seq: 0, time: 1, data: { text: 'unaccepted' } })
  228. expect(() => {
  229. restore.decodeRow({ type: 'session-log-deepseek/delivery-accepted', seq: 1, time: 2,
  230. data: { sessionId: header.id, throughSeq: 0, sessionFormatVersion: 3 } })
  231. restore.finish()
  232. }).toThrow(/format v2 delivery marker claims target format v3/)
  233. })
  234. it('validates complete relationships after streaming migration', () => {
  235. const stream = sessionFormatCatalog.createRestore({
  236. type: 'session', version: 1, id: 'invalid-stream', createdAt: 1, delegationDepth: 0,
  237. }, { recovery: 'strict', validation: 'current' })
  238. stream.decodeRow({ type: 'step/start', seq: 0, time: 2, data: { turn: 1, step: 1 } })
  239. expect(() => stream.finish()).toThrow(/open turn/)
  240. })
  241. })