execution-mode.spec.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146
  1. /**
  2. * Per-call concurrency classification: `ToolDefinition.isConcurrencySafe`,
  3. * `defineTool()`'s soft-validated forwarding of it, and the registry's
  4. * `executionMode(exec)` decision. Also proves the classifier never leaks into
  5. * the model-facing `schemas()` projection.
  6. */
  7. import { describe, expect, expectTypeOf, it } from 'vitest'
  8. import { Context } from 'cordis'
  9. import { CallId } from '@deepseek-ai/dsh-llm'
  10. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  11. import ToolRegistry, {
  12. defineTool,
  13. type ToolDefinition,
  14. type ToolExecutionInput,
  15. type ToolExecutionMode,
  16. } from '@deepseek-ai/dsh-tools'
  17. async function setup() {
  18. const ctx = new Context()
  19. await ctx.plugin(SystemPrompt)
  20. await ctx.plugin(ToolRegistry)
  21. return ctx
  22. }
  23. function exec(name: string, args: unknown): ToolExecutionInput {
  24. return { callId: CallId('c1'), name, arguments: args }
  25. }
  26. describe('ToolRegistry.executionMode', () => {
  27. it('returns parallel only when the registered tool declares isConcurrencySafe → true', async () => {
  28. const ctx = await setup()
  29. ctx.tools.register(defineTool({
  30. name: 'safe',
  31. description: 'parallel-safe',
  32. parameters: {},
  33. isConcurrencySafe: () => true,
  34. async execute() { return [] },
  35. }))
  36. expect(ctx.tools.executionMode(exec('safe', {}))).toEqual({ kind: 'parallel' })
  37. })
  38. it('defaults to exclusive for a tool with no isConcurrencySafe declaration', async () => {
  39. const ctx = await setup()
  40. ctx.tools.register(defineTool({
  41. name: 'plain',
  42. description: 'no declaration',
  43. parameters: {},
  44. async execute() { return [] },
  45. }))
  46. expect(ctx.tools.executionMode(exec('plain', {}))).toEqual({ kind: 'exclusive' })
  47. })
  48. it('returns exclusive for an unknown tool', async () => {
  49. const ctx = await setup()
  50. expect(ctx.tools.executionMode(exec('nonexistent', {}))).toEqual({ kind: 'exclusive' })
  51. })
  52. it('returns exclusive when the classifier returns false for these args', async () => {
  53. const ctx = await setup()
  54. // Input-sensitive: safe to read, unsafe to write — the same tool differs by args.
  55. ctx.tools.register(defineTool({
  56. name: 'rw',
  57. description: 'read or write',
  58. parameters: { mode: { type: 'string', required: true } },
  59. isConcurrencySafe: args => args.mode === 'read',
  60. async execute() { return [] },
  61. }))
  62. expect(ctx.tools.executionMode(exec('rw', { mode: 'read' }))).toEqual({ kind: 'parallel' })
  63. expect(ctx.tools.executionMode(exec('rw', { mode: 'write' }))).toEqual({ kind: 'exclusive' })
  64. })
  65. it('a defineTool classifier soft-fails to exclusive on invalid args (no ToolArgsError)', async () => {
  66. const ctx = await setup()
  67. // The typed classifier would read args.mode, but the required arg is missing:
  68. // soft validation returns false (exclusive) rather than throwing, matching the
  69. // presenter pattern. Executing the same bad args WOULD raise ToolArgsError.
  70. ctx.tools.register(defineTool({
  71. name: 'needs-mode',
  72. description: 'requires mode',
  73. parameters: { mode: { type: 'string', required: true } },
  74. isConcurrencySafe: () => true,
  75. async execute() { return [] },
  76. }))
  77. expect(ctx.tools.executionMode(exec('needs-mode', {}))).toEqual({ kind: 'exclusive' })
  78. })
  79. it('a thrown classifier fails closed to exclusive (raw definition)', async () => {
  80. const ctx = await setup()
  81. // A hand-rolled ToolDefinition (not via defineTool) whose check throws.
  82. const raw: ToolDefinition = {
  83. name: 'thrower',
  84. description: 'classifier throws',
  85. parameters: { type: 'object', properties: {} },
  86. isConcurrencySafe() { throw new Error('boom') },
  87. async execute() { return [] },
  88. }
  89. ctx.tools.register(raw)
  90. expect(ctx.tools.executionMode(exec('thrower', {}))).toEqual({ kind: 'exclusive' })
  91. })
  92. it('a truthy non-boolean classifier result fails closed to exclusive (raw definition)', async () => {
  93. const ctx = await setup()
  94. const raw = {
  95. name: 'truthy',
  96. description: 'classifier returns a truthy string',
  97. parameters: { type: 'object', properties: {} },
  98. isConcurrencySafe() { return 'yes' },
  99. async execute() { return [] },
  100. } as unknown as ToolDefinition
  101. ctx.tools.register(raw)
  102. expect(ctx.tools.executionMode(exec('truthy', {}))).toEqual({ kind: 'exclusive' })
  103. })
  104. it('a raw definition (no defineTool) receives the raw parsed value', async () => {
  105. const ctx = await setup()
  106. let seen: unknown
  107. ctx.tools.register({
  108. name: 'raw-safe',
  109. description: 'raw',
  110. parameters: { type: 'object', properties: {} },
  111. isConcurrencySafe(args) { seen = args; return true },
  112. async execute() { return [] },
  113. })
  114. expect(ctx.tools.executionMode(exec('raw-safe', { anything: 1 }))).toEqual({ kind: 'parallel' })
  115. expect(seen).toEqual({ anything: 1 })
  116. })
  117. it('isConcurrencySafe never reaches the model-facing schemas() projection', async () => {
  118. const ctx = await setup()
  119. ctx.tools.register(defineTool({
  120. name: 'safe',
  121. description: 'parallel-safe',
  122. parameters: { x: { type: 'string', required: true } },
  123. isConcurrencySafe: () => true,
  124. async execute() { return [] },
  125. }))
  126. const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
  127. expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
  128. expect(schema.isConcurrencySafe).toBeUndefined()
  129. })
  130. it('ToolExecutionMode is the object-tagged union', () => {
  131. expectTypeOf<ToolExecutionMode>().toEqualTypeOf<{ kind: 'parallel' } | { kind: 'exclusive' }>()
  132. })
  133. })