| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244 |
- /**
- * Unit + real-load-path coverage for @deepseek-ai/dsh-timeout-policy. The
- * timeout-wins cases drive the deadline under fake timers (deterministic — no
- * wall-clock race) and use a COOPERATIVE tool that settles only when its
- * `exec.signal` aborts, mirroring how a real capability forwards the signal and
- * reaches quiescence.
- */
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
- import { Context } from 'cordis'
- import Loader from '@cordisjs/plugin-loader'
- import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
- import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
- import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools'
- import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
- import { TOOL_TIMEOUT } from '@deepseek-ai/dsh-timeout-policy'
- const testToolSignal = new AbortController().signal
- /** Mount the registry + the zero-config timeout-policy enforcer. */
- async function setup() {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- await ctx.plugin(timeoutPolicy)
- return ctx
- }
- /** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
- const cooperativeTool = defineContentToolFixture({
- name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
- execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
- const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
- if (exec.signal.aborted) return Promise.resolve(done)
- return new Promise((resolve) => { exec.signal.addEventListener('abort', () => { resolve(done) }) })
- },
- })
- /** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
- const abortThrowingTool = defineContentToolFixture({
- name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
- execute(_args, exec): Promise<never> {
- if (exec.signal.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
- return new Promise((_resolve, reject) => { exec.signal.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
- },
- })
- describe('timeout-policy delegation (unconfigured / fast)', () => {
- it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
- const ctx = await setup()
- let seenSignal: AbortSignal | undefined
- ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {},
- async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
- const upstream = new AbortController().signal
- const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
- expect(result.isError).toBe(false)
- expect(seenSignal).toBe(upstream)
- })
- it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
- const ctx = await setup()
- ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
- const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} })
- expect(result).toEqual({
- content: [{ type: 'text', text: 'ok' }],
- isError: false,
- value: [{ type: 'text', text: 'ok' }],
- })
- })
- it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
- const ctx = await setup()
- let seenSignal: AbortSignal | undefined
- ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
- async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
- const upstream = new AbortController().signal
- await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
- expect(seenSignal).toBeDefined()
- expect(seenSignal).not.toBe(upstream)
- })
- })
- describe('timeout-policy signal restoration', () => {
- it('restores the caller signal for post-execute after wrapping', async () => {
- const ctx = await setup()
- ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
- let postSignal: AbortSignal | undefined | 'unset' = 'unset'
- ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
- const upstream = new AbortController().signal
- await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
- expect(postSignal).toBe(upstream)
- })
- })
- describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
- beforeEach(() => { vi.useFakeTimers() })
- afterEach(() => { vi.useRealTimers() })
- it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
- const ctx = await setup()
- ctx.tools.register(cooperativeTool)
- const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'slow', arguments: {} })
- await vi.advanceTimersByTimeAsync(150)
- const result = await pending
- expect(result).toEqual({
- content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
- isError: true,
- error: {
- message: 'tool call timed out after 100ms',
- info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
- },
- })
- })
- it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
- const ctx = await setup()
- ctx.tools.register(abortThrowingTool)
- const pending = ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'aborter', arguments: {} })
- await vi.advanceTimersByTimeAsync(150)
- const result = await pending
- expect(result.isError).toBe(true)
- expect(result.error).toEqual({
- message: 'tool call timed out after 100ms',
- info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
- })
- expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
- })
- it('preserves registry ABORTED when the caller aborts first (upstream cancel, not our timeout)', async () => {
- const ctx = await setup()
- const entered = Promise.withResolvers<undefined>()
- ctx.tools.register(defineContentToolFixture({
- name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
- execute(_args, exec) {
- entered.resolve(undefined)
- const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
- if (exec.signal.aborted) return Promise.resolve(done)
- return new Promise((resolve) => {
- exec.signal.addEventListener('abort', () => { resolve(done) }, { once: true })
- })
- },
- }))
- const upstream = new AbortController()
- const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
- await entered.promise
- upstream.abort('user cancelled')
- await vi.advanceTimersByTimeAsync(0)
- const result = await pending
- expect(result.isError).toBe(true)
- expect(result.error).toEqual({
- message: 'tool call aborted',
- info: { name: 'AbortError', code: TOOL_ABORTED },
- })
- expect(result.content[0]).toMatchObject({ text: 'Error: tool call aborted' })
- })
- it('preserves TOOL_TIMEOUT when the deadline wins before a later caller abort', async () => {
- const ctx = await setup()
- const sawAbort = Promise.withResolvers<undefined>()
- const releaseCleanup = Promise.withResolvers<undefined>()
- ctx.tools.register(defineContentToolFixture({
- name: 'slow-cleanup', description: 'settles after abort cleanup', parameters: {}, timeoutMs: 100,
- async execute(_args, exec) {
- if (!exec.signal.aborted) {
- await new Promise<undefined>((resolve) => {
- exec.signal.addEventListener('abort', () => { resolve(undefined) }, { once: true })
- })
- }
- sawAbort.resolve(undefined)
- await releaseCleanup.promise
- return [{ type: 'text' as const, text: 'cleanup complete' }]
- },
- }))
- const upstream = new AbortController()
- const pending = ctx.tools.execute({
- callId: CallId('timeout-first'), name: 'slow-cleanup', arguments: {}, signal: upstream.signal,
- })
- await vi.advanceTimersByTimeAsync(100)
- await sawAbort.promise
- upstream.abort('too late to replace timeout')
- releaseCleanup.resolve(undefined)
- await expect(pending).resolves.toMatchObject({
- isError: true,
- error: {
- message: 'tool call timed out after 100ms',
- info: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
- },
- })
- })
- })
- describe('timeout-policy contract', () => {
- it('exposes the owned code constant', () => {
- expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
- })
- })
- describe('timeout-policy disposal (HMR safety)', () => {
- it('removes its tools/execute listener when the plugin fiber disposes', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- let seenSignal: AbortSignal | undefined
- ctx.tools.register(defineContentToolFixture({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
- async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
- const fiber = await ctx.plugin(timeoutPolicy)
- const upstream = new AbortController().signal
- await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
- expect(seenSignal).not.toBe(upstream)
- await fiber.dispose()
- await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
- expect(seenSignal).toBe(upstream)
- })
- })
- describe('dsh-timeout-policy real-load-path guard', () => {
- it('has no default export and keeps name/inject through unwrapExports', () => {
- expect('default' in timeoutPolicy).toBe(false)
- const loader = Object.create(Loader.prototype) as Loader
- const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
- expect(unwrapped).toBe(timeoutPolicy)
- expect(unwrapped.name).toBe('timeout-policy')
- expect(unwrapped.inject).toEqual(['tools'])
- expect(typeof unwrapped.apply).toBe('function')
- })
- it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
- const ctx = new Context()
- await ctx.plugin(SystemPrompt)
- await ctx.plugin(ToolRegistry)
- ctx.tools.register(defineContentToolFixture({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
- async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
- const loader = Object.create(Loader.prototype) as Loader
- const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
- const fiber = await ctx.plugin(unwrapped)
- const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecutionInput)
- expect(result.isError).toBe(false)
- await fiber.dispose()
- })
- })
|