import { setupGlobalFetchMock } from '@sim/testing' import { afterEach, beforeEach, describe, expect, it, type Mock, vi } from 'vitest' import { getAllBlocks } from '@/blocks' import { BlockType, isMcpTool } from '@/executor/constants' import { AgentBlockHandler } from '@/executor/handlers/agent/agent-handler' import type { ExecutionContext, StreamingExecution } from '@/executor/types' import { executeProviderRequest } from '@/providers' import { getProviderFromModel, transformBlockTool } from '@/providers/utils' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import { executeTool } from '@/tools' process.env.NEXT_PUBLIC_APP_URL = 'http://localhost:3000' vi.mock('@/lib/core/config/env-flags', () => ({ isHosted: false, isProd: false, isDev: true, isTest: false, getCostMultiplier: vi.fn().mockReturnValue(1), getAllowedIntegrationsFromEnv: vi.fn().mockReturnValue(null), isEmailVerificationEnabled: false, isBillingEnabled: false, isOrganizationsEnabled: false, isAccessControlEnabled: false, })) vi.mock('@/providers/utils', () => ({ getProviderFromModel: vi.fn().mockReturnValue('mock-provider'), transformBlockTool: vi.fn(), getBaseModelProviders: vi.fn().mockReturnValue({ openai: {}, anthropic: {} }), getApiKey: vi.fn().mockReturnValue('mock-api-key'), getProvider: vi.fn().mockReturnValue({ chat: { completions: { create: vi.fn().mockResolvedValue({ content: 'Mocked response content', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: [], cost: 0.001, timing: { total: 100 }, }), }, }, }), })) vi.mock('@/blocks', () => ({ getAllBlocks: vi.fn().mockReturnValue([]), })) vi.mock('@/tools', () => ({ executeTool: vi.fn(), })) vi.mock('@/providers', () => ({ executeProviderRequest: vi.fn().mockResolvedValue({ content: 'Mocked response content', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: [], cost: 0.001, timing: { total: 100 }, }), })) vi.mock('@/executor/utils/http', () => ({ buildAuthHeaders: vi.fn().mockResolvedValue({ 'Content-Type': 'application/json' }), buildAPIUrl: vi.fn((path: string, params?: Record) => { const url = new URL(path, 'http://localhost:3000') if (params) { for (const [key, value] of Object.entries(params)) { if (value !== undefined && value !== null) { url.searchParams.set(key, value) } } } return url }), extractAPIErrorMessage: vi.fn(async (response: Response) => { const defaultMessage = `API request failed with status ${response.status}` try { const errorData = await response.json() return errorData.error || defaultMessage } catch { return defaultMessage } }), })) vi.mock('@sim/db', () => ({ db: { select: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([ { id: 'mcp-search-server', connectionStatus: 'connected' }, { id: 'same-server', connectionStatus: 'connected' }, { id: 'mcp-legacy-server', connectionStatus: 'connected' }, ]), }), }), }, })) const mockGetCustomToolById = vi.fn() vi.mock('@/lib/workflows/custom-tools/operations', () => ({ getCustomToolById: (...args: unknown[]) => mockGetCustomToolById(...args), })) setupGlobalFetchMock() const mockGetAllBlocks = getAllBlocks as Mock const mockExecuteTool = executeTool as Mock const mockGetProviderFromModel = getProviderFromModel as Mock const mockTransformBlockTool = transformBlockTool as Mock const mockFetch = global.fetch as unknown as Mock const mockExecuteProviderRequest = executeProviderRequest as Mock describe('AgentBlockHandler', () => { let handler: AgentBlockHandler let mockBlock: SerializedBlock let mockContext: ExecutionContext beforeEach(() => { handler = new AgentBlockHandler() vi.clearAllMocks() Object.defineProperty(global, 'window', { value: {}, writable: true, configurable: true, }) mockBlock = { id: 'test-agent-block', metadata: { id: BlockType.AGENT, name: 'Test Agent' }, type: BlockType.AGENT, position: { x: 0, y: 0 }, config: { tool: 'mock-tool', params: {}, }, inputs: {}, outputs: {}, enabled: true, } as SerializedBlock mockContext = { workflowId: 'test-workflow', blockStates: new Map(), blockLogs: [], metadata: { startTime: new Date().toISOString(), duration: 0 }, environmentVariables: {}, decisions: { router: new Map(), condition: new Map() }, loopExecutions: new Map(), completedLoops: new Set(), executedBlocks: new Set(), activeExecutionPath: new Set(), workflow: { blocks: [], connections: [], version: '1.0.0', loops: {}, } as SerializedWorkflow, } mockGetProviderFromModel.mockReturnValue('mock-provider') mockExecuteProviderRequest.mockResolvedValue({ content: 'Mocked response content', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: [], cost: 0.001, timing: { total: 100 }, }) mockFetch.mockImplementation((url: string) => { return Promise.resolve({ ok: true, headers: { get: () => null, }, json: () => Promise.resolve({}), }) }) mockTransformBlockTool.mockImplementation((tool: { id?: string; operation?: string }) => ({ id: `transformed_${tool.id}`, name: `${tool.id}_${tool.operation}`, description: 'Transformed tool', parameters: { type: 'object', properties: {} }, })) mockGetAllBlocks.mockReturnValue([]) mockExecuteTool.mockImplementation((toolId, params) => { if (toolId === 'function_execute') { return Promise.resolve({ success: true, output: { result: 'Executed successfully', params }, }) } return Promise.resolve({ success: false, error: 'Unknown tool' }) }) }) afterEach(() => { try { Object.defineProperty(global, 'window', { value: undefined, writable: true, configurable: true, }) } catch (e) {} }) describe('canHandle', () => { it('should return true for blocks with metadata id "agent"', () => { expect(handler.canHandle(mockBlock)).toBe(true) }) it('should return false for blocks without metadata id "agent"', () => { const nonAgentBlock: SerializedBlock = { ...mockBlock, metadata: { id: 'other-block' }, } expect(handler.canHandle(nonAgentBlock)).toBe(false) }) it('should return false for blocks without metadata', () => { const noMetadataBlock: SerializedBlock = { ...mockBlock, metadata: undefined, } expect(handler.canHandle(noMetadataBlock)).toBe(false) }) }) describe('execute', () => { it('should execute a basic agent block request', async () => { const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: 'User query: Hello!', temperature: 0.7, maxTokens: 100, apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') const expectedOutput = { content: 'Mocked response content', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: 0.001, } const result = await handler.execute(mockContext, mockBlock, inputs) expect(mockGetProviderFromModel).toHaveBeenCalledWith('gpt-4o') expect(mockExecuteProviderRequest).toHaveBeenCalled() expect(result).toEqual(expectedOutput) }) it('should attach files to the last user message only', async () => { const inputs = { model: 'gpt-4o', messages: [ { role: 'system' as const, content: 'You are helpful.' }, { role: 'user' as const, content: 'Earlier question' }, { role: 'assistant' as const, content: 'Earlier answer' }, { role: 'user' as const, content: 'Analyze this file' }, ], files: [ { id: 'file-1', key: 'workspace/ws-1/example.png', name: 'example.png', url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', size: 128, type: 'image/png', base64: 'aW1hZ2U=', }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const requestBody = mockExecuteProviderRequest.mock.calls[0][1] expect(requestBody.messages[1]).toMatchObject({ role: 'user', content: 'Earlier question', }) expect(requestBody.messages[1].files).toBeUndefined() expect(requestBody.messages[3]).toMatchObject({ role: 'user', content: 'Analyze this file', files: [ { id: 'file-1', name: 'example.png', type: 'image/png', base64: 'aW1hZ2U=', }, ], }) }) it('should reject files for providers without attachment support', async () => { const inputs = { model: 'deepseek-chat', messages: [{ role: 'user' as const, content: 'Analyze this file' }], files: [ { id: 'file-1', key: 'workspace/ws-1/example.png', name: 'example.png', url: '/api/files/serve/workspace%2Fws-1%2Fexample.png?context=workspace', size: 128, type: 'image/png', base64: 'aW1hZ2U=', }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('deepseek') await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( 'File attachments are not supported for provider "deepseek"' ) }) it('should preserve usageControl for custom tools and filter out "none"', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Test custom tools with different usageControl settings', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', title: 'Auto Tool', code: 'return { result: "auto tool executed", input }', timeout: 1000, schema: { function: { name: 'auto_tool', description: 'Custom tool with auto usage control', parameters: { type: 'object', properties: { input: { type: 'string' }, }, }, }, }, usageControl: 'auto' as const, }, { type: 'custom-tool', title: 'Force Tool', code: 'return { result: "force tool executed", input }', timeout: 1000, schema: { function: { name: 'force_tool', description: 'Custom tool with forced usage control', parameters: { type: 'object', properties: { input: { type: 'string' }, }, }, }, }, usageControl: 'force' as const, }, { type: 'custom-tool', title: 'None Tool', code: 'return { result: "none tool executed", input }', timeout: 1000, schema: { function: { name: 'none_tool', description: 'Custom tool that should be filtered out', parameters: { type: 'object', properties: { input: { type: 'string' }, }, }, }, }, usageControl: 'none' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(2) const autoTool = tools.find( (t: { name?: string; id?: string; usageControl?: string }) => t.name === 'auto_tool' ) const forceTool = tools.find( (t: { name?: string; id?: string; usageControl?: string }) => t.name === 'force_tool' ) const noneTool = tools.find( (t: { name?: string; id?: string; usageControl?: string }) => t.name === 'none_tool' ) expect(autoTool).toBeDefined() expect(forceTool).toBeDefined() expect(noneTool).toBeUndefined() expect(autoTool.usageControl).toBe('auto') expect(forceTool.usageControl).toBe('force') }) it('should filter out tools with usageControl set to "none"', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Use the tools provided.', apiKey: 'test-api-key', tools: [ { id: 'tool_1', title: 'Tool 1', type: 'tool-type-1', operation: 'operation1', usageControl: 'auto' as const, }, { id: 'tool_2', title: 'Tool 2', type: 'tool-type-2', operation: 'operation2', usageControl: 'none' as const, }, { id: 'tool_3', title: 'Tool 3', type: 'tool-type-3', operation: 'operation3', usageControl: 'force' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] expect(requestBody.tools.length).toBe(2) const toolIds = requestBody.tools.map( (t: { name?: string; id?: string; usageControl?: string }) => t.id ) expect(toolIds).toContain('transformed_tool_1') expect(toolIds).toContain('transformed_tool_3') expect(toolIds).not.toContain('transformed_tool_2') }) it('should include usageControl property in transformed tools', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Use the tools with different usage controls.', apiKey: 'test-api-key', tools: [ { id: 'tool_1', title: 'Tool 1', type: 'tool-type-1', operation: 'operation1', usageControl: 'auto' as const, }, { id: 'tool_2', title: 'Tool 2', type: 'tool-type-2', operation: 'operation2', usageControl: 'force' as const, }, ], } mockTransformBlockTool.mockImplementation((tool: { id?: string; operation?: string }) => ({ id: `transformed_${tool.id}`, name: `${tool.id}_${tool.operation}`, description: 'Transformed tool', parameters: { type: 'object', properties: {} }, })) mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] expect(requestBody.tools[0].usageControl).toBe('auto') expect(requestBody.tools[1].usageControl).toBe('force') }) it('should handle custom tools with usageControl properties', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Use the custom tools.', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', title: 'Custom Tool - Auto', schema: { function: { name: 'custom_tool_auto', description: 'A custom tool with auto usage control', parameters: { type: 'object', properties: { input: { type: 'string' } }, }, }, }, usageControl: 'auto' as const, }, { type: 'custom-tool', title: 'Custom Tool - Force', schema: { function: { name: 'custom_tool_force', description: 'A custom tool with forced usage', parameters: { type: 'object', properties: { input: { type: 'string' } }, }, }, }, usageControl: 'force' as const, }, { type: 'custom-tool', title: 'Custom Tool - None', schema: { function: { name: 'custom_tool_none', description: 'A custom tool that should not be used', parameters: { type: 'object', properties: { input: { type: 'string' } }, }, }, }, usageControl: 'none' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] expect(requestBody.tools.length).toBe(2) const toolNames = requestBody.tools.map( (t: { name?: string; id?: string; usageControl?: string }) => t.name ) expect(toolNames).toContain('custom_tool_auto') expect(toolNames).toContain('custom_tool_force') expect(toolNames).not.toContain('custom_tool_none') const autoTool = requestBody.tools.find( (t: { name?: string; id?: string; usageControl?: string }) => t.name === 'custom_tool_auto' ) const forceTool = requestBody.tools.find( (t: { name?: string; id?: string; usageControl?: string }) => t.name === 'custom_tool_force' ) expect(autoTool.usageControl).toBe('auto') expect(forceTool.usageControl).toBe('force') }) it('should not require API key for gpt-4o on hosted version', async () => { const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: 'User query: Hello!', temperature: 0.7, maxTokens: 100, } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() }) it('should execute with standard block tools', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Analyze this data.', apiKey: 'test-api-key', // Add API key for non-hosted env tools: [ { id: 'block_tool_1', title: 'Data Analysis Tool', operation: 'analyze', }, ], } const mockToolDetails = { id: 'block_tool_1', name: 'data_analysis_analyze', description: 'Analyzes data', parameters: { type: 'object', properties: { input: { type: 'string' } } }, } mockTransformBlockTool.mockReturnValue(mockToolDetails) mockGetProviderFromModel.mockReturnValue('openai') const expectedOutput = { content: 'Mocked response content', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, // Assuming no tool calls in this mock response providerTiming: { total: 100 }, cost: 0.001, } const result = await handler.execute(mockContext, mockBlock, inputs) expect(mockTransformBlockTool).toHaveBeenCalledWith( inputs.tools[0], expect.objectContaining({ selectedOperation: 'analyze' }) ) expect(mockExecuteProviderRequest).toHaveBeenCalled() expect(result).toEqual(expectedOutput) }) it('should execute with custom tools (schema only and with code)', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Use the custom tools.', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', title: 'Custom Schema Tool', schema: { function: { name: 'custom_schema_tool', description: 'A tool defined only by schema', parameters: { type: 'object', properties: { input: { type: 'string' }, }, }, }, }, }, { type: 'custom-tool', title: 'Custom Code Tool', code: 'return { result: input * 2 }', timeout: 1000, schema: { function: { name: 'custom_code_tool', description: 'A tool with code execution', parameters: { type: 'object', properties: { input: { type: 'number' }, }, }, }, }, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() }) it('should handle responseFormat with valid JSON', async () => { mockExecuteProviderRequest.mockResolvedValueOnce({ content: '{"result": "Success", "score": 0.95}', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, toolCalls: [], cost: undefined, }) const inputs = { model: 'gpt-4o', userPrompt: 'Test context', apiKey: 'test-api-key', responseFormat: '{"type":"object","properties":{"result":{"type":"string"},"score":{"type":"number"}}}', } const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toEqual({ result: 'Success', score: 0.95, model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, }) }) it('should handle responseFormat when it is an empty string', async () => { mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Regular text response', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, }) const inputs = { model: 'gpt-4o', userPrompt: 'Test context', apiKey: 'test-api-key', responseFormat: '', // Empty string } const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toEqual({ content: 'Regular text response', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, }) }) it('should handle invalid JSON in responseFormat gracefully', async () => { mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Regular text response', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, toolCalls: [], cost: undefined, }) const inputs = { model: 'gpt-4o', userPrompt: 'Format this output.', apiKey: 'test-api-key', responseFormat: '{invalid-json', } // Should not throw an error, but continue with default behavior const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toEqual({ content: 'Regular text response', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, }) }) it('should handle variable references in responseFormat gracefully', async () => { mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Regular text response', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, timing: { total: 100 }, toolCalls: [], cost: undefined, }) const inputs = { model: 'gpt-4o', userPrompt: 'Format this output.', apiKey: 'test-api-key', responseFormat: '', } // Should not throw an error, but continue with default behavior const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toEqual({ content: 'Regular text response', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, toolCalls: { list: [], count: 0 }, providerTiming: { total: 100 }, cost: undefined, }) }) it('should handle errors from the provider request', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'This will fail.', apiKey: 'test-api-key', // Add API key for non-hosted env } mockGetProviderFromModel.mockReturnValue('openai') mockExecuteProviderRequest.mockRejectedValueOnce(new Error('Provider API Error')) await expect(handler.execute(mockContext, mockBlock, inputs)).rejects.toThrow( 'Provider API Error' ) }) it('should handle streaming responses with text/event-stream content type', async () => { const mockStreamBody = new ReadableStream({ start(controller) { controller.close() }, }) mockExecuteProviderRequest.mockResolvedValueOnce({ stream: mockStreamBody, execution: { success: true, output: {}, logs: [], metadata: { duration: 0, startTime: new Date().toISOString(), }, }, }) const inputs = { model: 'gpt-4o', userPrompt: 'Stream this response.', apiKey: 'test-api-key', stream: true, } mockContext.stream = true mockContext.selectedOutputs = [mockBlock.id] const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toHaveProperty('stream') expect(result).toHaveProperty('execution') expect((result as StreamingExecution).execution).toHaveProperty('success', true) expect((result as StreamingExecution).execution).toHaveProperty('output') expect((result as StreamingExecution).execution.output).toBeDefined() expect((result as StreamingExecution).execution).toHaveProperty('logs') }) it('should handle streaming responses with execution data in header', async () => { const mockStreamBody = new ReadableStream({ start(controller) { controller.close() }, }) const mockExecutionData = { success: true, output: { content: '', model: 'mock-model', tokens: { input: 10, output: 20, total: 30 }, }, logs: [ { blockId: 'some-id', blockType: BlockType.AGENT, startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), durationMs: 100, success: true, }, ], metadata: { startTime: new Date().toISOString(), duration: 100, }, } mockExecuteProviderRequest.mockResolvedValueOnce({ stream: mockStreamBody, execution: mockExecutionData, }) const inputs = { model: 'gpt-4o', userPrompt: 'Stream this response with execution data.', apiKey: 'test-api-key', stream: true, } mockContext.stream = true mockContext.selectedOutputs = [mockBlock.id] const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toHaveProperty('stream') expect(result).toHaveProperty('execution') expect((result as StreamingExecution).execution.success).toBe(true) expect((result as StreamingExecution).execution.output.model).toBe('mock-model') const logs = (result as StreamingExecution).execution.logs expect(logs?.length).toBe(1) if (logs && logs.length > 0 && logs[0]) { expect(logs[0].blockType).toBe(BlockType.AGENT) } }) it('should handle combined stream+execution responses', async () => { new ReadableStream({ start(controller) { controller.close() }, }) mockExecuteProviderRequest.mockResolvedValueOnce({ stream: {}, // Serialized stream placeholder execution: { success: true, output: { content: 'Test streaming content', model: 'gpt-4o', tokens: { input: 10, output: 5, total: 15 }, }, logs: [], metadata: { startTime: new Date().toISOString(), duration: 150, }, }, }) const inputs = { model: 'gpt-4o', userPrompt: 'Return a combined response.', apiKey: 'test-api-key', stream: true, } mockContext.stream = true mockContext.selectedOutputs = [mockBlock.id] const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toHaveProperty('stream') expect(result).toHaveProperty('execution') expect((result as StreamingExecution).execution.success).toBe(true) expect((result as StreamingExecution).execution.output.content).toBe('Test streaming content') expect((result as StreamingExecution).execution.output.model).toBe('gpt-4o') }) it('should process memories in advanced mode with system prompt and user prompt', async () => { const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: 'What did we discuss before?', memories: [ { role: 'user', content: 'Hello, my name is John.' }, { role: 'assistant', content: 'Hello John! Nice to meet you.' }, { role: 'user', content: 'I like programming.' }, { role: 'assistant', content: "That's great! What programming languages do you enjoy?" }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify messages were built correctly expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(6) // system + 4 memories + user prompt // Check system prompt is first expect(requestBody.messages[0].role).toBe('system') expect(requestBody.messages[0].content).toBe('You are a helpful assistant.') // Check memories are in the middle expect(requestBody.messages[1].role).toBe('user') expect(requestBody.messages[1].content).toBe('Hello, my name is John.') expect(requestBody.messages[2].role).toBe('assistant') expect(requestBody.messages[2].content).toBe('Hello John! Nice to meet you.') // Check user prompt is last expect(requestBody.messages[5].role).toBe('user') expect(requestBody.messages[5].content).toBe('What did we discuss before?') // Verify system prompt and context are not included separately expect(requestBody.systemPrompt).toBeUndefined() expect(requestBody.userPrompt).toBeUndefined() }) it('should handle memory block output format', async () => { const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: 'Continue our conversation.', memories: { memories: [ { key: 'conversation-1', type: BlockType.AGENT, data: [ { role: 'user', content: 'Hi there!' }, { role: 'assistant', content: 'Hello! How can I help you?' }, ], }, ], }, apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify messages were built correctly expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(4) // system + 2 memories + user prompt // Check system prompt is first expect(requestBody.messages[0].role).toBe('system') expect(requestBody.messages[0].content).toBe('You are a helpful assistant.') // Check memories from memory block expect(requestBody.messages[1].role).toBe('user') expect(requestBody.messages[1].content).toBe('Hi there!') expect(requestBody.messages[2].role).toBe('assistant') expect(requestBody.messages[2].content).toBe('Hello! How can I help you?') // Check user prompt is last expect(requestBody.messages[3].role).toBe('user') expect(requestBody.messages[3].content).toBe('Continue our conversation.') }) it('should not duplicate system prompt if it exists in memories', async () => { const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: 'What should I do?', memories: [ { role: 'system', content: 'You are a helpful assistant.' }, { role: 'user', content: 'Hello!' }, { role: 'assistant', content: 'Hi there!' }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify messages were built correctly expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(4) // existing system + 2 memories + user prompt // Check only one system message exists const systemMessages = requestBody.messages.filter((msg: any) => msg.role === 'system') expect(systemMessages.length).toBe(1) expect(systemMessages[0].content).toBe('You are a helpful assistant.') }) it('should prefix agent system message before legacy memories', async () => { const inputs = { model: 'gpt-4o', messages: [ { role: 'system' as const, content: 'You are a helpful assistant.' }, { role: 'user' as const, content: 'What should I do?' }, ], memories: [ { role: 'system', content: 'Old system message from memories.' }, { role: 'user', content: 'Hello!' }, { role: 'assistant', content: 'Hi there!' }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify messages were built correctly // Agent system (1) + legacy memories (3) + user from messages (1) = 5 expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(5) // Agent's system message is prefixed first expect(requestBody.messages[0].role).toBe('system') expect(requestBody.messages[0].content).toBe('You are a helpful assistant.') // Then legacy memories (with their system message preserved) expect(requestBody.messages[1].role).toBe('system') expect(requestBody.messages[1].content).toBe('Old system message from memories.') expect(requestBody.messages[2].role).toBe('user') expect(requestBody.messages[2].content).toBe('Hello!') expect(requestBody.messages[3].role).toBe('assistant') expect(requestBody.messages[3].content).toBe('Hi there!') // Then user message from messages array expect(requestBody.messages[4].role).toBe('user') expect(requestBody.messages[4].content).toBe('What should I do?') }) it('should prefix agent system message and preserve legacy memory system messages', async () => { const inputs = { model: 'gpt-4o', messages: [ { role: 'system' as const, content: 'You are a helpful assistant.' }, { role: 'user' as const, content: 'Continue our conversation.' }, ], memories: [ { role: 'system', content: 'First system message.' }, { role: 'user', content: 'Hello!' }, { role: 'system', content: 'Second system message.' }, { role: 'assistant', content: 'Hi there!' }, { role: 'system', content: 'Third system message.' }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify messages were built correctly expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(7) // Agent's system message prefixed first expect(requestBody.messages[0].role).toBe('system') expect(requestBody.messages[0].content).toBe('You are a helpful assistant.') // Then legacy memories with their system messages preserved in order expect(requestBody.messages[1].role).toBe('system') expect(requestBody.messages[1].content).toBe('First system message.') expect(requestBody.messages[2].role).toBe('user') expect(requestBody.messages[2].content).toBe('Hello!') expect(requestBody.messages[3].role).toBe('system') expect(requestBody.messages[3].content).toBe('Second system message.') expect(requestBody.messages[4].role).toBe('assistant') expect(requestBody.messages[4].content).toBe('Hi there!') expect(requestBody.messages[5].role).toBe('system') expect(requestBody.messages[5].content).toBe('Third system message.') // Then user message from messages array expect(requestBody.messages[6].role).toBe('user') expect(requestBody.messages[6].content).toBe('Continue our conversation.') }) it('should preserve multiple system messages when no explicit systemPrompt is provided', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'What should I do?', memories: [ { role: 'system', content: 'First system message.' }, { role: 'user', content: 'Hello!' }, { role: 'system', content: 'Second system message.' }, { role: 'assistant', content: 'Hi there!' }, ], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify messages were built correctly expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(5) // 2 system + 2 non-system memories + user prompt // Check that multiple system messages are preserved when no explicit systemPrompt const systemMessages = requestBody.messages.filter((msg: any) => msg.role === 'system') expect(systemMessages.length).toBe(2) expect(systemMessages[0].content).toBe('First system message.') expect(systemMessages[1].content).toBe('Second system message.') // Verify original order is preserved expect(requestBody.messages[0].role).toBe('system') expect(requestBody.messages[0].content).toBe('First system message.') expect(requestBody.messages[1].role).toBe('user') expect(requestBody.messages[1].content).toBe('Hello!') expect(requestBody.messages[2].role).toBe('system') expect(requestBody.messages[2].content).toBe('Second system message.') expect(requestBody.messages[3].role).toBe('assistant') expect(requestBody.messages[3].content).toBe('Hi there!') expect(requestBody.messages[4].role).toBe('user') expect(requestBody.messages[4].content).toBe('What should I do?') }) it('should handle user prompt as object with input field', async () => { const inputs = { model: 'gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: { input: 'What is the weather like?', conversationId: 'abc-123', }, memories: [], apiKey: 'test-api-key', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] // Verify user prompt content was extracted correctly expect(requestBody.messages).toBeDefined() expect(requestBody.messages.length).toBe(2) // system + user prompt expect(requestBody.messages[1].role).toBe('user') expect(requestBody.messages[1].content).toBe('What is the weather like?') expect(requestBody.messages[1]).not.toHaveProperty('conversationId') }) it('should pass Azure OpenAI parameters through the request pipeline', async () => { const inputs = { model: 'azure/gpt-4o', systemPrompt: 'You are a helpful assistant.', userPrompt: 'Hello!', apiKey: 'test-azure-api-key', azureEndpoint: 'https://my-azure-resource.openai.azure.com', azureApiVersion: '2024-07-01-preview', temperature: 0.7, } mockGetProviderFromModel.mockReturnValue('azure-openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] expect(requestBody.azureEndpoint).toBe('https://my-azure-resource.openai.azure.com') expect(requestBody.azureApiVersion).toBe('2024-07-01-preview') expect(providerCall[0]).toBe('azure-openai') expect(requestBody.model).toBe('azure/gpt-4o') expect(requestBody.apiKey).toBe('test-azure-api-key') }) it('should pass GPT-5 specific parameters (reasoningEffort and verbosity) through the request pipeline', async () => { const inputs = { model: 'gpt-5', systemPrompt: 'You are a helpful assistant.', userPrompt: 'Hello!', apiKey: 'test-api-key', reasoningEffort: 'minimal', verbosity: 'high', temperature: 0.7, } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] expect(requestBody.reasoningEffort).toBe('minimal') expect(requestBody.verbosity).toBe('high') expect(providerCall[0]).toBe('openai') expect(requestBody.model).toBe('gpt-5') expect(requestBody.apiKey).toBe('test-api-key') }) it('should handle missing GPT-5 parameters gracefully', async () => { const inputs = { model: 'gpt-5', systemPrompt: 'You are a helpful assistant.', userPrompt: 'Hello!', apiKey: 'test-api-key', temperature: 0.7, } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const requestBody = providerCall[1] expect(requestBody.reasoningEffort).toBeUndefined() expect(requestBody.verbosity).toBeUndefined() expect(providerCall[0]).toBe('openai') expect(requestBody.model).toBe('gpt-5') }) it('should handle MCP tools in agent execution', async () => { mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => { if (isMcpTool(toolId)) { return Promise.resolve({ success: true, output: { content: [ { type: 'text', text: `MCP tool ${toolId} executed with params: ${JSON.stringify(params)}`, }, ], }, }) } return Promise.resolve({ success: false, error: 'Unknown tool' }) }) mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'I will use MCP tools to help you.', model: 'gpt-4o', tokens: { input: 15, output: 25, total: 40 }, toolCalls: [ { name: 'mcp-server1-list_files', arguments: { path: '/tmp' }, result: { success: true, output: { content: [{ type: 'text', text: 'Files listed' }] }, }, }, { name: 'mcp-server2-search', arguments: { query: 'test', limit: 5 }, result: { success: true, output: { content: [{ type: 'text', text: 'Search results' }] }, }, }, ], timing: { total: 150 }, }) const inputs = { model: 'gpt-4o', userPrompt: 'List files and search for test data', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'List Files', schema: { function: { name: 'mcp-server1-list_files', description: 'List files in directory', parameters: { type: 'object', properties: { path: { type: 'string', description: 'Directory path' }, }, }, }, }, usageControl: 'auto' as const, }, { type: 'mcp', title: 'Search', schema: { function: { name: 'mcp-server2-search', description: 'Search for data', parameters: { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, limit: { type: 'number', description: 'Result limit' }, }, }, }, }, usageControl: 'auto' as const, }, ], } const mcpContext = { ...mockContext, workspaceId: 'test-workspace-123', } mockGetProviderFromModel.mockReturnValue('openai') const result = await handler.execute(mcpContext, mockBlock, inputs) expect((result as any).content).toBe('I will use MCP tools to help you.') expect((result as any).toolCalls.count).toBe(2) expect((result as any).toolCalls.list).toHaveLength(2) expect((result as any).toolCalls.list[0].name).toBe('mcp-server1-list_files') expect((result as any).toolCalls.list[0].result.success).toBe(true) expect((result as any).toolCalls.list[1].name).toBe('mcp-server2-search') expect((result as any).toolCalls.list[1].result.success).toBe(true) }) it('should handle MCP tool execution errors', async () => { mockExecuteTool.mockImplementation((toolId, params) => { if (toolId === 'mcp-server1-failing_tool') { return Promise.resolve({ success: false, error: 'MCP server connection failed', }) } return Promise.resolve({ success: false, error: 'Unknown tool' }) }) mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Let me try to use this tool.', model: 'gpt-4o', tokens: { input: 10, output: 15, total: 25 }, toolCalls: [ { name: 'mcp-server1-failing_tool', arguments: { param: 'value' }, result: { success: false, error: 'MCP server connection failed', }, }, ], timing: { total: 100 }, }) const inputs = { model: 'gpt-4o', userPrompt: 'Try to use the failing tool', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'Failing Tool', schema: { function: { name: 'mcp-server1-failing_tool', description: 'A tool that will fail', parameters: { type: 'object', properties: { param: { type: 'string' }, }, }, }, }, usageControl: 'auto' as const, }, ], } const mcpContext = { ...mockContext, workspaceId: 'test-workspace-123', } mockGetProviderFromModel.mockReturnValue('openai') const result = await handler.execute(mcpContext, mockBlock, inputs) expect((result as any).content).toBe('Let me try to use this tool.') expect((result as any).toolCalls.count).toBe(1) expect((result as any).toolCalls.list[0].result.success).toBe(false) expect((result as any).toolCalls.list[0].result.error).toBe('MCP server connection failed') }) it('should transform MCP tools correctly for agent execution', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Use MCP tools to help me', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'Read File', schema: { function: { name: 'mcp-filesystem-read_file', description: 'Read file from filesystem', parameters: { type: 'object', properties: {} }, }, }, usageControl: 'auto' as const, }, { type: 'mcp', title: 'Web Search', schema: { function: { name: 'mcp-web-search', description: 'Search the web', parameters: { type: 'object', properties: {} }, }, }, usageControl: 'force' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Used MCP tools successfully', model: 'gpt-4o', tokens: { input: 20, output: 30, total: 50 }, toolCalls: [], timing: { total: 200 }, }) mockTransformBlockTool.mockImplementation((tool: { id?: string; operation?: string }) => ({ id: tool.schema?.function?.name || `mcp-${tool.title.toLowerCase().replace(' ', '-')}`, name: tool.schema?.function?.name || tool.title, description: tool.schema?.function?.description || `MCP tool: ${tool.title}`, parameters: tool.schema?.function?.parameters || { type: 'object', properties: {} }, usageControl: tool.usageControl, })) const result = await handler.execute(mockContext, mockBlock, inputs) expect(result).toBeDefined() expect(mockExecuteProviderRequest).toHaveBeenCalled() expect((result as any).content).toBe('Used MCP tools successfully') expect((result as any).model).toBe('gpt-4o') }) it('should provide workspaceId context for MCP tool execution', async () => { let capturedContext: any mockExecuteTool.mockImplementation((toolId, params, skipPostProcess, context) => { capturedContext = context if (isMcpTool(toolId)) { return Promise.resolve({ success: true, output: { content: [{ type: 'text', text: 'Success' }] }, }) } return Promise.resolve({ success: false, error: 'Unknown tool' }) }) mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Using MCP tool', model: 'gpt-4o', tokens: { input: 10, output: 10, total: 20 }, toolCalls: [{ name: 'mcp-test-tool', arguments: {} }], timing: { total: 50 }, }) const inputs = { model: 'gpt-4o', userPrompt: 'Test MCP context', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'Test Tool', schema: { function: { name: 'mcp-test-tool', description: 'Test MCP tool', parameters: { type: 'object', properties: {} }, }, }, usageControl: 'auto' as const, }, ], } const contextWithWorkspace = { ...mockContext, workspaceId: 'test-workspace-456', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(contextWithWorkspace, mockBlock, inputs) expect(contextWithWorkspace.workspaceId).toBe('test-workspace-456') }) it('should use cached schema for MCP tools (no discovery needed)', async () => { const fetchCalls: any[] = [] mockFetch.mockImplementation((url: string, options: any) => { fetchCalls.push({ url, options }) if (url.includes('/api/providers')) { return Promise.resolve({ ok: true, headers: { get: (name: string) => (name === 'Content-Type' ? 'application/json' : null), }, json: () => Promise.resolve({ content: 'Used MCP tool successfully', model: 'gpt-4o', tokens: { input: 10, output: 10, total: 20 }, toolCalls: [], timing: { total: 50 }, }), }) } if (url.includes('/api/mcp/tools/execute')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { output: { content: [{ type: 'text', text: 'Tool executed' }] } }, }), }) } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) }) const inputs = { model: 'gpt-4o', userPrompt: 'Use the MCP tool', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'list_files', schema: { type: 'object', properties: { path: { type: 'string', description: 'Directory path' }, }, required: ['path'], }, params: { serverId: 'mcp-server-123', toolName: 'list_files', serverName: 'filesystem', }, usageControl: 'auto' as const, }, ], } const contextWithWorkspace = { ...mockContext, workspaceId: 'test-workspace-123', workflowId: 'test-workflow-456', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(contextWithWorkspace, mockBlock, inputs) const discoveryCalls = fetchCalls.filter((c) => c.url.includes('/api/mcp/tools/discover')) expect(discoveryCalls.length).toBe(0) expect(mockExecuteProviderRequest).toHaveBeenCalled() }) it('should pass toolSchema to execution endpoint when using cached schema', async () => { let executionCall: any = null mockExecuteProviderRequest.mockResolvedValueOnce({ content: 'Tool executed', model: 'gpt-4o', tokens: { input: 10, output: 10, total: 20 }, toolCalls: [ { name: 'search_files', arguments: JSON.stringify({ query: 'test' }), }, ], timing: { total: 50 }, }) mockFetch.mockImplementation((url: string, options: any) => { if (url.includes('/api/mcp/tools/execute')) { executionCall = { url, body: JSON.parse(options.body) } return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { output: { content: [{ type: 'text', text: 'Search results' }] } }, }), }) } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) }) const cachedSchema = { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, }, required: ['query'], } const inputs = { model: 'gpt-4o', userPrompt: 'Search for files', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'search_files', schema: cachedSchema, params: { serverId: 'mcp-search-server', toolName: 'search_files', serverName: 'search', }, usageControl: 'auto' as const, }, ], } const contextWithWorkspace = { ...mockContext, workspaceId: 'test-workspace-123', workflowId: 'test-workflow-456', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(contextWithWorkspace, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCallArgs = mockExecuteProviderRequest.mock.calls[0] expect(providerCallArgs[1].tools).toBeDefined() expect(providerCallArgs[1].tools.length).toBe(1) expect(providerCallArgs[1].tools[0].name).toBe('search_files') }) it('should pass callChain to executeProviderRequest for MCP cycle detection', async () => { mockFetch.mockImplementation(() => Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) ) const inputs = { model: 'gpt-4o', userPrompt: 'Search for files', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'search_files', schema: { type: 'object', properties: { query: { type: 'string', description: 'Search query' }, }, required: ['query'], }, params: { serverId: 'mcp-search-server', toolName: 'search_files', serverName: 'search', }, usageControl: 'auto' as const, }, ], } const contextWithCallChain = { ...mockContext, workspaceId: 'test-workspace-123', workflowId: 'test-workflow-456', callChain: ['wf-parent', 'test-workflow-456'], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(contextWithCallChain, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCallArgs = mockExecuteProviderRequest.mock.calls[0][1] expect(providerCallArgs.callChain).toEqual(['wf-parent', 'test-workflow-456']) }) it('should handle multiple MCP tools from the same server efficiently', async () => { const fetchCalls: any[] = [] mockFetch.mockImplementation((url: string, options: any) => { fetchCalls.push({ url, options }) if (url.includes('/api/providers')) { return Promise.resolve({ ok: true, headers: { get: (name: string) => (name === 'Content-Type' ? 'application/json' : null), }, json: () => Promise.resolve({ content: 'Used tools', model: 'gpt-4o', tokens: { input: 10, output: 10, total: 20 }, toolCalls: [], timing: { total: 50 }, }), }) } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) }) const inputs = { model: 'gpt-4o', userPrompt: 'Use all the tools', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'tool_1', schema: { type: 'object', properties: {} }, params: { serverId: 'same-server', toolName: 'tool_1', serverName: 'server', }, usageControl: 'auto' as const, }, { type: 'mcp', title: 'tool_2', schema: { type: 'object', properties: {} }, params: { serverId: 'same-server', toolName: 'tool_2', serverName: 'server', }, usageControl: 'auto' as const, }, { type: 'mcp', title: 'tool_3', schema: { type: 'object', properties: {} }, params: { serverId: 'same-server', toolName: 'tool_3', serverName: 'server', }, usageControl: 'auto' as const, }, ], } const contextWithWorkspace = { ...mockContext, workspaceId: 'test-workspace-123', workflowId: 'test-workflow-456', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(contextWithWorkspace, mockBlock, inputs) const discoveryCalls = fetchCalls.filter((c) => c.url.includes('/api/mcp/tools/discover')) expect(discoveryCalls.length).toBe(0) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCallArgs = mockExecuteProviderRequest.mock.calls[0] expect(providerCallArgs[1].tools.length).toBe(3) }) it('should fallback to discovery for MCP tools without cached schema', async () => { const fetchCalls: any[] = [] mockFetch.mockImplementation((url: string, options: any) => { fetchCalls.push({ url, options }) if (url.includes('/api/mcp/tools/discover')) { return Promise.resolve({ ok: true, json: () => Promise.resolve({ success: true, data: { tools: [ { name: 'legacy_tool', description: 'A legacy tool without cached schema', inputSchema: { type: 'object', properties: {} }, serverName: 'legacy-server', }, ], }, }), }) } if (url.includes('/api/providers')) { return Promise.resolve({ ok: true, headers: { get: (name: string) => (name === 'Content-Type' ? 'application/json' : null), }, json: () => Promise.resolve({ content: 'Used legacy tool', model: 'gpt-4o', tokens: { input: 10, output: 10, total: 20 }, toolCalls: [], timing: { total: 50 }, }), }) } return Promise.resolve({ ok: true, json: () => Promise.resolve({}) }) }) const inputs = { model: 'gpt-4o', userPrompt: 'Use the legacy tool', apiKey: 'test-api-key', tools: [ { type: 'mcp', title: 'legacy_tool', params: { serverId: 'mcp-legacy-server', toolName: 'legacy_tool', serverName: 'legacy-server', }, usageControl: 'auto' as const, }, ], } const contextWithWorkspace = { ...mockContext, workspaceId: 'test-workspace-123', workflowId: 'test-workflow-456', } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(contextWithWorkspace, mockBlock, inputs) const discoveryCalls = fetchCalls.filter((c) => c.url.includes('/api/mcp/tools/discover')) expect(discoveryCalls.length).toBe(1) expect(discoveryCalls[0].url).toContain('serverId=mcp-legacy-server') }) describe('customToolId resolution - DB as source of truth', () => { const staleInlineSchema = { function: { name: 'formatReport', description: 'Formats a report', parameters: { type: 'object', properties: { title: { type: 'string', description: 'Report title' }, content: { type: 'string', description: 'Report content' }, }, required: ['title', 'content'], }, }, } const dbSchema = { function: { name: 'formatReport', description: 'Formats a report', parameters: { type: 'object', properties: { title: { type: 'string', description: 'Report title' }, content: { type: 'string', description: 'Report content' }, format: { type: 'string', description: 'Output format' }, }, required: ['title', 'content', 'format'], }, }, } const staleInlineCode = 'return { title, content };' const dbCode = 'return { title, content, format };' function mockDBForCustomTool(toolId: string) { mockGetCustomToolById.mockImplementation(({ toolId: id }: { toolId: string }) => { if (id === toolId) { return Promise.resolve({ id: toolId, title: 'formatReport', schema: dbSchema, code: dbCode, }) } return Promise.resolve(null) }) } function mockDBFailure() { mockGetCustomToolById.mockRejectedValue(new Error('DB connection failed')) } beforeEach(() => { Object.defineProperty(global, 'window', { value: undefined, writable: true, configurable: true, }) mockGetCustomToolById.mockReset() mockContext.userId = 'test-user' }) it('should always fetch latest schema from DB when customToolId is present', async () => { const toolId = 'custom-tool-123' mockDBForCustomTool(toolId) const inputs = { model: 'gpt-4o', userPrompt: 'Format a report', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', customToolId: toolId, title: 'formatReport', schema: staleInlineSchema, code: staleInlineCode, usageControl: 'auto' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(1) // DB schema wins over stale inline — includes format param expect(tools[0].parameters.required).toContain('format') expect(tools[0].parameters.properties).toHaveProperty('format') }) it('should fetch from DB when customToolId has no inline schema', async () => { const toolId = 'custom-tool-123' mockDBForCustomTool(toolId) const inputs = { model: 'gpt-4o', userPrompt: 'Format a report', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', customToolId: toolId, usageControl: 'auto' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(1) expect(tools[0].name).toBe('formatReport') expect(tools[0].parameters.required).toContain('format') }) it('should fall back to inline schema when DB fetch fails and inline exists', async () => { mockDBFailure() const inputs = { model: 'gpt-4o', userPrompt: 'Format a report', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', customToolId: 'custom-tool-123', title: 'formatReport', schema: staleInlineSchema, code: staleInlineCode, usageControl: 'auto' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(1) expect(tools[0].name).toBe('formatReport') expect(tools[0].parameters.required).not.toContain('format') }) it('should return null when DB fetch fails and no inline schema exists', async () => { mockDBFailure() const inputs = { model: 'gpt-4o', userPrompt: 'Format a report', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', customToolId: 'custom-tool-123', usageControl: 'auto' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(0) }) it('should use DB schema when customToolId resolves', async () => { const toolId = 'custom-tool-123' mockDBForCustomTool(toolId) const inputs = { model: 'gpt-4o', userPrompt: 'Format a report', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', customToolId: toolId, title: 'formatReport', schema: staleInlineSchema, code: staleInlineCode, usageControl: 'auto' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(1) expect(tools[0].name).toBe('formatReport') }) it('should not fetch from DB when no customToolId is present', async () => { const inputs = { model: 'gpt-4o', userPrompt: 'Use the tool', apiKey: 'test-api-key', tools: [ { type: 'custom-tool', title: 'formatReport', schema: staleInlineSchema, code: staleInlineCode, usageControl: 'auto' as const, }, ], } mockGetProviderFromModel.mockReturnValue('openai') await handler.execute(mockContext, mockBlock, inputs) expect(mockGetCustomToolById).not.toHaveBeenCalled() expect(mockExecuteProviderRequest).toHaveBeenCalled() const providerCall = mockExecuteProviderRequest.mock.calls[0] const tools = providerCall[1].tools expect(tools.length).toBe(1) expect(tools[0].name).toBe('formatReport') expect(tools[0].parameters.required).not.toContain('format') }) }) }) })