invariant.spec.ts 3.6 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
  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 type { ToolExecution, ToolExecutionResult, ToolExecutionToken } from '@deepseek-ai/dsh-tools'
  6. import * as ToolsInvariant from '@deepseek-ai/dsh-tools/invariant'
  7. import InvariantService from '@deepseek-ai/dsh-invariants'
  8. const testToolSignal = new AbortController().signal
  9. async function setup(): Promise<Context> {
  10. const ctx = new Context()
  11. await ctx.plugin(InvariantService)
  12. await ctx.plugin(ToolsInvariant)
  13. return ctx
  14. }
  15. const execution = (overrides: Partial<ToolExecution> = {}): ToolExecution => ({
  16. token: Symbol('tool') as ToolExecutionToken,
  17. callId: CallId('call-1'),
  18. name: 'echo',
  19. arguments: Object.freeze({ text: 'hi' }),
  20. ...overrides,
  21. signal: overrides.signal ?? testToolSignal,
  22. })
  23. const outcome = (): ToolExecutionResult => Object.freeze({
  24. content: Object.freeze([{ type: 'text' as const, text: 'ok' }]) as never,
  25. isError: false,
  26. value: null,
  27. })
  28. function emitResult(ctx: Context, exec: ToolExecution, result: ToolExecutionResult): void {
  29. ctx.emit(scopeTarget(ctx as never, undefined), 'tools/result', exec, result)
  30. }
  31. async function stage(ctx: Context, name: 'tools/pre-execute' | 'tools/execute', exec: ToolExecution): Promise<void> {
  32. if (name === 'tools/pre-execute') {
  33. await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve({ kind: 'allow' as const }))
  34. } else {
  35. await ctx.waterfall(ctx as never, name, exec, () => Promise.resolve(outcome()))
  36. }
  37. }
  38. describe('tool-pipeline invariants', () => {
  39. it('accepts dispatch and denial stage orders with frozen results', async () => {
  40. const ctx = await setup()
  41. const dispatched = execution()
  42. await stage(ctx, 'tools/pre-execute', dispatched)
  43. await stage(ctx, 'tools/execute', dispatched)
  44. await ctx.waterfall(ctx as never, 'tools/post-execute', dispatched, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
  45. Object.freeze(dispatched)
  46. emitResult(ctx, dispatched, outcome())
  47. const denied = execution({ callId: CallId('call-2') })
  48. await stage(ctx, 'tools/pre-execute', denied)
  49. await ctx.waterfall(ctx as never, 'tools/post-execute', denied, outcome(), () => Promise.resolve({ kind: 'accept' as const }))
  50. Object.freeze(denied)
  51. emitResult(ctx, denied, outcome())
  52. ctx.emit('tools/change')
  53. })
  54. it('rejects repeated and out-of-order pipeline stages', async () => {
  55. const ctx = await setup()
  56. const exec = execution()
  57. await stage(ctx, 'tools/pre-execute', exec)
  58. await expect(stage(ctx, 'tools/pre-execute', exec)).rejects.toThrow(/repeated/)
  59. const noPre = execution({ callId: CallId('call-2') })
  60. await expect(stage(ctx, 'tools/execute', noPre)).rejects.toThrow(/must follow tools\/pre-execute/)
  61. expect(() => ctx.waterfall(
  62. ctx as never, 'tools/post-execute', noPre, outcome(),
  63. () => Promise.resolve({ kind: 'accept' as const }),
  64. )).toThrow(/must follow tools\/pre-execute or tools\/execute/)
  65. })
  66. it('rejects mutable or anonymous final snapshots', async () => {
  67. const ctx = await setup()
  68. expect(() => { emitResult(ctx, execution(), outcome()) }).toThrow(/execution must be frozen/)
  69. const exec = Object.freeze(execution())
  70. expect(() => { emitResult(ctx, exec, { content: [], isError: false, value: null }) })
  71. .toThrow(/outcome and content must be frozen/)
  72. const anonymous = Object.freeze(execution({ name: '' }))
  73. expect(() => { emitResult(ctx, anonymous, outcome()) }).toThrow(/non-empty name and callId/)
  74. })
  75. })