timeout-policy.spec.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190
  1. /**
  2. * Unit + real-load-path coverage for @deepseek-ai/dsh-timeout-policy. The
  3. * timeout-wins cases drive the deadline under fake timers (deterministic — no
  4. * wall-clock race) and use a COOPERATIVE tool that settles only when its
  5. * `exec.signal` aborts, mirroring how a real capability forwards the signal and
  6. * reaches quiescence.
  7. */
  8. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
  9. import { Context } from 'cordis'
  10. import Loader from '@cordisjs/plugin-loader'
  11. import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
  12. import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
  13. import ToolRegistry, { defineTool, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
  14. import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
  15. import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
  16. /** Mount the registry + the zero-config timeout-policy enforcer. */
  17. async function setup() {
  18. const ctx = new Context()
  19. await ctx.plugin(SystemPrompt)
  20. await ctx.plugin(ToolRegistry)
  21. await ctx.plugin(timeoutPolicy)
  22. return ctx
  23. }
  24. /** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
  25. const cooperativeTool = defineTool({
  26. name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
  27. execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
  28. const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
  29. if (exec.signal?.aborted) return Promise.resolve(done)
  30. return new Promise((resolve) => { exec.signal?.addEventListener('abort', () => { resolve(done) }) })
  31. },
  32. })
  33. /** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
  34. const abortThrowingTool = defineTool({
  35. name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
  36. execute(_args, exec): Promise<never> {
  37. if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
  38. return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
  39. },
  40. })
  41. describe('timeout-policy delegation (unconfigured / fast)', () => {
  42. it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
  43. const ctx = await setup()
  44. let seenSignal: AbortSignal | undefined
  45. ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {},
  46. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  47. const upstream = new AbortController().signal
  48. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  49. expect(result.isError).toBe(false)
  50. expect(seenSignal).toBe(upstream)
  51. })
  52. it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
  53. const ctx = await setup()
  54. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
  55. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  56. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
  57. expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
  58. })
  59. it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
  60. const ctx = await setup()
  61. let seenSignal: AbortSignal | undefined
  62. ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
  63. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  64. const upstream = new AbortController().signal
  65. await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  66. expect(seenSignal).toBeDefined()
  67. expect(seenSignal).not.toBe(upstream)
  68. })
  69. })
  70. describe('timeout-policy signal restoration', () => {
  71. it('restores the caller signal for post-execute after wrapping', async () => {
  72. const ctx = await setup()
  73. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
  74. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  75. let postSignal: AbortSignal | undefined | 'unset' = 'unset'
  76. ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
  77. const upstream = new AbortController().signal
  78. await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
  79. expect(postSignal).toBe(upstream)
  80. })
  81. it('deletes exec.signal again when the caller passed none', async () => {
  82. const ctx = await setup()
  83. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
  84. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  85. let hadSignal: boolean | undefined
  86. ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() })
  87. await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
  88. expect(hadSignal).toBe(false)
  89. })
  90. })
  91. describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
  92. beforeEach(() => { vi.useFakeTimers() })
  93. afterEach(() => { vi.useRealTimers() })
  94. it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
  95. const ctx = await setup()
  96. ctx.tools.register(cooperativeTool)
  97. const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} })
  98. await vi.advanceTimersByTimeAsync(150)
  99. const result = await pending
  100. expect(result).toEqual({
  101. content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
  102. isError: true,
  103. error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
  104. })
  105. })
  106. it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
  107. const ctx = await setup()
  108. ctx.tools.register(abortThrowingTool)
  109. const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'aborter', arguments: {} })
  110. await vi.advanceTimersByTimeAsync(150)
  111. const result = await pending
  112. expect(result.isError).toBe(true)
  113. expect(result.error).toEqual({ name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' })
  114. expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
  115. })
  116. it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => {
  117. const ctx = await setup()
  118. ctx.tools.register(cooperativeTool)
  119. const upstream = new AbortController()
  120. const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
  121. upstream.abort('user cancelled')
  122. await vi.advanceTimersByTimeAsync(0)
  123. const result = await pending
  124. expect(result.isError).toBe(false)
  125. expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' })
  126. })
  127. })
  128. describe('timeout-policy contract', () => {
  129. it('exposes the owned code constant', () => {
  130. expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
  131. })
  132. })
  133. describe('timeout-policy disposal (HMR safety)', () => {
  134. it('removes its tools/execute listener when the plugin fiber disposes', async () => {
  135. const ctx = new Context()
  136. await ctx.plugin(SystemPrompt)
  137. await ctx.plugin(ToolRegistry)
  138. let seenSignal: AbortSignal | undefined
  139. ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
  140. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  141. const fiber = await ctx.plugin(timeoutPolicy)
  142. const upstream = new AbortController().signal
  143. await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  144. expect(seenSignal).not.toBe(upstream)
  145. await fiber.dispose()
  146. await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
  147. expect(seenSignal).toBe(upstream)
  148. })
  149. })
  150. describe('dsh-timeout-policy real-load-path guard', () => {
  151. it('has no default export and keeps name/inject through unwrapExports', () => {
  152. expect('default' in timeoutPolicy).toBe(false)
  153. const loader = Object.create(Loader.prototype) as Loader
  154. const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
  155. expect(unwrapped).toBe(timeoutPolicy)
  156. expect(unwrapped.name).toBe('timeout-policy')
  157. expect(unwrapped.inject).toEqual(['tools'])
  158. expect(typeof unwrapped.apply).toBe('function')
  159. })
  160. it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
  161. const ctx = new Context()
  162. await ctx.plugin(SystemPrompt)
  163. await ctx.plugin(ToolRegistry)
  164. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
  165. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  166. const loader = Object.create(Loader.prototype) as Loader
  167. const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
  168. const fiber = await ctx.plugin(unwrapped)
  169. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
  170. expect(result.isError).toBe(false)
  171. await fiber.dispose()
  172. })
  173. })