timeout-policy.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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, { defineContentToolFixture, 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 = defineContentToolFixture({
  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 = defineContentToolFixture({
  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(defineContentToolFixture({ 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(defineContentToolFixture({ 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({
  59. content: [{ type: 'text', text: 'ok' }],
  60. isError: false,
  61. value: [{ type: 'text', text: 'ok' }],
  62. })
  63. })
  64. it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
  65. const ctx = await setup()
  66. let seenSignal: AbortSignal | undefined
  67. ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
  68. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  69. const upstream = new AbortController().signal
  70. await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  71. expect(seenSignal).toBeDefined()
  72. expect(seenSignal).not.toBe(upstream)
  73. })
  74. })
  75. describe('timeout-policy signal restoration', () => {
  76. it('restores the caller signal for post-execute after wrapping', async () => {
  77. const ctx = await setup()
  78. ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
  79. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  80. let postSignal: AbortSignal | undefined | 'unset' = 'unset'
  81. ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
  82. const upstream = new AbortController().signal
  83. await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
  84. expect(postSignal).toBe(upstream)
  85. })
  86. })
  87. describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
  88. beforeEach(() => { vi.useFakeTimers() })
  89. afterEach(() => { vi.useRealTimers() })
  90. it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
  91. const ctx = await setup()
  92. ctx.tools.register(cooperativeTool)
  93. const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} })
  94. await vi.advanceTimersByTimeAsync(150)
  95. const result = await pending
  96. expect(result).toEqual({
  97. content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
  98. isError: true,
  99. error: {
  100. message: 'tool call timed out after 100ms',
  101. info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
  102. },
  103. })
  104. })
  105. it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
  106. const ctx = await setup()
  107. ctx.tools.register(abortThrowingTool)
  108. const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} })
  109. await vi.advanceTimersByTimeAsync(150)
  110. const result = await pending
  111. expect(result.isError).toBe(true)
  112. expect(result.error).toEqual({
  113. message: 'tool call timed out after 100ms',
  114. info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
  115. })
  116. expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
  117. })
  118. it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => {
  119. const ctx = await setup()
  120. const entered = Promise.withResolvers<undefined>()
  121. ctx.tools.register(defineContentToolFixture({
  122. name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
  123. execute(_args, exec) {
  124. entered.resolve(undefined)
  125. const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
  126. if (exec.signal.aborted) return Promise.resolve(done)
  127. return new Promise((resolve) => {
  128. exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true })
  129. })
  130. },
  131. }))
  132. const upstream = new AbortController()
  133. const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
  134. await entered.promise
  135. upstream.abort('user cancelled')
  136. await vi.advanceTimersByTimeAsync(0)
  137. const result = await pending
  138. expect(result.isError).toBe(true)
  139. expect(result.error).toEqual({
  140. message: 'tool call aborted',
  141. info: { name: 'AbortError', code: TOOL_ABORTED },
  142. })
  143. expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
  144. })
  145. it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => {
  146. const ctx = await setup()
  147. const sawAbort = Promise.withResolvers<undefined>()
  148. const releaseCleanup = Promise.withResolvers<undefined>()
  149. ctx.tools.register(defineContentToolFixture({
  150. name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
  151. async execute(_args, exec) {
  152. if (!exec.signal.aborted) {
  153. await new Promise<undefined>((resolve) => {
  154. exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
  155. })
  156. }
  157. sawAbort.resolve(undefined)
  158. await releaseCleanup.promise
  159. return [{ type: 'text' as const, text: 'cleanup complete' }]
  160. },
  161. }))
  162. const upstream = new AbortController()
  163. const pending = ctx.tools.execute({
  164. callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal,
  165. })
  166. await vi.advanceTimersByTimeAsync(100)
  167. await sawAbort.promise
  168. upstream.abort('too late to replace timeout')
  169. releaseCleanup.resolve(undefined)
  170. await expect(pending).resolves.toMatchObject({
  171. isError: true,
  172. error: {
  173. message: 'tool call timed out after 100ms',
  174. info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
  175. },
  176. })
  177. })
  178. })
  179. describe('timeout-policy contract', () => {
  180. it('exposes the owned code constant', () => {
  181. expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
  182. })
  183. })
  184. describe('timeout-policy disposal (HMR safety)', () => {
  185. it('removes its tools/execute listener when the plugin fiber disposes', async () => {
  186. const ctx = new Context()
  187. await ctx.plugin(SystemPrompt)
  188. await ctx.plugin(ToolRegistry)
  189. let seenSignal: AbortSignal | undefined
  190. ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
  191. async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
  192. const fiber = await ctx.plugin(timeoutPolicy)
  193. const upstream = new AbortController().signal
  194. await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
  195. expect(seenSignal).not.toBe(upstream)
  196. await fiber.dispose()
  197. await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
  198. expect(seenSignal).toBe(upstream)
  199. })
  200. })
  201. describe('dsh-timeout-policy real-load-path guard', () => {
  202. it('has no default export and keeps name/inject through unwrapExports', () => {
  203. expect('default' in timeoutPolicy).toBe(false)
  204. const loader = Object.create(Loader.prototype) as Loader
  205. const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
  206. expect(unwrapped).toBe(timeoutPolicy)
  207. expect(unwrapped.name).toBe('timeout-policy')
  208. expect(unwrapped.inject).toEqual(['tools'])
  209. expect(typeof unwrapped.apply).toBe('function')
  210. })
  211. it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
  212. const ctx = new Context()
  213. await ctx.plugin(SystemPrompt)
  214. await ctx.plugin(ToolRegistry)
  215. ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
  216. async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
  217. const loader = Object.create(Loader.prototype) as Loader
  218. const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
  219. const fiber = await ctx.plugin(unwrapped)
  220. const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
  221. expect(result.isError).toBe(false)
  222. await fiber.dispose()
  223. })
  224. })