diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index adeb6ce48da..61733f43a95 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -5,9 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' -const { isKnownTool, isSimExecuted } = vi.hoisted(() => ({ +const { isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({ isKnownTool: vi.fn(), isSimExecuted: vi.fn(), + isClientExecuted: vi.fn(), })) const { executeAppTool } = vi.hoisted(() => ({ @@ -17,17 +18,19 @@ const { executeAppTool } = vi.hoisted(() => ({ vi.mock('./router', () => ({ isKnownTool, isSimExecuted, + isClientExecuted, })) vi.mock('@/tools', () => ({ executeTool: executeAppTool, })) -import { executeTool } from './executor' +import { clearHandlers, executeTool, registerHandler } from './executor' describe('copilot tool executor fallback', () => { beforeEach(() => { vi.clearAllMocks() + clearHandlers() }) it('falls back to app tool executor for dynamic sim tools', async () => { @@ -59,6 +62,36 @@ describe('copilot tool executor fallback', () => { expect(result).toEqual({ success: true, output: { emails: [] } }) }) + it('uses the registered handler for client-routed tools when running headless (Mothership block)', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(false) + isClientExecuted.mockReturnValue(true) + + const runWorkflowHandler = vi.fn().mockResolvedValue({ success: true, output: { ran: true } }) + registerHandler('run_workflow', runWorkflowHandler) + + const context = { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'ws-1' } + const result = await executeTool('run_workflow', { workflow_input: {} }, context) + + expect(runWorkflowHandler).toHaveBeenCalledWith({ workflow_input: {} }, context) + expect(executeAppTool).not.toHaveBeenCalled() + expect(result).toEqual({ success: true, output: { ran: true } }) + }) + + it('falls back to app tool executor for client-routed tools with no registered handler', async () => { + isKnownTool.mockReturnValue(true) + isSimExecuted.mockReturnValue(false) + isClientExecuted.mockReturnValue(true) + executeAppTool.mockResolvedValue({ + success: false, + error: 'Tool not found: unknown_client_tool', + }) + + await executeTool('unknown_client_tool', {}, { userId: 'user-1' }) + + expect(executeAppTool).toHaveBeenCalledWith('unknown_client_tool', expect.any(Object)) + }) + it('converts function_execute timeout from seconds to milliseconds for copilot calls', async () => { isKnownTool.mockReturnValue(false) isSimExecuted.mockReturnValue(false) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index 084d046c027..d6f7d5caae8 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants' import { executeTool as executeAppTool } from '@/tools' -import { isKnownTool, isSimExecuted } from './router' +import { isClientExecuted, isKnownTool, isSimExecuted } from './router' import type { ToolCallDescriptor, ToolExecutionContext, @@ -35,12 +35,22 @@ export function hasHandler(toolId: string): boolean { return handlerRegistry.has(toolId) } +export function clearHandlers(): void { + handlerRegistry.clear() +} + export async function executeTool( toolId: string, params: Record, context: ToolExecutionContext ): Promise { - const canUseRegisteredHandler = isKnownTool(toolId) && isSimExecuted(toolId) + // Client-routed tools (e.g. run_workflow) are normally executed in the browser and never + // reach this point in interactive mode. In headless mode (Mothership block, no browser) there + // is no client to delegate to, so fall back to the registered server-side handler when one + // exists — otherwise the call would route to executeAppTool and throw "Tool not found". + const canUseRegisteredHandler = + isKnownTool(toolId) && + (isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId))) if (!canUseRegisteredHandler) { const appParams = buildAppToolParams(toolId, params, context) return executeAppTool(toolId, appParams) diff --git a/apps/sim/lib/copilot/tool-executor/router.ts b/apps/sim/lib/copilot/tool-executor/router.ts index 7c64490cb4c..46a6815cfd7 100644 --- a/apps/sim/lib/copilot/tool-executor/router.ts +++ b/apps/sim/lib/copilot/tool-executor/router.ts @@ -31,6 +31,10 @@ export function isGoExecuted(toolId: string): boolean { return getToolEntry(toolId)?.route === 'go' } +export function isClientExecuted(toolId: string): boolean { + return getToolEntry(toolId)?.route === 'client' +} + export function isKnownTool(toolId: string): boolean { return isToolInCatalog(toolId) }