invariant.spec.ts 9.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from '@deepseek-ai/cordis'
  3. import InvariantRegistry, { InvariantError } from '@deepseek-ai/dsh-invariants'
  4. import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
  5. import { WorkflowRunId, type WorkflowRunId as WorkflowRunIdType } from '@deepseek-ai/dsh-workflow/types'
  6. import * as ToolWorkflowInvariant from '../src/invariant.ts'
  7. import type {} from '../src/types.ts'
  8. async function setup(): Promise<Context> {
  9. const ctx = new Context()
  10. await ctx.plugin(SessionStore)
  11. await ctx.plugin(InvariantRegistry, { enabled: true })
  12. await ctx.plugin(ToolWorkflowInvariant)
  13. return ctx
  14. }
  15. describe('durable workflow-record invariants', () => {
  16. it('accepts interleaved complete runs and an unfinished continuous prefix', async () => {
  17. const ctx = await setup()
  18. const session = ctx.sessions.create(SessionId('workflow-record-valid'))
  19. session.append('turn/start', { turn: 1 })
  20. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  21. const first = WorkflowRunId('first')
  22. const second = WorkflowRunId('second')
  23. const third = WorkflowRunId('third')
  24. session.append('tool-workflow/run-start', { runId: first, name: 'first' })
  25. session.append('tool-workflow/run-start', { runId: second, name: 'second' })
  26. session.append('tool-workflow/agent-start', {
  27. runId: second, seq: 1, label: '', phase: '', childId: SessionId('child'),
  28. })
  29. session.append('tool-workflow/run-end', { runId: first, stopReason: 'completed' })
  30. session.append('tool-workflow/agent-end', { runId: second, seq: 1, outcome: 'cancelled' })
  31. session.append('tool-workflow/run-end', { runId: second, stopReason: 'cancelled' })
  32. session.append('tool-workflow/run-start', { runId: third, name: 'third' })
  33. session.append('tool-workflow/agent-start', {
  34. runId: third, seq: 1, label: 'failed', childId: SessionId('failed-child'),
  35. })
  36. session.append('tool-workflow/agent-end', { runId: third, seq: 1, outcome: 'failed' })
  37. session.append('tool-workflow/run-end', { runId: third, stopReason: 'error' })
  38. session.append('tool-workflow/run-start', { runId: WorkflowRunId('prefix'), name: 'prefix' })
  39. expect(() => session.append('tool-workflow/agent-start', {
  40. runId: WorkflowRunId('prefix'), seq: 1, label: 'open', childId: SessionId('open-child'),
  41. })).not.toThrow()
  42. })
  43. it('rejects a malformed candidate before commit and keeps the fold reusable', async () => {
  44. const ctx = await setup()
  45. const session = ctx.sessions.create(SessionId('workflow-record-invalid'))
  46. const runId = WorkflowRunId('run')
  47. session.append('tool-workflow/run-start', { runId, name: 'run' })
  48. const before = session.seq
  49. expect(() => session.append('tool-workflow/agent-end', {
  50. runId, seq: 1, outcome: 'completed',
  51. })).toThrow(expect.objectContaining<Partial<InvariantError>>({
  52. code: 'INVARIANT',
  53. packageName: '@deepseek-ai/dsh-tool-workflow',
  54. }))
  55. expect(session.seq).toBe(before)
  56. expect(() => session.append('tool-workflow/run-end', {
  57. runId, stopReason: 'completed',
  58. })).not.toThrow()
  59. })
  60. type Mutation = (session: Session, runId: WorkflowRunIdType) => void
  61. const appendRaw = (session: Session, type: string, data: unknown): void => {
  62. const append = session.append.bind(session) as (eventType: string, eventData: unknown) => unknown
  63. append(type, data)
  64. }
  65. const invalidCases: readonly [string, Mutation, RegExp][] = [
  66. ['null event data', (session) => {
  67. appendRaw(session, 'tool-workflow/run-start', null)
  68. }, /data must be a JSON object/],
  69. ['primitive event data', (session) => {
  70. appendRaw(session, 'tool-workflow/run-start', 1)
  71. }, /data must be a JSON object/],
  72. ['array event data', (session) => {
  73. appendRaw(session, 'tool-workflow/run-start', [])
  74. }, /data must be a JSON object/],
  75. ['numeric run id', (session) => {
  76. session.append('tool-workflow/agent-start', {
  77. runId: 1 as never, seq: 1, label: 'bad', childId: SessionId('child'),
  78. })
  79. }, /runId must be a non-empty string/],
  80. ['empty run id', (session) => {
  81. session.append('tool-workflow/agent-start', {
  82. runId: WorkflowRunId(''), seq: 1, label: 'bad', childId: SessionId('child'),
  83. })
  84. }, /runId must be a non-empty string/],
  85. ['empty run name', (session) => {
  86. session.append('tool-workflow/run-start', { runId: WorkflowRunId('empty-name'), name: '' })
  87. }, /name must be a non-empty string/],
  88. ['non-string run name', (session) => {
  89. session.append('tool-workflow/run-start', { runId: WorkflowRunId('bad-name'), name: 1 as never })
  90. }, /name must be a non-empty string/],
  91. ['duplicate run', (session, runId) => {
  92. session.append('tool-workflow/run-start', { runId, name: 'again' })
  93. }, /repeats run/],
  94. ['missing run', (session) => {
  95. session.append('tool-workflow/agent-start', {
  96. runId: WorkflowRunId('missing'), seq: 1, label: 'bad', childId: SessionId('child'),
  97. })
  98. }, /no matching tool-workflow\/run-start/],
  99. ['non-positive member seq', (session, runId) => {
  100. session.append('tool-workflow/agent-start', {
  101. runId, seq: 0, label: 'bad', childId: SessionId('child'),
  102. })
  103. }, /positive safe integer/],
  104. ['non-integer member seq', (session, runId) => {
  105. session.append('tool-workflow/agent-start', {
  106. runId, seq: 1.5, label: 'bad', childId: SessionId('child'),
  107. })
  108. }, /positive safe integer/],
  109. ['non-string member label', (session, runId) => {
  110. session.append('tool-workflow/agent-start', {
  111. runId, seq: 1, label: 1 as never, childId: SessionId('child'),
  112. })
  113. }, /label must be a string/],
  114. ['non-string member phase', (session, runId) => {
  115. session.append('tool-workflow/agent-start', {
  116. runId, seq: 1, label: 'bad', phase: 1 as never, childId: SessionId('child'),
  117. })
  118. }, /phase must be a string/],
  119. ['empty child id', (session, runId) => {
  120. session.append('tool-workflow/agent-start', {
  121. runId, seq: 1, label: 'bad', childId: SessionId(''),
  122. })
  123. }, /childId must be a non-empty string/],
  124. ['duplicate member start', (session, runId) => {
  125. session.append('tool-workflow/agent-start', {
  126. runId, seq: 1, label: 'one', childId: SessionId('child'),
  127. })
  128. session.append('tool-workflow/agent-start', {
  129. runId, seq: 1, label: 'two', childId: SessionId('child-2'),
  130. })
  131. }, /repeats member seq/],
  132. ['invalid member outcome', (session, runId) => {
  133. session.append('tool-workflow/agent-start', {
  134. runId, seq: 1, label: 'one', childId: SessionId('child'),
  135. })
  136. session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'unknown' as never })
  137. }, /outcome unknown is invalid/],
  138. ['duplicate member end', (session, runId) => {
  139. session.append('tool-workflow/agent-start', {
  140. runId, seq: 1, label: 'one', childId: SessionId('child'),
  141. })
  142. session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
  143. session.append('tool-workflow/agent-end', { runId, seq: 1, outcome: 'completed' })
  144. }, /repeats member seq/],
  145. ['run end with an open member', (session, runId) => {
  146. session.append('tool-workflow/agent-start', {
  147. runId, seq: 1, label: 'open', childId: SessionId('child'),
  148. })
  149. session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
  150. }, /leaves member seq 1 open/],
  151. ['invalid run stop reason', (session, runId) => {
  152. session.append('tool-workflow/run-end', { runId, stopReason: 'unknown' as never })
  153. }, /stopReason unknown is invalid/],
  154. ['event after run end', (session, runId) => {
  155. session.append('tool-workflow/run-end', { runId, stopReason: 'completed' })
  156. session.append('tool-workflow/agent-start', {
  157. runId, seq: 1, label: 'late', childId: SessionId('child'),
  158. })
  159. }, /appears after/],
  160. ['unknown workflow event', (session, runId) => {
  161. appendRaw(session, 'tool-workflow/unknown', { runId })
  162. }, /unknown tool-workflow event type/],
  163. ]
  164. it.each(invalidCases)('rejects %s', async (_name, mutate, pattern) => {
  165. const ctx = await setup()
  166. const session = ctx.sessions.create()
  167. const runId = WorkflowRunId('run')
  168. session.append('tool-workflow/run-start', { runId, name: 'run' })
  169. expect(() => { mutate(session, runId) }).toThrow(pattern)
  170. })
  171. it('validates existing cold history while allowing an unfinished prefix', async () => {
  172. const ctx = new Context()
  173. await ctx.plugin(SessionStore)
  174. const valid = ctx.sessions.create(SessionId('workflow-record-cold-valid'))
  175. valid.append('tool-workflow/run-start', { runId: WorkflowRunId('valid'), name: 'valid' })
  176. valid.append('tool-workflow/agent-start', {
  177. runId: WorkflowRunId('valid'), seq: 1, label: 'open', childId: SessionId('child'),
  178. })
  179. await ctx.plugin(InvariantRegistry, { enabled: true })
  180. await expect(ctx.plugin(ToolWorkflowInvariant)).resolves.toBeDefined()
  181. const brokenCtx = new Context()
  182. await brokenCtx.plugin(SessionStore)
  183. const broken = brokenCtx.sessions.create(SessionId('workflow-record-cold-invalid'))
  184. broken.append('tool-workflow/run-start', { runId: WorkflowRunId('broken'), name: 'broken' })
  185. broken.append('tool-workflow/run-end', { runId: WorkflowRunId('broken'), stopReason: 'completed' })
  186. broken.append('tool-workflow/agent-start', {
  187. runId: WorkflowRunId('broken'), seq: 1, label: 'late', childId: SessionId('late'),
  188. })
  189. await brokenCtx.plugin(InvariantRegistry, { enabled: true })
  190. await expect(brokenCtx.plugin(ToolWorkflowInvariant)).rejects.toThrow(/appears after/)
  191. })
  192. })