timeout-policy.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  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, TOOL_ABORTED, 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. const testToolSignal = new AbortController().signal
  17. /** Mount the registry + the zero-config timeout-policy enforcer. */
  18. async function setup() {
  19. const ctx = new Context()
  20. await ctx.plugin(SystemPrompt)
  21. await ctx.plugin(ToolRegistry)
  22. await ctx.plugin(timeoutPolicy)
  23. return ctx
  24. }
  25. /** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
  26. const cooperativeTool = defineTool({
  27. name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
  28. execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
  29. const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
  30. if (exec.signal.aborted) return Promise.resolve(done)
  31. return new Promise((resolve) => { exec.signal.addEventListener('abort', () => { resolve(done) }) })
  32. },
  33. })
  34. /** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
  35. const abortThrowingTool = defineTool({
  36. name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
  37. execute(_args, exec): Promise<never> {
  38. if (exec.signal.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
  39. return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
  40. },
  41. })
  42. describe('timeout-policy delegation (unconfigured / fast)', () => {
  43. it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
  44. const ctx = await setup()
  45. let seenSignal: AbortSignal | undefined
  46. ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {},
  47. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  48. const upstream = new AbortController().signal
  49. const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  50. expect(result.isError).toBe(false)
  51. expect(seenSignal).toBe(upstream)
  52. })
  53. it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
  54. const ctx = await setup()
  55. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
  56. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  57. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} })
  58. expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
  59. })
  60. it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
  61. const ctx = await setup()
  62. let seenSignal: AbortSignal | undefined
  63. ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
  64. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  65. const upstream = new AbortController().signal
  66. await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  67. expect(seenSignal).toBeDefined()
  68. expect(seenSignal).not.toBe(upstream)
  69. })
  70. })
  71. describe('timeout-policy signal restoration', () => {
  72. it('restores the caller signal for post-execute after wrapping', async () => {
  73. const ctx = await setup()
  74. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
  75. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  76. let postSignal: AbortSignal | undefined | 'unset' = 'unset'
  77. ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
  78. const upstream = new AbortController().signal
  79. await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
  80. expect(postSignal).toBe(upstream)
  81. })
  82. })
  83. describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
  84. beforeEach(() => { vi.useFakeTimers() })
  85. afterEach(() => { vi.useRealTimers() })
  86. it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
  87. const ctx = await setup()
  88. ctx.tools.register(cooperativeTool)
  89. const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} })
  90. await vi.advanceTimersByTimeAsync(150)
  91. const result = await pending
  92. expect(result).toEqual({
  93. content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
  94. isError: true,
  95. error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
  96. })
  97. })
  98. it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
  99. const ctx = await setup()
  100. ctx.tools.register(abortThrowingTool)
  101. const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} })
  102. await vi.advanceTimersByTimeAsync(150)
  103. const result = await pending
  104. expect(result.isError).toBe(true)
  105. expect(result.error).toEqual({ name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' })
  106. expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
  107. })
  108. it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => {
  109. const ctx = await setup()
  110. const entered = Promise.withResolvers<undefined>()
  111. ctx.tools.register(defineTool({
  112. name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
  113. execute(_args, exec) {
  114. entered.resolve(undefined)
  115. const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
  116. if (exec.signal.aborted) return Promise.resolve(done)
  117. return new Promise((resolve) => {
  118. exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true })
  119. })
  120. },
  121. }))
  122. const upstream = new AbortController()
  123. const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
  124. await entered.promise
  125. upstream.abort('user cancelled')
  126. await vi.advanceTimersByTimeAsync(0)
  127. const result = await pending
  128. expect(result.isError).toBe(true)
  129. expect(result.error).toEqual({ name: 'AbortError', code: TOOL_ABORTED })
  130. expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
  131. })
  132. it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => {
  133. const ctx = await setup()
  134. const sawAbort = Promise.withResolvers<undefined>()
  135. const releaseCleanup = Promise.withResolvers<undefined>()
  136. ctx.tools.register(defineTool({
  137. name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
  138. async execute(_args, exec) {
  139. if (!exec.signal.aborted) {
  140. await new Promise<undefined>((resolve) => {
  141. exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
  142. })
  143. }
  144. sawAbort.resolve(undefined)
  145. await releaseCleanup.promise
  146. return [{ type: 'text' as const, text: 'cleanup complete' }]
  147. },
  148. }))
  149. const upstream = new AbortController()
  150. const pending = ctx.tools.execute({
  151. callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal,
  152. })
  153. await vi.advanceTimersByTimeAsync(100)
  154. await sawAbort.promise
  155. upstream.abort('too late to replace timeout')
  156. releaseCleanup.resolve(undefined)
  157. await expect(pending).resolves.toMatchObject({
  158. isError: true,
  159. error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
  160. })
  161. })
  162. })
  163. describe('timeout-policy contract', () => {
  164. it('exposes the owned code constant', () => {
  165. expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
  166. })
  167. })
  168. describe('timeout-policy disposal (HMR safety)', () => {
  169. it('removes its tools/execute listener when the plugin fiber disposes', async () => {
  170. const ctx = new Context()
  171. await ctx.plugin(SystemPrompt)
  172. await ctx.plugin(ToolRegistry)
  173. let seenSignal: AbortSignal | undefined
  174. ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
  175. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  176. const fiber = await ctx.plugin(timeoutPolicy)
  177. const upstream = new AbortController().signal
  178. await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  179. expect(seenSignal).not.toBe(upstream)
  180. await fiber.dispose()
  181. await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
  182. expect(seenSignal).toBe(upstream)
  183. })
  184. })
  185. describe('dsh-timeout-policy real-load-path guard', () => {
  186. it('has no default export and keeps name/inject through unwrapExports', () => {
  187. expect('default' in timeoutPolicy).toBe(false)
  188. const loader = Object.create(Loader.prototype) as Loader
  189. const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
  190. expect(unwrapped).toBe(timeoutPolicy)
  191. expect(unwrapped.name).toBe('timeout-policy')
  192. expect(unwrapped.inject).toEqual(['tools'])
  193. expect(typeof unwrapped.apply).toBe('function')
  194. })
  195. it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
  196. const ctx = new Context()
  197. await ctx.plugin(SystemPrompt)
  198. await ctx.plugin(ToolRegistry)
  199. ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
  200. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  201. const loader = Object.create(Loader.prototype) as Loader
  202. const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
  203. const fiber = await ctx.plugin(unwrapped)
  204. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
  205. expect(result.isError).toBe(false)
  206. await fiber.dispose()
  207. })
  208. })