From 452de03338490f6ef54ed464a43b2a7a1e6b42f8 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 3 Jun 2026 13:33:58 -0700 Subject: [PATCH 1/2] fix(mothership): run client-routed workflow tools server-side in headless execution Headless Mothership (Mothership block, no browser) could not run workflows. The run_workflow/run_workflow_until_block/run_block/run_from_block tools are registered with route 'client', so the executor gate (isSimExecuted) skipped their registered server handlers and fell through to executeAppTool, throwing 'Tool not found'. Interactive runs delegate these to the browser before reaching the executor, so only the headless path broke. Allow a client-routed tool to use its registered server handler when one exists, which only affects the four run tools (the only client-routed tools, all of which have server handlers). --- .../copilot/tool-executor/executor.test.ts | 36 +++++++++++++++++-- .../sim/lib/copilot/tool-executor/executor.ts | 10 ++++-- apps/sim/lib/copilot/tool-executor/router.ts | 4 +++ 3 files changed, 46 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index adeb6ce48da..6448f7455e0 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,13 +18,14 @@ const { executeAppTool } = vi.hoisted(() => ({ vi.mock('./router', () => ({ isKnownTool, isSimExecuted, + isClientExecuted, })) vi.mock('@/tools', () => ({ executeTool: executeAppTool, })) -import { executeTool } from './executor' +import { executeTool, registerHandler } from './executor' describe('copilot tool executor fallback', () => { beforeEach(() => { @@ -59,6 +61,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..b3e6d75c1cd 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, @@ -40,7 +40,13 @@ export async function executeTool( 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) } From 688c738806e3f5733b59e892a9dacf1ce3b1bdaa Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Wed, 3 Jun 2026 13:38:41 -0700 Subject: [PATCH 2/2] test(mothership): clear handler registry between executor tests Add clearHandlers() helper and reset the module-level handler registry in beforeEach so handlers registered in one test do not leak into the next. --- apps/sim/lib/copilot/tool-executor/executor.test.ts | 3 ++- apps/sim/lib/copilot/tool-executor/executor.ts | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts index 6448f7455e0..61733f43a95 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.test.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -25,11 +25,12 @@ vi.mock('@/tools', () => ({ executeTool: executeAppTool, })) -import { executeTool, registerHandler } 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 () => { diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts index b3e6d75c1cd..d6f7d5caae8 100644 --- a/apps/sim/lib/copilot/tool-executor/executor.ts +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -35,6 +35,10 @@ export function hasHandler(toolId: string): boolean { return handlerRegistry.has(toolId) } +export function clearHandlers(): void { + handlerRegistry.clear() +} + export async function executeTool( toolId: string, params: Record,