validation.spec.ts 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. import { describe, expect, it } from 'vitest'
  2. import type { SessionFormatEvent, SessionFormatJsonValue } from '@deepseek-ai/dsh-session-format'
  3. import { KNOWN_SESSION_EVENT_TYPES } from '@deepseek-ai/dsh-session'
  4. import {
  5. RELEASED_V0_EVENT_TYPES,
  6. RELEASED_V0_EVENT_DISPOSITIONS,
  7. } from '../src/index.ts'
  8. import { assertReleasedEventPayload, assertReleasedSurfaceMetadata } from '../src/validation.ts'
  9. import { restoreV0ToV1, restoreV1 } from '../src/testing/restore.ts'
  10. import {
  11. assertNormalizedReleasedV0Artifact,
  12. assertReleasedV0SourceArtifact,
  13. assertReleasedV1Artifact,
  14. assertReleasedV1MigrationSource,
  15. assertReleasedV1PhysicalArtifact,
  16. } from '../src/testing/validation.ts'
  17. const v0Header = {
  18. type: 'session', version: 0, id: 'validation', createdAt: 1, delegationDepth: 0,
  19. } as const
  20. const v1Header = { ...v0Header, version: 1 } as const
  21. const textBlock = { type: 'text', text: 'text' } as const
  22. const userMessage = {
  23. id: 'user-1', role: 'user', content: [textBlock], source: { kind: 'user' },
  24. } as const
  25. const assistantMessage = {
  26. id: 'assistant-1', role: 'assistant', content: [textBlock],
  27. source: { kind: 'model', provider: 'mock', model: 'mock' },
  28. } as const
  29. const toolMessage = {
  30. id: 'tool-1', role: 'user',
  31. content: [{ type: 'tool-result', toolCallId: 'call-1', content: [textBlock], isError: false }],
  32. source: { kind: 'tool', callId: 'call-1' },
  33. } as const
  34. const validPayloads: Readonly<Record<string, SessionFormatJsonValue>> = {
  35. 'agent-preset/selected': { agentPreset: 'default' },
  36. 'agent/inbox/spliced': { target: 'next-turn', start: 0, inserted: [userMessage] },
  37. 'approval/asked': { id: 'approval-1', toolName: 'bash', callId: 'call-1', reason: 'needed' },
  38. 'approval/decided': { id: 'approval-1', outcome: 'allowed-once' },
  39. 'approval/policy': { policy: 'ask', source: 'delegation' },
  40. 'assistant/chunk': { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'x' } },
  41. 'assistant/message': { turn: 1, step: 0, message: assistantMessage, usage: { inputTokens: 1, outputTokens: 1 } },
  42. 'command/done': { commandId: 'command-1', kind: 'success', sourceEventSeq: 0 },
  43. 'command/run': { commandId: 'command-1', name: 'compact', args: ' now', source: { kind: 'user' } },
  44. 'compaction/end': { compactionId: 'compact-1', turn: null },
  45. 'compaction/prune': { shadowedRange: { start: 0, end: 1 }, shadowedSeqs: [0, 1], shadowedTokenCount: 2 },
  46. 'compaction/start': { compactionId: 'compact-1', turn: 1 },
  47. 'compaction/summary': {
  48. compactionId: 'compact-1', summary: [textBlock],
  49. shadowedRange: { start: 0, end: 1 }, shadowedSeqs: [0, 1], shadowedTokenCount: 2,
  50. provider: 'mock', model: 'mock', rawOutput: [textBlock], llmStreamCall: true,
  51. },
  52. 'feedback/record': { text: 'feedback' },
  53. 'goal/change': {
  54. kind: 'goal/change', version: 1, operation: 'create',
  55. goal: { id: 'goal-1', revision: 1, objective: 'ship', phase: 'active', maxGoalRounds: 3 },
  56. roundsStarted: 0, createdAt: 1, updatedAt: 1,
  57. },
  58. 'hook/invoked': { turn: 1, point: 'PreToolUse', dialect: 'claude-code', handlerId: 'hook-1' },
  59. 'hook/result': { turn: 1, point: 'PreToolUse', handlerId: 'hook-1', decision: 'pass', durationMs: 1 },
  60. 'llm/retry': {
  61. retryId: 'retry-1', turn: 1, step: 0, provider: 'mock', mode: 'normal', policyKey: 'default',
  62. retry: 1, maxRetries: 2, delayMs: 10, failure: { message: 'retry', code: 'SERVER' },
  63. },
  64. 'llm/retry-started': { retryId: 'retry-1', turn: 1, step: 0, retry: 1 },
  65. 'model/selection': { provider: 'mock', model: 'mock', reasoningEffort: 'high' },
  66. 'permission/preset': { preset: 'default' },
  67. 'plan/mode': { active: true },
  68. 'request/context': { provider: 'mock', model: 'mock', contextWindow: 8192 },
  69. 'request/header': {
  70. header: {
  71. config: { provider: 'mock', model: 'mock', reasoningEffort: 'high', maxTokens: 100 },
  72. adapterDefaults: { reasoningEffort: true, maxTokens: true },
  73. system: 'system',
  74. tools: [{ name: 'tool', description: 'Tool', parameters: { type: 'object' } }],
  75. },
  76. reason: 'initial',
  77. },
  78. 'sandbox/mode': { mode: 'workspace-write', source: 'delegation' },
  79. 'schedule/change': {
  80. version: 1, operation: 'create',
  81. schedule: { id: 'schedule-1', kind: 'after', prompt: 'remember', afterSeconds: 60, scheduledAt: '2026-08-31T00:00:00.000Z' },
  82. },
  83. 'session-log-deepseek/delivery-accepted': { sessionId: 'validation', throughSeq: 0, sessionFormatVersion: 1 },
  84. 'session/end-seed': {},
  85. 'session/title': { title: 'Title', messageSeqs: [0], source: { kind: 'fallback' } },
  86. 'session/title-llm-request': {
  87. titleProvider: 'title-1', messageSeqs: [0], route: { provider: 'mock', model: 'mock' },
  88. system: 'title', messages: [userMessage], maxTokens: 20,
  89. },
  90. 'step/end': { turn: 1, step: 0 },
  91. 'step/start': { turn: 1, step: 0 },
  92. 'subagent/descriptor': {
  93. mode: 'continuable', version: 3, provider: 'in-process', label: 'child',
  94. agentProvider: 'mock', agentModel: 'mock', toolFilter: { allow: ['read'] },
  95. },
  96. 'subagent/model-selection-policy': { allowedModels: [{ provider: 'mock', model: 'mock' }] },
  97. 'team/member': {
  98. version: 1, teamId: 'team-1',
  99. member: { id: 'member-1', name: 'worker', description: 'work', provider: 'in-process', context: 'fresh', phase: 'active' },
  100. },
  101. 'team/message/delivered': { version: 1, teamId: 'team-1', messageId: 'message-1', targetId: 'member-1' },
  102. 'team/message/queued': {
  103. version: 1, teamId: 'team-1',
  104. message: {
  105. id: 'message-1', senderId: 'lead', senderName: 'lead', targetId: 'member-1',
  106. delivery: 'quiet', content: [textBlock],
  107. },
  108. },
  109. 'team/task': {
  110. version: 1, teamId: 'team-1',
  111. task: {
  112. id: 'task-1', revision: 1, subject: 'subject', description: 'description', status: 'pending',
  113. blockedBy: [], writeScopes: ['/work'],
  114. },
  115. },
  116. 'todo/write': { todos: [{ content: 'work', status: 'in_progress' }] },
  117. 'tool-workflow/agent-end': { runId: 'run-1', seq: 1, outcome: 'completed' },
  118. 'tool-workflow/agent-start': { runId: 'run-1', seq: 1, label: 'worker', phase: 'build', childId: 'child-1' },
  119. 'tool-workflow/run-end': { runId: 'run-1', stopReason: 'completed' },
  120. 'tool-workflow/run-start': { runId: 'run-1', name: 'workflow' },
  121. 'tool/call': { turn: 1, step: 0, callId: 'call-1', name: 'read', arguments: '{}' },
  122. 'tool/code-dispatch': {
  123. rootCallId: 'root', parentCallId: 'parent', subCallId: 'sub', name: 'read', arguments: { path: '/work' },
  124. isError: false, content: [textBlock],
  125. },
  126. 'tool/code-dispatch-start': {
  127. rootCallId: 'root', parentCallId: 'parent', subCallId: 'sub', name: 'read', arguments: { path: '/work' },
  128. },
  129. 'tool/result': { turn: 1, step: 0, message: toolMessage, error: { name: 'Error', code: 'FAILED' }, meta: { seq: 999 } },
  130. 'turn/end': { turn: 1, reason: { kind: 'completed' } },
  131. 'turn/start': { turn: 1 },
  132. 'user/message': userMessage,
  133. 'web/deepseek-search-llm-request': {
  134. endpoint: 'https://example.test/messages', apiVersion: '2023-06-01',
  135. body: {
  136. model: 'deepseek-chat', max_tokens: 100,
  137. messages: [{ role: 'user', content: [{ type: 'text', text: 'search' }] }],
  138. tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }],
  139. },
  140. },
  141. }
  142. function v1Artifact(type: string, data: SessionFormatJsonValue) {
  143. const surface = type === 'user/message' || type === 'assistant/message' || type === 'tool/result'
  144. const event = {
  145. type,
  146. seq: 3,
  147. time: 4,
  148. data,
  149. ...(surface ? { surfaceOp: 'append' } : {}),
  150. }
  151. return {
  152. header: {
  153. version: 1, id: 'validation', createdAt: 1, isSeeded: false, delegationDepth: 0,
  154. },
  155. inheritedEventCount: 0,
  156. events: [
  157. { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
  158. { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 0 } },
  159. { type: 'step/end', seq: 2, time: 3, data: { turn: 1, step: 0 } },
  160. event,
  161. ],
  162. }
  163. }
  164. function assertPayload(type: string, data: SessionFormatJsonValue): void {
  165. assertReleasedEventPayload({ type, seq: 3, time: 4, data }, 1)
  166. }
  167. function invalidLeafMutations(
  168. type: string,
  169. value: SessionFormatJsonValue,
  170. path: readonly string[] = [],
  171. ): Array<{ readonly path: string; readonly value: SessionFormatJsonValue }> {
  172. const joined = path.join('.')
  173. const opaque = RELEASED_V0_EVENT_DISPOSITIONS[type]?.opaque.includes(path[0] as string) === true
  174. || path.at(-1) === 'replayState'
  175. || path.at(-1) === 'parameters'
  176. if (opaque) return []
  177. const replacement: SessionFormatJsonValue = value === null
  178. ? false
  179. : typeof value === 'string' ? 1
  180. : typeof value === 'number' ? 'invalid'
  181. : typeof value === 'boolean' ? 'invalid'
  182. : Array.isArray(value) ? {}
  183. : []
  184. const mutations: Array<{ readonly path: string; readonly value: SessionFormatJsonValue }> = path.length === 0
  185. ? []
  186. : [{ path: joined, value: replacement }]
  187. if (Array.isArray(value)) {
  188. const members = value as readonly SessionFormatJsonValue[]
  189. members.forEach((member, index) => {
  190. mutations.push(...invalidLeafMutations(type, member, [...path, String(index)]))
  191. })
  192. } else if (typeof value === 'object' && value !== null) {
  193. for (const [key, member] of Object.entries(value)) {
  194. mutations.push(...invalidLeafMutations(type, member, [...path, key]))
  195. }
  196. }
  197. return mutations
  198. }
  199. function replaceAtPath(value: SessionFormatJsonValue, path: string, replacement: SessionFormatJsonValue): SessionFormatJsonValue {
  200. const copy = structuredClone(value)
  201. const keys = path.split('.')
  202. let current = copy as unknown as Record<string, SessionFormatJsonValue>
  203. for (const key of keys.slice(0, -1)) current = current[key] as unknown as Record<string, SessionFormatJsonValue>
  204. current[keys.at(-1) as string] = replacement
  205. return copy
  206. }
  207. describe('released event and payload inventory', () => {
  208. it('has an executable valid fixture for every frozen released-v0 event type', () => {
  209. expect(Object.keys(validPayloads).sort()).toEqual([...RELEASED_V0_EVENT_TYPES].sort())
  210. expect(RELEASED_V0_EVENT_TYPES).toHaveLength(51)
  211. expect(RELEASED_V0_EVENT_TYPES.filter(type => !KNOWN_SESSION_EVENT_TYPES.has(type))).toEqual([
  212. 'assistant/chunk',
  213. 'tool/code-dispatch',
  214. 'tool/code-dispatch-start',
  215. ])
  216. expect(KNOWN_SESSION_EVENT_TYPES.has('tool/ptc-dispatch')).toBe(true)
  217. expect(KNOWN_SESSION_EVENT_TYPES.has('tool/ptc-dispatch-start')).toBe(true)
  218. for (const [type, data] of Object.entries(validPayloads)) {
  219. expect(() => { assertPayload(type, data) }, type).not.toThrow()
  220. }
  221. })
  222. it('refuses an unexpected member on every known payload', () => {
  223. for (const [type, data] of Object.entries(validPayloads)) {
  224. const changed = { ...(data as Record<string, SessionFormatJsonValue>), unexpected: true }
  225. expect(() => { assertPayload(type, changed) }, type).toThrow(/unexpected member/)
  226. }
  227. })
  228. it('refuses nested schema drift across core and owner payload families', () => {
  229. const cases: Array<[string, SessionFormatJsonValue]> = [
  230. ['assistant/message', {
  231. ...(validPayloads['assistant/message'] as Record<string, SessionFormatJsonValue>),
  232. message: { ...assistantMessage, content: [{ ...textBlock, extra: true }] },
  233. }],
  234. ['llm/retry', {
  235. ...(validPayloads['llm/retry'] as Record<string, SessionFormatJsonValue>),
  236. failure: { message: 'bad', code: 'BAD', extra: true },
  237. }],
  238. ['request/header', {
  239. ...(validPayloads['request/header'] as Record<string, SessionFormatJsonValue>),
  240. header: { config: { provider: 'mock', model: 'mock', extra: true } },
  241. }],
  242. ['schedule/change', {
  243. version: 1, operation: 'create',
  244. schedule: { id: 'x', kind: 'at', prompt: 'x', scheduledAt: '2026-08-31T00:00:00.000Z', extra: true },
  245. }],
  246. ['goal/change', {
  247. ...(validPayloads['goal/change'] as Record<string, SessionFormatJsonValue>),
  248. goal: { id: 'g', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 1, extra: true },
  249. }],
  250. ['team/task', {
  251. ...(validPayloads['team/task'] as Record<string, SessionFormatJsonValue>),
  252. task: {
  253. id: 'task-1', revision: 1, subject: 'x', description: 'x', status: 'pending',
  254. blockedBy: [], writeScopes: [], extra: true,
  255. },
  256. }],
  257. ['todo/write', { todos: [{ content: 'x', status: 'pending', extra: true }] }],
  258. ['web/deepseek-search-llm-request', {
  259. ...(validPayloads['web/deepseek-search-llm-request'] as Record<string, SessionFormatJsonValue>),
  260. body: { model: 'x', max_tokens: 1, messages: [], tools: [], extra: true },
  261. }],
  262. ]
  263. for (const [index, [type, data]] of cases.entries()) {
  264. expect(() => { assertPayload(type, data) }, `${type}-${index}`).toThrow()
  265. }
  266. })
  267. it('refuses type corruption at every non-opaque nested member in the frozen fixture inventory', () => {
  268. let mutations = 0
  269. for (const [type, data] of Object.entries(validPayloads)) {
  270. for (const mutation of invalidLeafMutations(type, data)) {
  271. const changed = replaceAtPath(data, mutation.path, mutation.value)
  272. expect(
  273. () => { assertPayload(type, changed) },
  274. `${type}.${mutation.path}`,
  275. ).toThrow()
  276. mutations += 1
  277. }
  278. }
  279. expect(mutations).toBeGreaterThan(250)
  280. })
  281. it('allows explicit opaque tool metadata and PTC arguments losslessly', () => {
  282. for (const type of ['tool/result', 'tool/code-dispatch', 'tool/code-dispatch-start']) {
  283. const data = structuredClone(validPayloads[type] as SessionFormatJsonValue)
  284. expect(() => { assertPayload(type, data) }).not.toThrow()
  285. expect(data).toEqual(validPayloads[type])
  286. }
  287. })
  288. it('refuses opaque members that are not lossless JSON without snapshotting them', () => {
  289. const data = {
  290. ...(validPayloads['tool/result'] as Record<string, unknown>),
  291. meta: { value: undefined },
  292. }
  293. expect(() => { assertPayload('tool/result', data as unknown as SessionFormatJsonValue) })
  294. .toThrow(/opaque meta is not lossless JSON/)
  295. })
  296. it.each([
  297. ['user/message content block', 'user/message', {
  298. ...userMessage,
  299. content: [{ type: 'future-block', private: { preserved: true } }],
  300. }],
  301. ['user/message source', 'user/message', {
  302. ...userMessage,
  303. source: { kind: 'future-source', private: { preserved: true } },
  304. }],
  305. ['assistant finish reason', 'assistant/chunk', {
  306. turn: 1, step: 0,
  307. chunk: { type: 'finish', reason: { kind: 'future-reason', private: { preserved: true } } },
  308. }],
  309. ['turn/end reason', 'turn/end', {
  310. turn: 1, reason: { kind: 'future-reason', private: { preserved: true } },
  311. }],
  312. ] as const)('preserves an unknown merge-extensible %s arm', (_name, type, data) => {
  313. expect(() => { assertPayload(type, data) }).not.toThrow()
  314. })
  315. it('refuses unknown v0 events even when the envelope marks them ignorable', () => {
  316. const row = { type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true }
  317. expect(() => restoreV0ToV1(v0Header, [row]))
  318. .toThrow(/unknown historical event.*refuses.*ignorable/)
  319. })
  320. it('exercises each released test validation policy', () => {
  321. const v0 = {
  322. header: { version: 0, id: 'validation', createdAt: 1, isSeeded: false, delegationDepth: 0 },
  323. inheritedEventCount: 0,
  324. events: [],
  325. } as const
  326. const v1 = { ...v0, header: { ...v0.header, version: 1 } } as const
  327. const forwardCompatible = {
  328. ...v1,
  329. events: [{ type: 'plugin/future', seq: 0, time: 1, data: {}, ignorable: true }],
  330. } as const
  331. expect(() => { assertReleasedV0SourceArtifact(v0) }).not.toThrow()
  332. expect(() => { assertNormalizedReleasedV0Artifact({
  333. ...v0,
  334. events: [{ type: 'feedback/record', seq: 0, time: 1, data: { text: 'retained' } }],
  335. }) }).not.toThrow()
  336. expect(() => { assertReleasedV1MigrationSource(v1) }).not.toThrow()
  337. expect(() => { assertReleasedV1MigrationSource(forwardCompatible) }).not.toThrow()
  338. expect(() => { assertReleasedV1PhysicalArtifact(v1) }).not.toThrow()
  339. expect(() => { assertReleasedV0SourceArtifact({
  340. ...v0,
  341. events: [{
  342. type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
  343. data: { turn: 1, content: [], source: { kind: 'user' } },
  344. }],
  345. }) }).not.toThrow()
  346. expect(() => { assertReleasedV0SourceArtifact({
  347. ...v0,
  348. events: [{ type: 'compact/start', seq: 0, time: 1, data: { turn: null } }],
  349. }) }).not.toThrow()
  350. expect(() => { assertReleasedV0SourceArtifact({
  351. ...v0,
  352. events: [{ type: 'plugin/unknown', seq: 0, time: 1, data: {}, ignorable: true }],
  353. }) }).toThrow(/unknown historical event/)
  354. })
  355. it('permits empty Assistant source-event references only under the released-v1 policy', () => {
  356. const assistant = {
  357. type: 'assistant/message', seq: 1, time: 2, data: {},
  358. sourceEventSeqs: [], surfaceOp: 'append',
  359. }
  360. expect(() => { assertReleasedSurfaceMetadata(assistant, 1, assistant.type, 'allow-empty-assistant') })
  361. .not.toThrow()
  362. expect(() => { assertReleasedSurfaceMetadata(assistant, 1, assistant.type, 'forbid-assistant') })
  363. .toThrow(/obsolete chunk references/)
  364. })
  365. it('keeps capturedFormatVersion v1-only inside session-reference sources', () => {
  366. const data = {
  367. id: 'reference', role: 'user', content: [textBlock],
  368. source: {
  369. kind: 'session-reference', form: 'recall', version: 1,
  370. references: [{
  371. sessionId: 'source', label: 'Source', capturedFormatVersion: 1, capturedThroughSeq: 0,
  372. compacted: false, originalMessages: 1, retainedMessages: 1, omittedMessages: 0,
  373. omittedBytes: 0, truncated: false, inputIndex: 0,
  374. }],
  375. },
  376. }
  377. const event = { type: 'user/message', seq: 0, time: 1, data, surfaceOp: 'append' }
  378. expect(() => restoreV0ToV1(v0Header, [event]))
  379. .toThrow(/capturedFormatVersion/)
  380. expect(restoreV1(v1Header, [event]).events).toEqual([event])
  381. })
  382. it('accepts every released nested union variant and optional member', () => {
  383. const sources: SessionFormatJsonValue[] = [
  384. { kind: 'user', rpcId: 'rpc-1', clientTimeZone: 'Asia/Shanghai' },
  385. { kind: 'plugin', plugin: 'plain' },
  386. { kind: 'plugin', plugin: 'instructions', form: 'instructions' },
  387. { kind: 'plugin', plugin: 'catalog', form: 'catalog' },
  388. { kind: 'plugin', plugin: 'snapshot', form: 'snapshot', sections: [{ name: 'one', text: 'value' }] },
  389. { kind: 'plugin', plugin: 'notice', form: 'notice', summary: 'notice' },
  390. { kind: 'plugin', plugin: 'relay', form: 'relay' },
  391. { kind: 'plugin', plugin: 'recall', form: 'recall' },
  392. { kind: 'plugin', plugin: 'compact', compactionId: 'compact-1', sourceCommandId: 'command-1' },
  393. { kind: 'model', provider: 'mock', model: 'mock', replayState: { private: true } },
  394. { kind: 'tool', callId: 'call-1' },
  395. {
  396. kind: 'agent-instructions', form: 'instructions', baseline: true, baselineIdentity: 'base',
  397. changes: [
  398. { action: 'set', scope: '.', path: 'AGENTS.md', digest: 'one' },
  399. { action: 'replace', scope: 'src', path: 'src/AGENTS.md' },
  400. { action: 'remove', scope: 'old', path: 'old/AGENTS.md' },
  401. ],
  402. },
  403. { kind: 'agent-instructions', form: 'instructions', changes: [] },
  404. {
  405. kind: 'session-reference', form: 'recall', version: 1,
  406. references: [{
  407. sessionId: 'source', label: 'Source', capturedFormatVersion: 1, capturedThroughSeq: null,
  408. compacted: true, originalMessages: 2, retainedMessages: 1, omittedMessages: 1,
  409. omittedBytes: 20, truncated: true, inputIndex: 0,
  410. }],
  411. },
  412. {
  413. kind: 'session-reference', form: 'recall', version: 1,
  414. references: [{
  415. sessionId: 'source-without-version', label: 'Source', capturedThroughSeq: 0,
  416. compacted: false, originalMessages: 1, retainedMessages: 1, omittedMessages: 0,
  417. omittedBytes: 0, truncated: false, inputIndex: 0,
  418. }],
  419. },
  420. { kind: 'team-message', teamId: 'team', messageId: 'message', senderId: 'sender', senderName: 'Sender' },
  421. { kind: 'goal', goalId: 'goal', revision: 1, round: 1 },
  422. { kind: 'skill-invocation', name: 'skill', form: 'instructions' },
  423. { kind: 'skill-catalog', form: 'catalog', update: true, entries: [{ name: 'skill', description: 'Skill' }] },
  424. { kind: 'skill-catalog', form: 'catalog', entries: [] },
  425. { kind: 'coordinator', form: 'relay', senderSessionId: 'parent' },
  426. { kind: 'subagent-report', form: 'relay', senderSessionId: 'child' },
  427. { kind: 'subagent-settled', form: 'notice', summary: 'settled', senderSessionId: 'child' },
  428. {
  429. kind: 'webhook', provider: 'github', source: 'repo', deliveryId: 'delivery', ruleId: 'rule',
  430. form: 'notice', summary: 'push',
  431. },
  432. ]
  433. for (const [index, source] of sources.entries()) {
  434. const message = { id: `source-${index}`, role: 'user', content: [textBlock], source }
  435. expect(() => { assertPayload('user/message', message) }).not.toThrow()
  436. }
  437. const blocks: SessionFormatJsonValue[] = [
  438. { type: 'reasoning', text: 'reasoning' },
  439. {
  440. type: 'image', attachment: {
  441. attachmentId: 'image', mediaType: 'image/png', bytes: 10, width: 2, height: 2, name: 'x.png',
  442. originalDimensions: { width: 4, height: 4 },
  443. },
  444. },
  445. {
  446. type: 'image', attachment: {
  447. attachmentId: 'minimal-image', mediaType: 'image/jpeg', bytes: 1, width: 1, height: 1,
  448. },
  449. },
  450. { type: 'tool-call', id: 'call', name: 'read', arguments: '{}' },
  451. { type: 'tool-result', toolCallId: 'call', content: [textBlock], isError: true },
  452. ]
  453. for (const [index, block] of blocks.entries()) {
  454. const message = { ...userMessage, id: `block-${index}`, content: [block] }
  455. expect(() => { assertPayload('user/message', message) }).not.toThrow()
  456. }
  457. const chunks: SessionFormatJsonValue[] = [
  458. { type: 'block-start', index: 0, blockType: 'text' },
  459. { type: 'reasoning-delta', index: 0, text: 'r' },
  460. { type: 'tool-call-delta', index: 0, id: 'call', name: 'read', argumentsDelta: '{}' },
  461. { type: 'tool-call-delta', index: 0, id: 'call', argumentsDelta: '{}' },
  462. { type: 'block-end', index: 0, block: textBlock },
  463. { type: 'usage', usage: {
  464. inputTokens: 1, outputTokens: 2, totalTokens: 3, cacheReadTokens: 0,
  465. cacheWriteTokens: 0, reasoningTokens: 1,
  466. } },
  467. { type: 'finish', reason: { kind: 'stop' }, replayState: { response: { id: 'response' }, blocks: [{}] } },
  468. { type: 'finish', reason: { kind: 'tool-calls' } },
  469. { type: 'finish', reason: { kind: 'max-tokens' } },
  470. { type: 'finish', reason: { kind: 'aborted', failure: { message: 'abort', code: 'ABORT' } } },
  471. { type: 'finish', reason: { kind: 'error', failure: { message: 'error', code: 'ERROR' } } },
  472. ]
  473. for (const chunk of chunks) {
  474. expect(() => { assertPayload('assistant/chunk', { turn: 1, step: 0, chunk }) }).not.toThrow()
  475. }
  476. const turnReasons: SessionFormatJsonValue[] = [
  477. { kind: 'blocked' }, { kind: 'max-tokens' }, { kind: 'interrupted' },
  478. { kind: 'aborted', reason: { kind: 'user' } },
  479. { kind: 'aborted', reason: { kind: 'parent' } },
  480. { kind: 'aborted', reason: { kind: 'disposed' } },
  481. { kind: 'aborted', reason: { kind: 'legacy' } },
  482. { kind: 'aborted', reason: { kind: 'hook', reason: 'hook' } },
  483. { kind: 'error', error: { message: 'error', code: 'ERROR', status: 500, providerRetryAfterMs: 1, requestId: 'id' } },
  484. ]
  485. for (const reason of turnReasons) {
  486. expect(() => { assertPayload('turn/end', { turn: 1, reason }) }).not.toThrow()
  487. }
  488. const remaining: Array<[string, SessionFormatJsonValue]> = [
  489. ['goal/change', { kind: 'goal/change', version: 1, operation: 'clear', cleared: { id: 'g', revision: 2 }, clearedAt: 3 }],
  490. ['goal/change', {
  491. kind: 'goal/change', version: 1, operation: 'block',
  492. goal: {
  493. id: 'g', revision: 2, objective: 'x', phase: 'blocked', maxGoalRounds: 3,
  494. blockedReason: { code: 'waiting', message: 'Wait' },
  495. },
  496. roundsStarted: 1, createdAt: 1, updatedAt: 2,
  497. }],
  498. ['schedule/change', { version: 1, operation: 'create', schedule: { id: 'at', kind: 'at', prompt: 'x', scheduledAt: '2026-08-31T00:00:00.000Z' } }],
  499. ['schedule/change', { version: 1, operation: 'create', schedule: { id: 'every', kind: 'every', prompt: 'x', everySeconds: 300, scheduledAt: '2026-08-31T00:00:00.000Z' } }],
  500. ['schedule/change', { version: 1, operation: 'delete', id: 'at' }],
  501. ['schedule/change', { version: 1, operation: 'dispatch', id: 'every', acceptedAt: '2026-08-31T00:00:00.000Z' }],
  502. ['llm/retry', {
  503. retryId: 'r', turn: 1, step: 0, provider: 'p', mode: 'always', policyKey: 'k', retry: 1,
  504. delayMs: 0, failure: { message: 'x', code: 'X' },
  505. }],
  506. ['session/title', { title: 'User title', messageSeqs: [], source: { kind: 'user' } }],
  507. ['session/title', { title: 'Provider title', messageSeqs: [0], source: { kind: 'provider', provider: 'p', model: { provider: 'p', model: 'm' } } }],
  508. ['session/title', { title: 'Provider title', messageSeqs: [0], source: { kind: 'provider', provider: 'p' } }],
  509. ['subagent/descriptor', { mode: 'one-shot', version: 3, provider: 'p', label: 'child' }],
  510. ['subagent/descriptor', { mode: 'one-shot', version: 3, provider: 'p' }],
  511. ['subagent/descriptor', {
  512. mode: 'continuable', version: 3, provider: 'p', label: 'child', agentProvider: 'p', agentModel: 'm',
  513. agentReasoningEffort: 'high', persona: 'persona', toolFilter: { deny: ['write'] },
  514. }],
  515. ['subagent/descriptor', { mode: 'continuable', version: 3, provider: 'p', label: 'child' }],
  516. ['approval/asked', { id: 'approval', toolName: 'read' }],
  517. ['team/member', {
  518. version: 1, teamId: 'team',
  519. member: { id: 'm', name: 'm', description: 'd', provider: 'p', context: 'fork', phase: 'failed', error: 'failure' },
  520. }],
  521. ['team/task', {
  522. version: 1, teamId: 'team',
  523. task: { id: 'task-2', revision: 2, subject: 's', description: 'd', status: 'completed', ownerId: 'm', blockedBy: ['task-1'], writeScopes: [] },
  524. }],
  525. ['command/done', { commandId: 'c', kind: 'error', text: 'failed' }],
  526. ['compaction/end', { compactionId: 'c', sourceCommandId: 'command', turn: 1, error: 'failure' }],
  527. ]
  528. for (const [type, data] of remaining) {
  529. expect(() => { assertPayload(type, data) }, type).not.toThrow()
  530. }
  531. })
  532. it('validates legacy round-zero goal mutation messages', () => {
  533. const change = {
  534. kind: 'goal/change', version: 1, operation: 'clear',
  535. cleared: { id: 'goal', revision: 2 }, clearedAt: 3,
  536. }
  537. const content = [{
  538. type: 'text',
  539. text: `<goal_state>${JSON.stringify({ cleared: change.cleared, clearedAt: change.clearedAt })}</goal_state>`,
  540. }]
  541. const message = {
  542. id: 'legacy-goal', role: 'user', content,
  543. source: { kind: 'goal', goalId: 'goal', revision: 2, round: 0, change },
  544. }
  545. expect(() => { assertPayload('user/message', message) }).not.toThrow()
  546. expect(() => { assertPayload('user/message', {
  547. ...message, source: { ...message.source, round: 1 },
  548. }) }).toThrow(/round must be 0/)
  549. expect(() => { assertPayload('user/message', {
  550. ...message, source: { ...message.source, goalId: 'other' },
  551. }) }).toThrow(/does not match/)
  552. expect(() => { assertPayload('user/message', { ...message, content: [textBlock] }) })
  553. .toThrow(/content does not match/)
  554. })
  555. it('refuses malformed logical headers, cuts, event envelopes, and surface metadata', () => {
  556. const base = v1Artifact('turn/start', { turn: 1 })
  557. const invalidHeaders = [
  558. { ...base.header, version: 0 },
  559. { ...base.header, id: 1 },
  560. { ...base.header, createdAt: -1 },
  561. { ...base.header, isSeeded: 'yes' },
  562. { ...base.header, delegationDepth: -1 },
  563. { ...base.header, cwd: 1 },
  564. { ...base.header, parentSession: 1 },
  565. { ...base.header, agentPreset: 1 },
  566. { ...base.header, origin: 'other' },
  567. ]
  568. for (const header of invalidHeaders) {
  569. expect(() => { assertReleasedV1Artifact({ ...base, header } as never) }).toThrow()
  570. }
  571. expect(() => { assertReleasedV1Artifact({ ...base, inheritedEventCount: base.events.length + 1 }) }).toThrow(/exceeds/)
  572. expect(() => { assertReleasedV1Artifact({ ...base, inheritedEventCount: 1 }) }).toThrow(/unseeded/)
  573. const rawEvents: unknown[][] = [
  574. [{ type: 1, seq: 0, time: 1, data: {} }],
  575. [{ type: 'plugin/unknown', seq: 0, time: 1, data: {} }],
  576. [{ type: 'turn/start', seq: 1, time: 1, data: { turn: 1 } }],
  577. [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, ignorable: false }],
  578. [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 }, surfaceOp: 'append' }],
  579. ]
  580. for (const events of rawEvents) {
  581. expect(() => { assertReleasedV1Artifact({ ...base, events } as never) }).toThrow()
  582. }
  583. const surfaceData = userMessage
  584. const surfaceVariants = [
  585. { sourceEventSeqs: 'bad', surfaceOp: 'append' },
  586. { sourceEventSeqs: [3], surfaceOp: 'append' },
  587. { sourceEventSeqs: [1, 1], surfaceOp: 'append' },
  588. { sourceEventSeqs: [], surfaceOp: 'append' },
  589. { surfaceOp: null },
  590. { surfaceOp: { op: 'append', start: 0, end: 1 } },
  591. { surfaceOp: { op: 'replace', start: 3, end: 1 } },
  592. { surfaceOp: { op: 'replace', start: 1, end: 3 } },
  593. ]
  594. for (const metadata of surfaceVariants) {
  595. const artifact = v1Artifact('user/message', surfaceData)
  596. artifact.events[3] = { ...artifact.events[3], ...metadata } as never
  597. expect(() => { assertReleasedV1Artifact(artifact) }).toThrow()
  598. }
  599. const assistant = v1Artifact('assistant/message', validPayloads['assistant/message'] as SessionFormatJsonValue)
  600. assistant.events[3] = { ...assistant.events[3], sourceEventSeqs: [], surfaceOp: 'append' } as never
  601. expect(() => { assertReleasedEventPayload(assistant.events[3] as SessionFormatEvent, 1) }).not.toThrow()
  602. })
  603. it('accepts remaining optional payload members', () => {
  604. const cases: Array<[string, SessionFormatJsonValue]> = [
  605. ['agent/inbox/spliced', { target: 'next-step', start: 0, removedCount: 1, inserted: [], outcome: 'canceled' }],
  606. ['assistant/message', {
  607. turn: 1, step: 0, message: assistantMessage, interrupted: true,
  608. usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0 },
  609. }],
  610. ['compaction/summary', {
  611. compactionId: 'c', sourceCommandId: 'command', summary: [textBlock],
  612. shadowedRange: { start: 0, end: 1 }, shadowedSeqs: [0, 1], shadowedTokenCount: 2,
  613. provider: 'p', model: 'm', maxTokens: 10, usage: { inputTokens: 1, outputTokens: 1 },
  614. rawOutput: [textBlock], llmStreamCall: true,
  615. }],
  616. ['hook/invoked', { turn: 1, point: 'Stop', dialect: 'codex', matcher: '*', handlerId: 'h' }],
  617. ['hook/result', {
  618. turn: 1, point: 'Stop', handlerId: 'h', decision: 'pass', exitCode: -1,
  619. stderrSummary: 'stderr', durationMs: 1,
  620. }],
  621. ['request/header', {
  622. header: {
  623. config: { provider: 'p', model: 'm', temperature: 0.5, maxTokens: 5, stop: ['stop'] },
  624. system: '', tools: [],
  625. },
  626. reason: 'change', startsSeries: true,
  627. }],
  628. ]
  629. for (const [type, data] of cases) {
  630. expect(() => { assertPayload(type, data) }, type).not.toThrow()
  631. }
  632. })
  633. it('refuses every relationship-specific invalid payload branch', () => {
  634. const cases: Array<[string, SessionFormatJsonValue]> = [
  635. ['command/done', { commandId: 'c', kind: 'success', sourceEventSeq: 3 }],
  636. ['session/title-llm-request', {
  637. titleProvider: 'p', messageSeqs: [], route: { provider: 'p', model: 'm' },
  638. system: 's', messages: [userMessage], maxTokens: 1,
  639. }],
  640. ['session/title', { title: 't', messageSeqs: [0, 0], source: { kind: 'fallback' } }],
  641. ['tool/result', { turn: 1, step: 0, message: { ...toolMessage, content: [] } }],
  642. ['tool/result', {
  643. turn: 1, step: 0,
  644. message: { ...toolMessage, content: [{ type: 'text', text: 'not a result' }] },
  645. }],
  646. ['user/message', {
  647. ...userMessage,
  648. source: { kind: 'plugin', plugin: 'x', form: 'notice', summary: 'x', sections: [] },
  649. }],
  650. ['user/message', {
  651. ...userMessage,
  652. source: {
  653. kind: 'session-reference', form: 'recall', version: 1,
  654. references: [{
  655. sessionId: 's', label: 's', capturedThroughSeq: null, compacted: false,
  656. originalMessages: 1, retainedMessages: 1, omittedMessages: 0, omittedBytes: 0,
  657. truncated: false, inputIndex: 1,
  658. }],
  659. },
  660. }],
  661. ['user/message', {
  662. ...userMessage,
  663. source: { kind: 'plugin', plugin: 'x', form: 'snapshot', sections: [], summary: 'x' },
  664. }],
  665. ['user/message', {
  666. ...userMessage,
  667. source: { kind: 'plugin', plugin: 'compact' },
  668. }],
  669. ['assistant/chunk', {
  670. turn: 1, step: 0, chunk: { type: 'finish', reason: { kind: 'stop' }, replayState: { response: {}, blocks: {} } },
  671. }],
  672. ['compaction/summary', {
  673. compactionId: 'c', summary: [], shadowedRange: { start: 0, end: 0 }, shadowedSeqs: [0],
  674. shadowedTokenCount: 0, provider: 'p', model: 'm', llmStreamCall: true,
  675. }],
  676. ['hook/result', { turn: 1, point: 'Stop', handlerId: 'h', decision: 'pass', durationMs: -0.5 }],
  677. ['llm/retry', {
  678. retryId: 'r', turn: 1, step: 0, provider: 'p', mode: 'always', policyKey: 'k', retry: 1,
  679. maxRetries: 2, delayMs: 0, failure: { message: 'x', code: 'X' },
  680. }],
  681. ['session/title-llm-request', {
  682. titleProvider: 'p', messageSeqs: [0], route: { provider: 'p', model: 'm' },
  683. system: 's', messages: [userMessage], maxTokens: 0,
  684. }],
  685. ['request/header', {
  686. header: { config: { provider: 'p', model: 'm' }, adapterDefaults: { maxTokens: true } },
  687. reason: 'initial',
  688. }],
  689. ['compaction/prune', {
  690. shadowedRange: { start: 1, end: 0 }, shadowedSeqs: [0, 1], shadowedTokenCount: 1,
  691. }],
  692. ['compaction/prune', {
  693. shadowedRange: { start: 0, end: 2 }, shadowedSeqs: [0, 1], shadowedTokenCount: 1,
  694. }],
  695. ['goal/change', {
  696. kind: 'goal/change', version: 1, operation: 'create',
  697. goal: { id: 'g', revision: 1, objective: 'x', phase: 'active', maxGoalRounds: 1, blockedReason: { code: 'x', message: 'x' } },
  698. roundsStarted: 0, createdAt: 1, updatedAt: 1,
  699. }],
  700. ['schedule/change', { version: 1, operation: 'create', schedule: { id: 'x', kind: 'unknown', prompt: 'x', scheduledAt: 'x' } }],
  701. ['schedule/change', { version: 1, operation: 'create', schedule: { id: 'x', kind: 'every', prompt: 'x', everySeconds: 299, scheduledAt: '2026-08-31T00:00:00.000Z' } }],
  702. ['schedule/change', { version: 1, operation: 'delete', id: ' x ' }],
  703. ['schedule/change', { version: 1, operation: 'dispatch', id: 'x', acceptedAt: '2026-02-31T00:00:00.000Z' }],
  704. ['subagent/descriptor', { mode: 'continuable', version: 3, provider: 'p', label: 'x', agentProvider: 'p' }],
  705. ['subagent/descriptor', { mode: 'continuable', version: 3, provider: 'p', label: 'x', toolFilter: {} }],
  706. ['subagent/model-selection-policy', { allowedModels: [{ provider: 'p', model: 'm' }, { provider: 'p', model: 'm' }] }],
  707. ['subagent/model-selection-policy', { allowedModels: [] }],
  708. ['user/message', {
  709. ...userMessage,
  710. source: {
  711. kind: 'session-reference', form: 'recall', version: 1,
  712. references: [{
  713. sessionId: 's', label: 's', capturedFormatVersion: 2, capturedThroughSeq: null,
  714. compacted: false, originalMessages: 1, retainedMessages: 1, omittedMessages: 0,
  715. omittedBytes: 0, truncated: false, inputIndex: 0,
  716. }],
  717. },
  718. }],
  719. ['user/message', {
  720. ...userMessage,
  721. source: {
  722. kind: 'session-reference', form: 'recall', version: 1,
  723. references: [{
  724. sessionId: 's', label: 's', capturedThroughSeq: null, compacted: false,
  725. originalMessages: 2, retainedMessages: 1, omittedMessages: 1, omittedBytes: 0,
  726. truncated: false, inputIndex: 0,
  727. }],
  728. },
  729. }],
  730. ['user/message', { ...userMessage, source: { kind: 'session-reference', form: 'recall', version: 1, references: [] } }],
  731. ['user/message', {
  732. ...userMessage,
  733. source: {
  734. kind: 'session-reference', form: 'recall', version: 1,
  735. references: [0, 1].map(inputIndex => ({
  736. sessionId: 'same', label: 's', capturedThroughSeq: null, compacted: false,
  737. originalMessages: 1, retainedMessages: 1, omittedMessages: 0, omittedBytes: 0,
  738. truncated: false, inputIndex,
  739. })),
  740. },
  741. }],
  742. ['web/deepseek-search-llm-request', {
  743. endpoint: 'x', apiVersion: 'x',
  744. body: { model: 'm', max_tokens: 1, messages: [{ role: 'user', content: [] }], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }] },
  745. }],
  746. ['web/deepseek-search-llm-request', {
  747. endpoint: 'x', apiVersion: 'x',
  748. body: { model: 'm', max_tokens: 1, messages: [], tools: [{ type: 'web_search_20250305', name: 'web_search', max_uses: 1 }] },
  749. }],
  750. ['web/deepseek-search-llm-request', {
  751. endpoint: 'x', apiVersion: 'x',
  752. body: { model: 'm', max_tokens: 1, messages: [{ role: 'user', content: [{ type: 'text', text: 'x' }] }], tools: [] },
  753. }],
  754. ]
  755. for (const [index, [type, data]] of cases.entries()) {
  756. expect(() => { assertPayload(type, data) }, `${type}-${index}`).toThrow()
  757. }
  758. })
  759. })