codec.spec.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { describe, expect, it } from 'vitest'
  2. import { SessionFormatEventCollector } from '@deepseek-ai/dsh-session-format'
  3. import type {
  4. SessionFormatArtifact,
  5. SessionFormatArtifactDecoder,
  6. SessionFormatEvent,
  7. SessionFormatJsonObject,
  8. SessionFormatRecovery,
  9. } from '@deepseek-ai/dsh-session-format'
  10. import { decodeSeqRanges as decodeCurrentSeqRanges } from '@deepseek-ai/dsh-session'
  11. import { releasedV2SessionFormatCodec } from '@deepseek-ai/dsh-session-format-v1-to-v2'
  12. const minimalPhysicalHeader = {
  13. type: 'session', version: 2, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0,
  14. } as const
  15. const fullPhysicalHeader = {
  16. ...minimalPhysicalHeader,
  17. cwd: '/work',
  18. parentSession: 'parent',
  19. origin: 'subagent',
  20. agentPreset: 'default',
  21. } as const
  22. const textBlock = { type: 'text', text: 'text' } as const
  23. function throwUnknown(value: unknown): never {
  24. throw value
  25. }
  26. function feedback(seq: number): SessionFormatEvent {
  27. return { type: 'feedback/record', seq, time: seq + 1, data: { text: `feedback-${seq}` } }
  28. }
  29. function userMessage(seq: number, sourceEventSeqs?: readonly number[]): SessionFormatEvent {
  30. return {
  31. type: 'user/message',
  32. seq,
  33. time: seq + 1,
  34. data: {
  35. id: `user-${seq}`, role: 'user', content: [textBlock], source: { kind: 'user' },
  36. },
  37. surfaceOp: 'append',
  38. ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }),
  39. }
  40. }
  41. function artifact(
  42. events: readonly SessionFormatEvent[],
  43. overrides: Partial<SessionFormatArtifact> = {},
  44. ): SessionFormatArtifact {
  45. return {
  46. header: { version: 2, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0 },
  47. inheritedEventCount: 0,
  48. events,
  49. ...overrides,
  50. }
  51. }
  52. function decodeV2(
  53. header: unknown,
  54. rows: readonly unknown[],
  55. recovery: SessionFormatRecovery = 'strict',
  56. ): SessionFormatArtifact {
  57. const decoder: SessionFormatArtifactDecoder = releasedV2SessionFormatCodec.createDecoder(header, recovery)
  58. const context = new SessionFormatEventCollector()
  59. for (const row of rows) decoder.decodeRow(row, context)
  60. return { header: decoder.header, inheritedEventCount: decoder.finish(context), events: context.values }
  61. }
  62. function encodeV2(source: SessionFormatArtifact): {
  63. readonly header: SessionFormatJsonObject
  64. readonly rows: readonly SessionFormatJsonObject[]
  65. } {
  66. return {
  67. header: releasedV2SessionFormatCodec.encodeHeader(source.header, source.inheritedEventCount),
  68. rows: source.events.map(event => releasedV2SessionFormatCodec.encodeEvent(event)),
  69. }
  70. }
  71. describe('releasedV2SessionFormatCodec headers', () => {
  72. it('round-trips the minimal and complete optional header images', () => {
  73. expect(releasedV2SessionFormatCodec.version).toBe(2)
  74. expect(releasedV2SessionFormatCodec.decodeHeader(minimalPhysicalHeader)).toStrictEqual({
  75. version: 2, id: 'codec', createdAt: 1, isSeeded: false, delegationDepth: 0,
  76. })
  77. expect(releasedV2SessionFormatCodec.decodeHeader(fullPhysicalHeader)).toStrictEqual({
  78. version: 2,
  79. id: 'codec',
  80. createdAt: 1,
  81. cwd: '/work',
  82. parentSession: 'parent',
  83. isSeeded: false,
  84. origin: 'subagent',
  85. delegationDepth: 0,
  86. agentPreset: 'default',
  87. })
  88. const encodedMinimal = encodeV2(artifact([]))
  89. expect(encodedMinimal).toStrictEqual({ header: minimalPhysicalHeader, rows: [] })
  90. const complete = artifact([], {
  91. header: {
  92. version: 2,
  93. id: 'codec',
  94. createdAt: 1,
  95. cwd: '/work',
  96. parentSession: 'parent',
  97. isSeeded: false,
  98. origin: 'subagent',
  99. delegationDepth: 0,
  100. agentPreset: 'default',
  101. },
  102. })
  103. expect(encodeV2(complete).header)
  104. .toStrictEqual(fullPhysicalHeader)
  105. expect(() => releasedV2SessionFormatCodec.encodeHeader(
  106. artifact([]).header,
  107. 1,
  108. )).toThrow(/unseeded.*inherited events/)
  109. })
  110. it.each([
  111. ['null', null, /must be an object/],
  112. ['array', [], /must be an object/],
  113. ['scalar', 'session', /must be an object/],
  114. ['actually absent required member', (({ id: _id, ...rest }) => rest)(minimalPhysicalHeader), /lacks id/],
  115. ['unexpected member', { ...minimalPhysicalHeader, seedLength: 0 }, /unexpected field seedLength/],
  116. ['wrong type tag', { ...minimalPhysicalHeader, type: 'other' }, /expected released v2/],
  117. ['wrong version', { ...minimalPhysicalHeader, version: 1 }, /expected released v2/],
  118. ['non-string id', { ...minimalPhysicalHeader, id: 1 }, /id must be a string/],
  119. ['negative creation time', { ...minimalPhysicalHeader, createdAt: -1 }, /createdAt/],
  120. ['negative delegation depth', { ...minimalPhysicalHeader, delegationDepth: -1 }, /delegationDepth/],
  121. ['non-boolean lineage', { ...minimalPhysicalHeader, isSeeded: 0 }, /isSeeded must be boolean/],
  122. ['non-string cwd', { ...minimalPhysicalHeader, cwd: 1 }, /cwd must be a string/],
  123. ['relative cwd', { ...minimalPhysicalHeader, cwd: 'relative' }, /cwd must be absolute/],
  124. ['non-string parent', { ...minimalPhysicalHeader, parentSession: 1 }, /parentSession must be a string/],
  125. ['non-string preset', { ...minimalPhysicalHeader, agentPreset: 1 }, /agentPreset must be a string/],
  126. ['bad origin', { ...minimalPhysicalHeader, origin: 'user' }, /origin must be "subagent"/],
  127. ])('refuses a malformed physical header: %s', (_name, header, message) => {
  128. expect(() => releasedV2SessionFormatCodec.decodeHeader(header)).toThrow(message)
  129. })
  130. })
  131. describe('releasedV2SessionFormatCodec rows', () => {
  132. it('stores one event per row and compacts scalar, pair, and long-run source-event references exactly', () => {
  133. const source = artifact([
  134. feedback(0),
  135. userMessage(1, [0]),
  136. userMessage(2, [0, 1]),
  137. userMessage(3, [0, 1, 2]),
  138. ])
  139. const encoded = encodeV2(source)
  140. expect(encoded.rows.map(row => row['sourceEventSeqs'])).toStrictEqual([
  141. undefined,
  142. [0],
  143. [0, 1],
  144. [[0, 2]],
  145. ])
  146. expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source)
  147. })
  148. it('keeps non-monotonic source-event references scalar-only for the current backend reader', () => {
  149. const sourceEventSeqs = [4, 5, 1, 2, 3]
  150. const source = artifact([
  151. feedback(0), feedback(1), feedback(2), feedback(3), feedback(4), feedback(5),
  152. userMessage(6, sourceEventSeqs),
  153. ])
  154. const encoded = encodeV2(source)
  155. const stored = encoded.rows[6]?.['sourceEventSeqs']
  156. expect(stored).toStrictEqual(sourceEventSeqs)
  157. expect(decodeCurrentSeqRanges(stored, 6)).toStrictEqual(sourceEventSeqs)
  158. expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source)
  159. })
  160. it('keeps the v2 physical codec vocabulary-neutral for current growth and a future source freeze', () => {
  161. const source = artifact([
  162. { type: 'external/required', seq: 0, time: 1, data: { retained: true } },
  163. { type: 'external/ignorable', seq: 1, time: 2, data: { retained: true }, ignorable: true },
  164. { type: 'turn/start', seq: 2, time: 3, data: { turn: 1, postReleaseMember: true } },
  165. ])
  166. const encoded = encodeV2(source)
  167. expect(decodeV2(encoded.header, encoded.rows)).toStrictEqual(source)
  168. })
  169. it('expands mixed stored source-event ranges and preserves unrelated rows', () => {
  170. const rows: SessionFormatJsonObject[] = [feedback(0), feedback(1), feedback(2), feedback(3), feedback(4), {
  171. ...userMessage(5),
  172. sourceEventSeqs: [[0, 2], 4],
  173. }]
  174. const decoded = decodeV2(minimalPhysicalHeader, rows)
  175. expect(decoded.events[0]).toStrictEqual(feedback(0))
  176. expect(decoded.events[5]?.sourceEventSeqs).toStrictEqual([0, 1, 2, 4])
  177. const descending = decodeV2(minimalPhysicalHeader, [
  178. feedback(0), feedback(1), { ...userMessage(2), sourceEventSeqs: [1, 0] },
  179. ])
  180. expect(descending.events[2]?.sourceEventSeqs).toStrictEqual([1, 0])
  181. })
  182. it.each([
  183. ['not an array', 'bad', /must be an array/],
  184. ['invalid scalar', [-1], /sourceEventSeqs member/],
  185. ['malformed range', [[0]], /must be a \[start, end\] pair/],
  186. ['invalid range start', [[-1, 0]], /range start/],
  187. ['invalid range end', [[0, -1]], /range end/],
  188. ['reversed range', [[2, 1]], /range exceeds/],
  189. ['range ending at the event', [[0, 4]], /range exceeds/],
  190. ['ranges with too many expanded members', [0, 1, 2, [0, 1]], /range exceeds/],
  191. ['scalar at the event', [4], /unique earlier/],
  192. ['duplicate scalars', [0, 0], /unique earlier/],
  193. ['overlapping range and scalar', [[0, 1], 1], /unique earlier/],
  194. ['non-monotonic range', [3, [0, 2]], /strictly increasing/],
  195. ])('refuses malformed stored source-event ranges: %s', (_name, sourceEventSeqs, message) => {
  196. const rows = [feedback(0), feedback(1), feedback(2), feedback(3), {
  197. ...userMessage(4), sourceEventSeqs,
  198. }]
  199. expect(() => decodeV2(minimalPhysicalHeader, rows)).toThrow(message)
  200. })
  201. it('contains ordinary and non-SessionFormatError row failures in a recoverable tail', () => {
  202. const explosive = new Proxy(feedback(1), { ownKeys: () => { throw new Error('proxy failure') } })
  203. const primitiveFailure = new Proxy(feedback(1), {
  204. ownKeys: () => throwUnknown('primitive proxy failure'),
  205. })
  206. expect(decodeV2(
  207. minimalPhysicalHeader,
  208. [feedback(0), explosive],
  209. 'recoverable',
  210. ).events).toStrictEqual([feedback(0)])
  211. expect(decodeV2(
  212. minimalPhysicalHeader,
  213. [feedback(0), null],
  214. 'recoverable',
  215. ).events).toStrictEqual([feedback(0)])
  216. expect(decodeV2(
  217. minimalPhysicalHeader,
  218. [feedback(0), primitiveFailure],
  219. 'recoverable',
  220. ).events).toStrictEqual([feedback(0)])
  221. expect(decodeV2(
  222. minimalPhysicalHeader,
  223. [null, [], feedback(0)],
  224. 'recoverable',
  225. ).events).toStrictEqual([])
  226. })
  227. it('refuses malformed strict rows, strict gaps, and terminal recoverable tails', () => {
  228. expect(() => decodeV2(minimalPhysicalHeader, [null]))
  229. .toThrow(/row 0/)
  230. expect(() => decodeV2(minimalPhysicalHeader, [feedback(1)]))
  231. .toThrow(/seq gap/)
  232. expect(() => decodeV2(minimalPhysicalHeader, [{
  233. type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } },
  234. }], 'recoverable')).toThrow(/seq gap/)
  235. expect(() => decodeV2(minimalPhysicalHeader, [null, {
  236. type: 'turn/end', seq: 0, time: 1, data: { turn: 1, reason: { kind: 'completed' } },
  237. }], 'recoverable')).toThrow(/row 0/)
  238. expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), extra: true }]))
  239. .toThrow(/unexpected field extra/)
  240. expect(() => decodeV2(minimalPhysicalHeader, [{ type: 'feedback/record', seq: 0, time: 1 }]))
  241. .toThrow(/lacks required field data/)
  242. expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), type: 1 }]))
  243. .toThrow(/type must be a string/)
  244. expect(() => decodeV2(minimalPhysicalHeader, [{ ...feedback(0), ignorable: false }]))
  245. .toThrow(/ignorable must be true/)
  246. })
  247. it('keeps the prefix before a recoverable non-terminal seq gap', () => {
  248. expect(decodeV2(minimalPhysicalHeader, [
  249. feedback(0), feedback(3), feedback(1),
  250. ], 'recoverable').events).toStrictEqual([feedback(0)])
  251. })
  252. it('derives the last tagged seed marker and rejects lineage disagreements', () => {
  253. const seededHeader = { ...minimalPhysicalHeader, id: 'seeded', isSeeded: true }
  254. const markers = [
  255. { type: 'session/end-seed', seq: 0, time: 1, data: { inherited: true } },
  256. { type: 'session/end-seed', seq: 1, time: 2, data: {} },
  257. { type: 'session/end-seed', seq: 2, time: 3, data: { inherited: true } },
  258. ]
  259. expect(decodeV2(seededHeader, markers).inheritedEventCount).toBe(2)
  260. expect(decodeV2(seededHeader, [markers[0]]).inheritedEventCount).toBe(0)
  261. expect(() => decodeV2(seededHeader, []))
  262. .toThrow(/lacks an inherited end-seed marker/)
  263. expect(() => decodeV2(minimalPhysicalHeader, [markers[0]]))
  264. .toThrow(/unseeded Session contains an inherited end-seed marker/)
  265. expect(() => decodeV2(seededHeader, [{
  266. type: 'session/end-seed', seq: 0, time: 1, data: null,
  267. }])).toThrow(/must be an object/)
  268. })
  269. })