invariant.spec.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  1. import { describe, expect, it } from 'vitest'
  2. import { Context } from 'cordis'
  3. import { scopeTarget } from '@deepseek-ai/dsh-scope'
  4. import { CallId } from '@deepseek-ai/dsh-llm'
  5. import SessionStore from '@deepseek-ai/dsh-session'
  6. import type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
  7. import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
  8. import InvariantService from '@deepseek-ai/dsh-invariants'
  9. const testToolSignal = new AbortController().signal
  10. async function setup(): Promise<Context> {
  11. const ctx = new Context()
  12. await ctx.plugin(SessionStore)
  13. await ctx.plugin(InvariantService)
  14. await ctx.plugin(ToolsInvariant)
  15. return ctx
  16. }
  17. const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
  18. token: Symbol('tool') as ToolExecutionToken,
  19. callId: CallId('call-1'),
  20. name: 'echo',
  21. arguments: Object.freeze({ text: 'hi' }),
  22. ...overrides,
  23. signal: overrides.signal ?? testToolSignal,
  24. })
  25. const outcome = (): ToolExecutionResult => Object.freeze({
  26. content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
  27. isError: false,
  28. value: null,
  29. })
  30. function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
  31. ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result)
  32. }
  33. async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise<void> {
  34. if (name === 'tools/pre-execute') {
  35. await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const }))
  36. } else {
  37. await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome()))
  38. }
  39. }
  40. describe('tool-pipeline invariants', () => {
  41. it('accepts dispatch and denial stage orders with frozen results', async () => {
  42. const ctx = await setup()
  43. const dispatched = execution()
  44. await stage(ctx, 'tools/pre-execute', dispatched)
  45. await stage(ctx, 'tools/execute', dispatched)
  46. await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
  47. Object.freeze(dispatched)
  48. emitResult(ctx, dispatched, outcome())
  49. const denied = execution({ callId: CallId('call-2') })
  50. await stage(ctx, 'tools/pre-execute', denied)
  51. await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
  52. Object.freeze(denied)
  53. emitResult(ctx, denied, outcome())
  54. ctx.emit('tools/change')
  55. })
  56. it('rejects repeated and out-of-order pipeline stages', async () => {
  57. const ctx = await setup()
  58. const exec = execution()
  59. await stage(ctx, 'tools/pre-execute', exec)
  60. await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/)
  61. const noPre = execution({ callId: CallId('call-2') })
  62. await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/)
  63. expect(() => ctx.waterfall(
  64. ctx as never, 'tools/post-execute', noPre, outcome(),
  65. () => Promise.resolve({ kind: 'accept' as const }),
  66. )).toThrow(/must follow tools\/pre-execute or tools\/execute/)
  67. })
  68. it('rejects mutable or anonymous final snapshots', async () => {
  69. const ctx = await setup()
  70. expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
  71. const exec = Object.freeze(execution())
  72. expect(() => { emitResult(ctx, exec, { content: [], isError: false, value: null }) })
  73. .toThrow(/outcome and content must be frozen/)
  74. const anonymous = Object.freeze(execution({ name: '' }))
  75. expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/)
  76. })
  77. it('requires code-dispatch records to be turn-enclosed', async () => {
  78. const ctx = await setup()
  79. const session = ctx.sessions.create()
  80. const data = {
  81. parentCallId: CallId('parent'),
  82. subCallId: CallId('child'),
  83. name: 'echo',
  84. arguments: {},
  85. }
  86. expect(() => session.append('tool/code-dispatch-start', data)).toThrow(/outside any open turn/)
  87. session.append('turn/start', { turn: 1 })
  88. expect(() => session.append('tool/code-dispatch-start', data)).not.toThrow()
  89. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  90. })
  91. it('replays enclosed code-dispatch records on late registration', async () => {
  92. const ctx = new Context()
  93. await ctx.plugin(SessionStore)
  94. const session = ctx.sessions.create()
  95. session.append('turn/start', { turn: 1 })
  96. session.append('tool/code-dispatch', {
  97. parentCallId: CallId('parent'),
  98. subCallId: CallId('child'),
  99. name: 'echo',
  100. arguments: {},
  101. isError: false,
  102. content: [{ type: 'text', text: 'ok' }],
  103. })
  104. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  105. await ctx.plugin(InvariantService)
  106. await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).resolves.toBeUndefined()
  107. })
  108. it('rejects an unenclosed code-dispatch record on late registration', async () => {
  109. const ctx = new Context()
  110. await ctx.plugin(SessionStore)
  111. ctx.sessions.create().append('tool/code-dispatch-start', {
  112. parentCallId: CallId('parent'),
  113. subCallId: CallId('child'),
  114. name: 'echo',
  115. arguments: {},
  116. })
  117. await ctx.plugin(InvariantService)
  118. await expect(ctx.plugin(ToolsInvariant).then(() => undefined)).rejects.toThrow(/outside any open turn/)
  119. })
  120. })