validation.spec.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444
  1. import { describe, expect, it } from 'vitest'
  2. import type {
  3. SessionFormatArtifact,
  4. SessionFormatEvent,
  5. SessionFormatHeader,
  6. SessionFormatJsonObject,
  7. SessionFormatJsonValue,
  8. } from '@deepseek-ai/dsh-session-format'
  9. import {
  10. RELEASED_V2_EVENT_TYPES,
  11. assertReleasedV2Header,
  12. restoreReleasedV2Artifact,
  13. } from '@deepseek-ai/dsh-session-format-v1-to-v2'
  14. import { assertReleasedV2Artifact } from '../src/testing/validation.ts'
  15. const textBlock = { type: 'text', text: 'hello' } as const
  16. const usage = { inputTokens: 3, outputTokens: 2 } as const
  17. const replayState = { response: { id: 'response' }, blocks: ['text-meta'] } as const
  18. function userData(id = 'user'): SessionFormatJsonObject {
  19. return {
  20. id,
  21. role: 'user',
  22. content: [textBlock],
  23. source: { kind: 'user' },
  24. }
  25. }
  26. function assistantData(
  27. options: {
  28. readonly content?: readonly SessionFormatJsonValue[]
  29. readonly stream?: SessionFormatJsonValue
  30. readonly usage?: SessionFormatJsonValue
  31. readonly replayState?: SessionFormatJsonValue
  32. readonly interrupted?: true
  33. } = {},
  34. ): SessionFormatJsonObject {
  35. const sourceReplay = options.replayState === undefined ? replayState : options.replayState
  36. return {
  37. turn: 1,
  38. step: 1,
  39. message: {
  40. id: 'assistant',
  41. role: 'assistant',
  42. content: options.content ?? [textBlock],
  43. source: {
  44. kind: 'model',
  45. provider: 'mock',
  46. model: 'mock',
  47. ...(sourceReplay === null ? {} : { replayState: sourceReplay }),
  48. },
  49. },
  50. stream: options.stream ?? [
  51. { type: 'text-chunks', time0: 3, index: 0, dt: [], texts: ['hello'] },
  52. { type: 'chunk', time: 4, chunk: { type: 'usage', usage } },
  53. { type: 'chunk', time: 5, chunk: { type: 'finish', reason: { kind: 'stop' }, replayState } },
  54. ],
  55. ...(options.usage === null ? {} : { usage: options.usage ?? usage }),
  56. ...(options.interrupted === undefined ? {} : { interrupted: options.interrupted }),
  57. }
  58. }
  59. function toolResultData(includeMeta: boolean): SessionFormatJsonObject {
  60. return {
  61. turn: 1,
  62. step: 1,
  63. message: {
  64. id: 'tool',
  65. role: 'user',
  66. content: [{
  67. type: 'tool-result', toolCallId: 'call', content: [textBlock], isError: false,
  68. }],
  69. source: { kind: 'tool', callId: 'call' },
  70. },
  71. ...(includeMeta ? { meta: { durable: true } } : {}),
  72. }
  73. }
  74. function event(
  75. type: string,
  76. seq: number,
  77. data: SessionFormatJsonValue,
  78. optional: Readonly<Record<string, SessionFormatJsonValue>> = {},
  79. ): SessionFormatEvent {
  80. return { type, seq, time: seq + 1, data, ...optional }
  81. }
  82. function artifact(
  83. events: readonly SessionFormatEvent[],
  84. overrides: Partial<SessionFormatArtifact> = {},
  85. ): SessionFormatArtifact {
  86. return {
  87. header: { version: 2, id: 'validation', createdAt: 1, isSeeded: false, delegationDepth: 0 },
  88. inheritedEventCount: 0,
  89. events,
  90. ...overrides,
  91. }
  92. }
  93. function assistantLifecycle(
  94. type: 'assistant/attempt' | 'assistant/message',
  95. data: SessionFormatJsonValue,
  96. ): SessionFormatArtifact {
  97. return artifact([
  98. event('turn/start', 0, { turn: 1 }),
  99. event('step/start', 1, { turn: 1, step: 1 }),
  100. event(type, 2, data, type === 'assistant/message' ? { surfaceOp: 'append' } : {}),
  101. event('step/end', 3, { turn: 1, step: 1 }),
  102. event('turn/end', 4, { turn: 1, reason: { kind: 'completed' } }),
  103. ])
  104. }
  105. describe('released v2 header validation', () => {
  106. const minimal = {
  107. version: 2, id: 'validation', createdAt: 1, isSeeded: false, delegationDepth: 0,
  108. } as const
  109. it('accepts minimal and complete logical headers', () => {
  110. expect(() => { assertReleasedV2Header(minimal) }).not.toThrow()
  111. expect(() => { assertReleasedV2Header({
  112. ...minimal,
  113. cwd: '/work',
  114. parentSession: 'parent',
  115. origin: 'subagent',
  116. agentPreset: 'default',
  117. }) }).not.toThrow()
  118. })
  119. it.each([
  120. ['null', null, /must be an object/],
  121. ['array', [], /must be an object/],
  122. ['scalar', 'header', /must be an object/],
  123. ['missing field', (({ id: _id, ...rest }) => rest)(minimal), /lacks required field id/],
  124. ['unexpected field', { ...minimal, extra: true }, /unexpected field extra/],
  125. ['wrong version', { ...minimal, version: 1 }, /expected format v2/],
  126. ['non-string id', { ...minimal, id: 1 }, /id must be a string/],
  127. ['invalid createdAt', { ...minimal, createdAt: -1 }, /createdAt/],
  128. ['invalid depth', { ...minimal, delegationDepth: -1 }, /delegationDepth/],
  129. ['invalid isSeeded', { ...minimal, isSeeded: 1 }, /isSeeded must be boolean/],
  130. ['non-string cwd', { ...minimal, cwd: 1 }, /cwd must be absolute/],
  131. ['relative cwd', { ...minimal, cwd: 'relative' }, /cwd must be absolute/],
  132. ['invalid parent', { ...minimal, parentSession: 1 }, /parentSession must be a string/],
  133. ['invalid preset', { ...minimal, agentPreset: 1 }, /agentPreset must be a string/],
  134. ['invalid origin', { ...minimal, origin: 'parent' }, /origin must be "subagent"/],
  135. ])('rejects exact logical header drift: %s', (_name, value, message) => {
  136. expect(() => { assertReleasedV2Header(value as SessionFormatHeader) }).toThrow(message)
  137. })
  138. })
  139. describe('released v2 event envelopes and payloads', () => {
  140. it('accepts empty artifacts and ordinary log events with a true ignorable marker', () => {
  141. expect(() => { assertReleasedV2Artifact(artifact([])) }).not.toThrow()
  142. expect(() => { assertReleasedV2Artifact(artifact([
  143. event('feedback/record', 0, { text: 'feedback' }, { ignorable: true }),
  144. ])) }).not.toThrow()
  145. })
  146. it('preserves the referenced Session generation across containing-log migration', () => {
  147. const containing = (capturedFormatVersion: number): SessionFormatArtifact => artifact([
  148. event('user/message', 0, {
  149. id: 'reference',
  150. role: 'user',
  151. content: [textBlock],
  152. source: {
  153. kind: 'session-reference',
  154. form: 'recall',
  155. version: 1,
  156. references: [{
  157. sessionId: 'source',
  158. label: 'Source',
  159. capturedFormatVersion,
  160. capturedThroughSeq: 0,
  161. compacted: false,
  162. originalMessages: 1,
  163. retainedMessages: 1,
  164. omittedMessages: 0,
  165. omittedBytes: 0,
  166. truncated: false,
  167. inputIndex: 0,
  168. }],
  169. },
  170. }, { surfaceOp: 'append' }),
  171. ])
  172. expect(() => { assertReleasedV2Artifact(containing(1)) }).not.toThrow()
  173. expect(() => { assertReleasedV2Artifact(containing(2)) }).not.toThrow()
  174. expect(() => { assertReleasedV2Artifact(containing(0)) }).toThrow(/capturedFormatVersion/)
  175. expect(() => { assertReleasedV2Artifact(containing(3)) }).toThrow(/capturedFormatVersion/)
  176. })
  177. it.each([
  178. ['inherited count beyond events', artifact([], { inheritedEventCount: 1 }), /exceeds its events/],
  179. ['unseeded inherited count', artifact([event('feedback/record', 0, { text: 'x' })], { inheritedEventCount: 1 }), /unseeded.*inherited events/],
  180. ['non-object event', artifact([null as unknown as SessionFormatEvent]), /event 0 must be an object/],
  181. ['non-string type', artifact([{ type: 1, seq: 0, time: 1, data: {} } as unknown as SessionFormatEvent]), /type must be a string/],
  182. ['unknown type', artifact([event('external/unknown', 0, {}, { ignorable: true })]), /unknown event type.*external\/unknown/],
  183. ['missing envelope field', artifact([{ type: 'feedback/record', seq: 0, time: 1 } as SessionFormatEvent]), /lacks required field data/],
  184. ['extra log field', artifact([event('feedback/record', 0, { text: 'x' }, { surfaceOp: 'append' })]), /unexpected field surfaceOp/],
  185. ['extra surface field', artifact([event('user/message', 0, userData(), { surfaceOp: 'append', extra: true })]), /unexpected field extra/],
  186. ['non-dense seq', artifact([event('feedback/record', 1, { text: 'x' })]), /not dense/],
  187. ['unsafe time', artifact([{ ...event('feedback/record', 0, { text: 'x' }), time: 0.5 }]), /time/],
  188. ['false ignorable marker', artifact([event('feedback/record', 0, { text: 'x' }, { ignorable: false })]), /ignorable must be true/],
  189. ['non-object payload', artifact([event('feedback/record', 0, null)]), /data must be an object/],
  190. ['missing payload member', artifact([event('feedback/record', 0, {})]), /lacks required field text/],
  191. ['extra payload member', artifact([event('feedback/record', 0, { text: 'x', extra: true })]), /unexpected field extra/],
  192. ['invalid payload semantics', artifact([event('feedback/record', 0, { text: '' })]), /non-empty string/],
  193. ])('rejects exact event or payload drift: %s', (_name, value, message) => {
  194. expect(() => { assertReleasedV2Artifact(value) }).toThrow(message)
  195. })
  196. it.each([
  197. ['inherited count beyond events', artifact([], { inheritedEventCount: 1 }), /exceeds its events/],
  198. ['unseeded inherited count', artifact([
  199. event('feedback/record', 0, { text: 'x' }),
  200. ], { inheritedEventCount: 1 }), /unseeded.*inherited events/],
  201. ['non-string type', artifact([
  202. { type: 1, seq: 0, time: 1, data: {} } as unknown as SessionFormatEvent,
  203. ]), /type must be a string/],
  204. ['non-dense seq', artifact([event('feedback/record', 1, { text: 'x' })]), /not dense/],
  205. ['false ignorable marker', artifact([
  206. event('feedback/record', 0, { text: 'x' }, { ignorable: false }),
  207. ]), /ignorable must be true/],
  208. ])('rejects production artifact drift before payload restoration: %s', (_name, value, message) => {
  209. expect(() => { restoreReleasedV2Artifact(value, new Set(RELEASED_V2_EVENT_TYPES)) }).toThrow(message)
  210. })
  211. it('validates present and absent opaque payload members before relationship checks', () => {
  212. for (const includeMeta of [false, true]) {
  213. const value = artifact([
  214. event('turn/start', 0, { turn: 1 }),
  215. event('step/start', 1, { turn: 1, step: 1 }),
  216. event('tool/result', 2, toolResultData(includeMeta), { surfaceOp: 'append' }),
  217. ])
  218. expect(() => { assertReleasedV2Artifact(value) }).toThrow(/no advertised tool lifecycle/)
  219. }
  220. const invalidOpaque = {
  221. ...toolResultData(true),
  222. meta: { callback: undefined as unknown as SessionFormatJsonValue },
  223. }
  224. expect(() => { assertReleasedV2Artifact(artifact([
  225. event('turn/start', 0, { turn: 1 }),
  226. event('step/start', 1, { turn: 1, step: 1 }),
  227. event('tool/result', 2, invalidOpaque, { surfaceOp: 'append' }),
  228. ])) }).toThrow()
  229. })
  230. })
  231. describe('released v2 Assistant streams', () => {
  232. it('accepts successful, interrupted, legacy-empty, and failed-attempt streams', () => {
  233. expect(() => { assertReleasedV2Artifact(assistantLifecycle('assistant/message', assistantData())) }).not.toThrow()
  234. expect(() => { assertReleasedV2Artifact(assistantLifecycle(
  235. 'assistant/message', assistantData({ interrupted: true }),
  236. )) }).not.toThrow()
  237. expect(() => { assertReleasedV2Artifact(assistantLifecycle('assistant/message', assistantData({
  238. content: [], stream: [], usage: null, replayState: null,
  239. }))) }).not.toThrow()
  240. expect(() => { assertReleasedV2Artifact(assistantLifecycle('assistant/attempt', {
  241. turn: 1,
  242. step: 1,
  243. stream: [{ type: 'text-chunks', time0: 3, index: 0, dt: [1], texts: ['a', 'b'] }],
  244. })) }).not.toThrow()
  245. })
  246. it.each([
  247. ['non-array stream', assistantLifecycle('assistant/attempt', { turn: 1, step: 1, stream: null }), /invalid embedded stream/],
  248. ['invalid compact record', assistantLifecycle('assistant/attempt', {
  249. turn: 1, step: 1, stream: [{ type: 'future' }],
  250. }), /invalid embedded stream/],
  251. ['invalid embedded chunk semantics', assistantLifecycle('assistant/attempt', {
  252. turn: 1,
  253. step: 1,
  254. stream: [{ type: 'chunk', time: 3, chunk: { type: 'future' } }],
  255. }), /invalid embedded stream/],
  256. ['invalid turn coordinate', assistantLifecycle('assistant/attempt', { turn: -1, step: 1, stream: [] }), /turn/],
  257. ['invalid step coordinate', assistantLifecycle('assistant/attempt', { turn: 1, step: -1, stream: [] }), /step/],
  258. ['content disagreement', assistantLifecycle('assistant/message', assistantData({
  259. content: [{ type: 'text', text: 'different' }],
  260. })), /content disagrees/],
  261. ['usage disagreement', assistantLifecycle('assistant/message', assistantData({
  262. usage: { inputTokens: 9, outputTokens: 2 },
  263. })), /usage disagrees/],
  264. ['replay disagreement', assistantLifecycle('assistant/message', assistantData({
  265. replayState: { response: { id: 'different' }, blocks: ['text-meta'] },
  266. })), /replay state disagrees/],
  267. ])('rejects an invalid embedded stream or projection: %s', (_name, value, message) => {
  268. expect(() => { assertReleasedV2Artifact(value) }).toThrow(message)
  269. })
  270. })
  271. describe('released v2 seed and surface relationships', () => {
  272. it('accepts tagged seeded lineage and an ordinary unseeded marker', () => {
  273. expect(() => { assertReleasedV2Artifact(artifact([
  274. event('session/end-seed', 0, { inherited: true }),
  275. ], {
  276. header: { version: 2, id: 'seeded', createdAt: 1, isSeeded: true, delegationDepth: 0 },
  277. inheritedEventCount: 0,
  278. })) }).not.toThrow()
  279. expect(() => { assertReleasedV2Artifact(artifact([
  280. event('session/end-seed', 0, {}),
  281. ])) }).not.toThrow()
  282. })
  283. it.each([
  284. ['seeded marker cut mismatch', artifact([event('session/end-seed', 0, { inherited: true })], {
  285. header: { version: 2, id: 'seeded', createdAt: 1, isSeeded: true, delegationDepth: 0 },
  286. inheritedEventCount: 1,
  287. }), /seeded header disagrees/],
  288. ['seeded header without marker', artifact([], {
  289. header: { version: 2, id: 'seeded', createdAt: 1, isSeeded: true, delegationDepth: 0 },
  290. }), /seeded header disagrees/],
  291. ['unseeded tagged marker', artifact([event('session/end-seed', 0, { inherited: true })]), /unseeded.*inherited end-seed/],
  292. ['invalid inherited tag', artifact([event('session/end-seed', 0, { inherited: false })]), /inherited must be true/],
  293. ])('rejects seed lineage disagreement: %s', (_name, value, message) => {
  294. expect(() => { assertReleasedV2Artifact(value) }).toThrow(message)
  295. })
  296. it('rejects production seed lineage disagreement', () => {
  297. const known = new Set(RELEASED_V2_EVENT_TYPES)
  298. expect(() => { restoreReleasedV2Artifact(artifact([], {
  299. header: { version: 2, id: 'seeded', createdAt: 1, isSeeded: true, delegationDepth: 0 },
  300. }), known) }).toThrow(/seeded header disagrees/)
  301. expect(() => { restoreReleasedV2Artifact(artifact([
  302. event('session/end-seed', 0, { inherited: true }),
  303. ]), known) }).toThrow(/unseeded.*inherited end-seed/)
  304. })
  305. it('accepts append and exact replacement surface operations', () => {
  306. expect(() => { assertReleasedV2Artifact(artifact([
  307. event('user/message', 0, userData('one'), { surfaceOp: 'append' }),
  308. ])) }).not.toThrow()
  309. expect(() => { assertReleasedV2Artifact(artifact([
  310. event('user/message', 0, userData('one'), { surfaceOp: 'append' }),
  311. event('user/message', 1, userData('two'), {
  312. surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0],
  313. }),
  314. ])) }).not.toThrow()
  315. })
  316. it.each([
  317. ['assistant chunk references', artifact([
  318. event('user/message', 0, userData(), { surfaceOp: 'append' }),
  319. event('assistant/message', 1, assistantData({ content: [], stream: [], usage: null, replayState: null }), {
  320. surfaceOp: 'append', sourceEventSeqs: [0],
  321. }),
  322. ]), /obsolete chunk references/],
  323. ['non-array source-event references', artifact([
  324. event('feedback/record', 0, { text: 'x' }),
  325. event('user/message', 1, userData(), { surfaceOp: 'append', sourceEventSeqs: 0 }),
  326. ]), /must be an array/],
  327. ['invalid source-event reference', artifact([
  328. event('feedback/record', 0, { text: 'x' }),
  329. event('user/message', 1, userData(), { surfaceOp: 'append', sourceEventSeqs: [-1] }),
  330. ]), /sourceEventSeqs member/],
  331. ['current source-event reference', artifact([
  332. event('feedback/record', 0, { text: 'x' }),
  333. event('user/message', 1, userData(), { surfaceOp: 'append', sourceEventSeqs: [1] }),
  334. ]), /unique earlier seqs/],
  335. ['duplicate source-event reference', artifact([
  336. event('feedback/record', 0, { text: 'x' }),
  337. event('feedback/record', 1, { text: 'y' }),
  338. event('user/message', 2, userData(), { surfaceOp: 'append', sourceEventSeqs: [0, 0] }),
  339. ]), /unique earlier seqs/],
  340. ['empty source-event references', artifact([
  341. event('feedback/record', 0, { text: 'x' }),
  342. event('user/message', 1, userData(), { surfaceOp: 'append', sourceEventSeqs: [] }),
  343. ]), /must be non-empty/],
  344. ['missing operation', artifact([event('user/message', 0, userData())]), /requires a surfaceOp/],
  345. ['non-object replacement', artifact([
  346. event('feedback/record', 0, { text: 'x' }),
  347. event('user/message', 1, userData(), { surfaceOp: true }),
  348. ]), /surfaceOp must be a JSON object/],
  349. ['missing replacement member', artifact([
  350. event('feedback/record', 0, { text: 'x' }),
  351. event('user/message', 1, userData(), { surfaceOp: { op: 'replace', start: 0 } }),
  352. ]), /lacks required member "end"/],
  353. ['extra replacement member', artifact([
  354. event('feedback/record', 0, { text: 'x' }),
  355. event('user/message', 1, userData(), { surfaceOp: { op: 'replace', start: 0, end: 0, extra: true } }),
  356. ]), /unexpected member "extra"/],
  357. ['wrong replacement op', artifact([
  358. event('feedback/record', 0, { text: 'x' }),
  359. event('user/message', 1, userData(), { surfaceOp: { op: 'append', start: 0, end: 0 } }),
  360. ]), /surfaceOp must replace/],
  361. ['invalid replacement start', artifact([
  362. event('feedback/record', 0, { text: 'x' }),
  363. event('user/message', 1, userData(), { surfaceOp: { op: 'replace', start: -1, end: 0 } }),
  364. ]), /surface start/],
  365. ['invalid replacement end', artifact([
  366. event('feedback/record', 0, { text: 'x' }),
  367. event('user/message', 1, userData(), { surfaceOp: { op: 'replace', start: 0, end: -1 } }),
  368. ]), /surface end/],
  369. ['reversed replacement', artifact([
  370. event('feedback/record', 0, { text: 'x' }),
  371. event('user/message', 1, userData(), { surfaceOp: { op: 'replace', start: 1, end: 0 } }),
  372. ]), /invalid surface replacement/],
  373. ['replacement reaches current event', artifact([
  374. event('feedback/record', 0, { text: 'x' }),
  375. event('user/message', 1, userData(), { surfaceOp: { op: 'replace', start: 0, end: 1 } }),
  376. ]), /invalid surface replacement/],
  377. ['replacement misses current surface', artifact([
  378. event('feedback/record', 0, { text: 'x' }),
  379. event('user/message', 1, userData(), {
  380. surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0],
  381. }),
  382. ]), /range is not on the current surface/],
  383. ])('rejects invalid surface metadata or relationships: %s', (_name, value, message) => {
  384. expect(() => { assertReleasedV2Artifact(value) }).toThrow(message)
  385. })
  386. it('reports cross-event relationship errors after exact local validation', () => {
  387. expect(() => { assertReleasedV2Artifact(artifact([
  388. event('turn/end', 0, { turn: 1, reason: { kind: 'completed' } }),
  389. ])) }).toThrow(/no matching open turn/)
  390. expect(() => { assertReleasedV2Artifact(artifact([
  391. event('session-log-deepseek/delivery-accepted', 0, {
  392. sessionId: 'other', throughSeq: 0, sessionFormatVersion: 2,
  393. }),
  394. ])) }).toThrow(/throughSeq/)
  395. })
  396. })
  397. describe('released v2 restoration seam', () => {
  398. it('returns the same validated artifact and preserves safe vocabulary extensions', () => {
  399. const value = artifact([])
  400. expect(restoreReleasedV2Artifact(value, new Set(RELEASED_V2_EVENT_TYPES))).toBe(value)
  401. const ignorableExtension = artifact([
  402. event('external/ignorable', 0, { retained: true }, { ignorable: true }),
  403. ])
  404. expect(restoreReleasedV2Artifact(ignorableExtension, new Set(RELEASED_V2_EVENT_TYPES)))
  405. .toBe(ignorableExtension)
  406. const installedExtension = artifact([event('external/installed', 0, { retained: true })])
  407. expect(restoreReleasedV2Artifact(
  408. installedExtension,
  409. new Set([...RELEASED_V2_EVENT_TYPES, 'external/installed']),
  410. )).toBe(installedExtension)
  411. const extendedPayload = artifact([event('turn/start', 0, { turn: 1, postReleaseMember: true })])
  412. expect(restoreReleasedV2Artifact(extendedPayload, new Set(RELEASED_V2_EVENT_TYPES)))
  413. .toBe(extendedPayload)
  414. expect(() => restoreReleasedV2Artifact(
  415. artifact([event('external/unknown', 0, {})]),
  416. new Set(RELEASED_V2_EVENT_TYPES),
  417. )).toThrow(/unknown event type/)
  418. })
  419. })