UNPKG

ai-functions

Version:

Core AI primitives for building intelligent applications

1,041 lines (874 loc) 31.1 kB
/** * Tests for agentic tool orchestration * * These tests cover multi-turn model→tools→model loops for complex AI workflows. * Tests are written first (TDD RED phase) - implementation follows. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { z } from 'zod' // Import types and classes we'll implement import { AgenticLoop, ToolRouter, ToolValidator, type Tool, type ToolResult, type LoopOptions, type LoopResult, type ValidationResult, } from '../src/tool-orchestration.js' // Mock model for testing const createMockModel = () => ({ generate: vi.fn(), }) // Sample tools for testing const calculatorTool: Tool = { name: 'calculator', description: 'Performs basic math operations', parameters: z.object({ operation: z.enum(['add', 'subtract', 'multiply', 'divide']), a: z.number(), b: z.number(), }), execute: async ({ operation, a, b }) => { switch (operation) { case 'add': return a + b case 'subtract': return a - b case 'multiply': return a * b case 'divide': return b !== 0 ? a / b : 'Division by zero' } }, } const fetchTool: Tool = { name: 'fetch', description: 'Fetches data from a URL', parameters: z.object({ url: z.string().url(), }), execute: async ({ url }) => { return { data: `Content from ${url}`, status: 200 } }, } const slowTool: Tool = { name: 'slow', description: 'A tool that takes time to execute', parameters: z.object({ delay: z.number(), }), execute: async ({ delay }) => { await new Promise(resolve => setTimeout(resolve, delay)) return 'completed' }, } const failingTool: Tool = { name: 'failing', description: 'A tool that always fails', parameters: z.object({ message: z.string(), }), execute: async ({ message }) => { throw new Error(`Tool failed: ${message}`) }, } // ============================================================================ // AgenticLoop Tests - Multi-turn model→tools→model loops // ============================================================================ describe('AgenticLoop', () => { describe('basic loop execution', () => { it('should execute a single tool call and return result', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 5, }) // Mock model response that calls calculator then finishes const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 5, b: 3 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'The result of 5 + 3 is 8', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'What is 5 + 3?', }) expect(result.text).toContain('8') expect(result.steps).toBe(2) expect(result.toolCalls).toHaveLength(1) expect(result.toolCalls[0].name).toBe('calculator') expect(result.toolCalls[0].result).toBe(8) }) it('should handle multiple sequential tool calls', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 10, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 5, b: 3 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'multiply', a: 8, b: 2 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: '5 + 3 = 8, then 8 * 2 = 16', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Add 5 and 3, then multiply by 2', }) expect(result.steps).toBe(3) expect(result.toolCalls).toHaveLength(2) }) it('should preserve conversation state across turns', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 5, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 10, b: 5 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Done', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Calculate 10 + 5', }) // Verify the second call received the tool result in messages const secondCall = mockGenerate.mock.calls[1][0] expect(secondCall.messages).toBeDefined() expect(secondCall.messages.some((m: any) => m.role === 'tool' && m.content.includes('15') )).toBe(true) }) }) describe('maxSteps limit enforcement', () => { it('should stop at maxSteps limit', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 3, }) // Model keeps calling tools indefinitely const mockGenerate = vi.fn().mockResolvedValue({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 1 } }], finishReason: 'tool_call', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Keep adding', }) expect(result.steps).toBe(3) expect(result.stopReason).toBe('max_steps') }) it('should throw error when maxSteps is exceeded in strict mode', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 2, strictMaxSteps: true, }) const mockGenerate = vi.fn().mockResolvedValue({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 1 } }], finishReason: 'tool_call', }) await expect(loop.run({ model: { generate: mockGenerate } as any, prompt: 'Keep adding', })).rejects.toThrow('Max steps exceeded') }) }) describe('parallel tool execution', () => { it('should execute multiple tool calls in parallel', async () => { const timingTool: Tool = { name: 'timing', description: 'Returns execution order', parameters: z.object({ id: z.string() }), execute: async ({ id }) => { await new Promise(r => setTimeout(r, 10)) return { id, time: Date.now() } }, } const loop = new AgenticLoop({ tools: [timingTool], maxSteps: 5, parallelExecution: true, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [ { name: 'timing', arguments: { id: 'a' } }, { name: 'timing', arguments: { id: 'b' } }, { name: 'timing', arguments: { id: 'c' } }, ], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'All done', finishReason: 'stop', }) const startTime = Date.now() const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Run all', }) const elapsed = Date.now() - startTime expect(result.toolCalls).toHaveLength(3) // Parallel execution should be faster than sequential (3 * 10ms) expect(elapsed).toBeLessThan(40) }) it('should respect parallel execution limit', async () => { const executionOrder: string[] = [] const trackingTool: Tool = { name: 'track', description: 'Tracks execution', parameters: z.object({ id: z.string() }), execute: async ({ id }) => { executionOrder.push(`start:${id}`) await new Promise(r => setTimeout(r, 20)) executionOrder.push(`end:${id}`) return id }, } const loop = new AgenticLoop({ tools: [trackingTool], maxSteps: 5, parallelExecution: true, maxParallelCalls: 2, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [ { name: 'track', arguments: { id: '1' } }, { name: 'track', arguments: { id: '2' } }, { name: 'track', arguments: { id: '3' } }, { name: 'track', arguments: { id: '4' } }, ], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Done', finishReason: 'stop', }) await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Track all', }) // With maxParallelCalls: 2, at most 2 should start before any ends let concurrentStarts = 0 let maxConcurrent = 0 for (const event of executionOrder) { if (event.startsWith('start:')) concurrentStarts++ else concurrentStarts-- maxConcurrent = Math.max(maxConcurrent, concurrentStarts) } expect(maxConcurrent).toBeLessThanOrEqual(2) }) }) describe('abort signal support', () => { it('should abort execution when signal is triggered', async () => { const loop = new AgenticLoop({ tools: [slowTool], maxSteps: 10, }) const controller = new AbortController() const mockGenerate = vi.fn().mockResolvedValue({ toolCalls: [{ name: 'slow', arguments: { delay: 1000 } }], finishReason: 'tool_call', }) // Abort after 50ms setTimeout(() => controller.abort(), 50) await expect(loop.run({ model: { generate: mockGenerate } as any, prompt: 'Run slow tool', abortSignal: controller.signal, })).rejects.toThrow('Aborted') }) }) describe('onStep callback', () => { it('should call onStep for each loop iteration', async () => { const steps: any[] = [] const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 5, onStep: (step) => { steps.push(step) }, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Result is 3', finishReason: 'stop', }) await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Add', }) expect(steps).toHaveLength(2) expect(steps[0].stepNumber).toBe(1) expect(steps[0].toolCalls).toHaveLength(1) expect(steps[1].stepNumber).toBe(2) }) }) }) // ============================================================================ // ToolRouter Tests - Routing tool calls to handlers // ============================================================================ describe('ToolRouter', () => { describe('tool registration and routing', () => { it('should register and route to correct tool', async () => { const router = new ToolRouter() router.register(calculatorTool) router.register(fetchTool) const result = await router.route({ name: 'calculator', arguments: { operation: 'multiply', a: 6, b: 7 }, }) expect(result.success).toBe(true) expect(result.result).toBe(42) }) it('should return error for unknown tool', async () => { const router = new ToolRouter() router.register(calculatorTool) const result = await router.route({ name: 'unknown_tool', arguments: {}, }) expect(result.success).toBe(false) expect(result.error).toContain('not found') }) it('should route multiple calls in order', async () => { const router = new ToolRouter() router.register(calculatorTool) const calls = [ { name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }, { name: 'calculator', arguments: { operation: 'multiply', a: 3, b: 4 } }, ] const results = await router.routeAll(calls) expect(results).toHaveLength(2) expect(results[0].result).toBe(3) expect(results[1].result).toBe(12) }) }) describe('tool result formatting', () => { it('should format tool results for model consumption', async () => { const router = new ToolRouter() router.register(calculatorTool) const result = await router.route({ name: 'calculator', arguments: { operation: 'add', a: 10, b: 20 }, }) const formatted = router.formatResult(result) expect(formatted.role).toBe('tool') expect(formatted.content).toContain('30') }) it('should format error results appropriately', async () => { const router = new ToolRouter() router.register(failingTool) const result = await router.route({ name: 'failing', arguments: { message: 'test error' }, }) const formatted = router.formatResult(result) expect(formatted.role).toBe('tool') expect(formatted.content).toContain('error') expect(formatted.isError).toBe(true) }) }) describe('parallel routing', () => { it('should route multiple calls in parallel', async () => { const router = new ToolRouter() const executionTimes: number[] = [] const timingTool: Tool = { name: 'time', description: 'Records time', parameters: z.object({ id: z.number() }), execute: async ({ id }) => { await new Promise(r => setTimeout(r, 20)) executionTimes.push(Date.now()) return id }, } router.register(timingTool) const startTime = Date.now() await router.routeAllParallel([ { name: 'time', arguments: { id: 1 } }, { name: 'time', arguments: { id: 2 } }, { name: 'time', arguments: { id: 3 } }, ]) const elapsed = Date.now() - startTime // Should complete in ~20ms, not 60ms expect(elapsed).toBeLessThan(50) }) }) }) // ============================================================================ // ToolValidator Tests - Pre-execution validation // ============================================================================ describe('ToolValidator', () => { describe('argument validation', () => { it('should validate arguments against tool schema', () => { const validator = new ToolValidator() validator.register(calculatorTool) const result = validator.validate('calculator', { operation: 'add', a: 5, b: 10, }) expect(result.valid).toBe(true) }) it('should reject invalid arguments', () => { const validator = new ToolValidator() validator.register(calculatorTool) const result = validator.validate('calculator', { operation: 'invalid_op', a: 'not a number', b: 10, }) expect(result.valid).toBe(false) expect(result.errors).toBeDefined() expect(result.errors!.length).toBeGreaterThan(0) }) it('should reject missing required arguments', () => { const validator = new ToolValidator() validator.register(calculatorTool) const result = validator.validate('calculator', { operation: 'add', a: 5, // missing 'b' }) expect(result.valid).toBe(false) }) it('should return error for unknown tool', () => { const validator = new ToolValidator() const result = validator.validate('unknown', { foo: 'bar' }) expect(result.valid).toBe(false) expect(result.errors![0]).toContain('not registered') }) }) describe('batch validation', () => { it('should validate multiple tool calls at once', () => { const validator = new ToolValidator() validator.register(calculatorTool) validator.register(fetchTool) const results = validator.validateAll([ { name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }, { name: 'fetch', arguments: { url: 'https://example.com' } }, { name: 'calculator', arguments: { operation: 'bad', a: 1, b: 2 } }, ]) expect(results).toHaveLength(3) expect(results[0].valid).toBe(true) expect(results[1].valid).toBe(true) expect(results[2].valid).toBe(false) }) }) }) // ============================================================================ // Tool Error Recovery Tests // ============================================================================ describe('Tool Error Recovery', () => { describe('error handling', () => { it('should catch and report tool execution errors', async () => { const loop = new AgenticLoop({ tools: [failingTool, calculatorTool], maxSteps: 5, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'failing', arguments: { message: 'test' } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Tool failed, moving on', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Try the failing tool', }) expect(result.toolCalls[0].error).toBeDefined() expect(result.toolCalls[0].error).toContain('Tool failed') }) it('should retry failed tool calls when retry is enabled', async () => { let attempts = 0 const flakeyTool: Tool = { name: 'flakey', description: 'Fails first attempt', parameters: z.object({}), execute: async () => { attempts++ if (attempts < 2) throw new Error('First attempt fails') return 'success' }, } const loop = new AgenticLoop({ tools: [flakeyTool], maxSteps: 5, retryFailedTools: true, maxToolRetries: 3, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'flakey', arguments: {} }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Got success', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Use flakey tool', }) expect(result.toolCalls[0].result).toBe('success') expect(result.toolCalls[0].retryCount).toBe(1) }) }) describe('graceful degradation', () => { it('should continue with partial results when tools fail', async () => { const loop = new AgenticLoop({ tools: [calculatorTool, failingTool], maxSteps: 5, continueOnError: true, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [ { name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }, { name: 'failing', arguments: { message: 'error' } }, ], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Calculator worked, other failed', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Use both tools', }) expect(result.toolCalls).toHaveLength(2) expect(result.toolCalls[0].result).toBe(3) expect(result.toolCalls[1].error).toBeDefined() }) }) describe('timeout handling', () => { it('should timeout long-running tools', async () => { const loop = new AgenticLoop({ tools: [slowTool], maxSteps: 5, toolTimeout: 50, // 50ms timeout }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'slow', arguments: { delay: 1000 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: 'Tool timed out', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Run slow tool', }) expect(result.toolCalls[0].error).toContain('timeout') }) }) }) // ============================================================================ // Integration with generateText Tests // ============================================================================ describe('Integration with generateText', () => { it('should work with AI SDK tool format', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 5, }) // Verify tool conversion to AI SDK format const sdkTools = loop.getToolsForSDK() expect(sdkTools.calculator).toBeDefined() expect(sdkTools.calculator.description).toBe('Performs basic math operations') expect(sdkTools.calculator.parameters).toBeDefined() }) it('should expose tool results through experimental_toolResultContent', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 5, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 2, b: 3 } }], finishReason: 'tool_call', }) .mockResolvedValueOnce({ text: '5', finishReason: 'stop', }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Add 2 + 3', }) // Verify tool results are exposed in a format compatible with AI SDK expect(result.toolResults).toBeDefined() expect(result.toolResults[0].toolName).toBe('calculator') expect(result.toolResults[0].result).toBe(5) }) }) // ============================================================================ // Token Usage Tracking Tests // ============================================================================ describe('Token Usage Tracking', () => { it('should track token usage across multi-turn conversations', async () => { const loop = new AgenticLoop({ tools: [calculatorTool], maxSteps: 5, trackUsage: true, }) const mockGenerate = vi.fn() .mockResolvedValueOnce({ toolCalls: [{ name: 'calculator', arguments: { operation: 'add', a: 1, b: 2 } }], finishReason: 'tool_call', usage: { promptTokens: 50, completionTokens: 20, totalTokens: 70 }, }) .mockResolvedValueOnce({ text: 'Result is 3', finishReason: 'stop', usage: { promptTokens: 80, completionTokens: 10, totalTokens: 90 }, }) const result = await loop.run({ model: { generate: mockGenerate } as any, prompt: 'Add 1 + 2', }) expect(result.usage).toBeDefined() expect(result.usage!.promptTokens).toBe(130) expect(result.usage!.completionTokens).toBe(30) expect(result.usage!.totalTokens).toBe(160) }) }) // ============================================================================ // cachedTool Tests - Tool result caching with cleanup // ============================================================================ import { cachedTool, type CachedTool } from '../src/tool-orchestration.js' describe('cachedTool', () => { describe('basic caching behavior', () => { it('should cache tool results', async () => { let executionCount = 0 const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => { executionCount++ return { key, count: executionCount } }, } const cached = cachedTool(countingTool, { ttl: 1000 }) // First call executes the tool const result1 = await cached.execute({ key: 'test' }) expect(result1).toEqual({ key: 'test', count: 1 }) // Second call with same key returns cached result const result2 = await cached.execute({ key: 'test' }) expect(result2).toEqual({ key: 'test', count: 1 }) expect(executionCount).toBe(1) // Only executed once // Different key executes again const result3 = await cached.execute({ key: 'other' }) expect(result3).toEqual({ key: 'other', count: 2 }) }) it('should expire cached entries after TTL', async () => { vi.useFakeTimers() let executionCount = 0 const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => { executionCount++ return { key, count: executionCount } }, } const cached = cachedTool(countingTool, { ttl: 100 }) // First call await cached.execute({ key: 'test' }) expect(executionCount).toBe(1) // After TTL expires vi.advanceTimersByTime(150) // Should execute again since cache expired await cached.execute({ key: 'test' }) expect(executionCount).toBe(2) vi.useRealTimers() }) }) describe('cache cleanup', () => { it('should periodically clean up expired entries', async () => { vi.useFakeTimers() let executionCount = 0 const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => { executionCount++ return { key, count: executionCount } }, } const cached = cachedTool(countingTool, { ttl: 100, cleanupIntervalMs: 50, }) as CachedTool // Execute to populate cache await cached.execute({ key: 'entry1' }) await cached.execute({ key: 'entry2' }) expect(cached.cacheSize()).toBe(2) // Wait for TTL to expire and cleanup to run vi.advanceTimersByTime(150) // Entries should be cleaned up automatically expect(cached.cacheSize()).toBe(0) // Cleanup timer cached.destroy() vi.useRealTimers() }) it('should stop cleanup timer and clear cache when destroyed', async () => { vi.useFakeTimers() const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => ({ key }), } const cached = cachedTool(countingTool, { ttl: 100, cleanupIntervalMs: 50, }) as CachedTool await cached.execute({ key: 'entry1' }) expect(cached.cacheSize()).toBe(1) // Destroy stops cleanup timer and clears cache to prevent memory leaks cached.destroy() // Cache should be cleared immediately on destroy expect(cached.cacheSize()).toBe(0) // Advancing time should have no effect (timer is stopped) vi.advanceTimersByTime(150) expect(cached.cacheSize()).toBe(0) vi.useRealTimers() }) it('should clear all cache entries on clearCache()', async () => { const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => ({ key }), } const cached = cachedTool(countingTool, { ttl: 60000 }) as CachedTool await cached.execute({ key: 'entry1' }) await cached.execute({ key: 'entry2' }) await cached.execute({ key: 'entry3' }) expect(cached.cacheSize()).toBe(3) cached.clearCache() expect(cached.cacheSize()).toBe(0) cached.destroy() }) }) describe('max cache size (LRU eviction)', () => { it('should evict oldest entries when maxSize is reached', async () => { vi.useFakeTimers() let executionCount = 0 const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => { executionCount++ return { key, count: executionCount } }, } const cached = cachedTool(countingTool, { ttl: 60000, maxSize: 3, }) as CachedTool // Fill cache to max await cached.execute({ key: 'a' }) vi.advanceTimersByTime(10) await cached.execute({ key: 'b' }) vi.advanceTimersByTime(10) await cached.execute({ key: 'c' }) vi.advanceTimersByTime(10) expect(cached.cacheSize()).toBe(3) expect(executionCount).toBe(3) // Adding 4th entry should evict oldest ('a') await cached.execute({ key: 'd' }) expect(cached.cacheSize()).toBe(3) // Accessing 'a' should re-execute since it was evicted await cached.execute({ key: 'a' }) expect(executionCount).toBe(5) // New execution cached.destroy() vi.useRealTimers() }) it('should update LRU order on cache hit', async () => { vi.useFakeTimers() let executionCount = 0 const countingTool: Tool = { name: 'counting', description: 'Counts executions', parameters: z.object({ key: z.string() }), execute: async ({ key }) => { executionCount++ return { key, count: executionCount } }, } const cached = cachedTool(countingTool, { ttl: 60000, maxSize: 3, }) as CachedTool // Fill cache: a, b, c (oldest to newest) await cached.execute({ key: 'a' }) vi.advanceTimersByTime(10) await cached.execute({ key: 'b' }) vi.advanceTimersByTime(10) await cached.execute({ key: 'c' }) vi.advanceTimersByTime(10) // Access 'a' to make it recently used await cached.execute({ key: 'a' }) // Cache hit vi.advanceTimersByTime(10) expect(executionCount).toBe(3) // No new execution // Add 'd' - should evict 'b' (now oldest) not 'a' await cached.execute({ key: 'd' }) // 'b' was evicted, 'a' and 'c' remain await cached.execute({ key: 'b' }) // Should re-execute expect(executionCount).toBe(5) await cached.execute({ key: 'a' }) // Still cached expect(executionCount).toBe(5) cached.destroy() vi.useRealTimers() }) }) describe('resource cleanup on tool destruction', () => { it('should clean up timers and memory when destroy is called', async () => { const tool: Tool = { name: 'test', description: 'Test tool', parameters: z.object({ key: z.string() }), execute: async ({ key }) => ({ key }), } const cached = cachedTool(tool, { ttl: 1000, cleanupIntervalMs: 100, }) as CachedTool await cached.execute({ key: 'test' }) expect(cached.cacheSize()).toBe(1) cached.destroy() expect(cached.cacheSize()).toBe(0) // Should still work after destroy but without caching await cached.execute({ key: 'test2' }) expect(cached.cacheSize()).toBe(0) }) }) })