Skip to content

Commit 4b84f06

Browse files
fix(mothership): run client-routed workflow tools server-side in headless execution (#4870)
* 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). * 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.
1 parent cd66774 commit 4b84f06

3 files changed

Lines changed: 51 additions & 4 deletions

File tree

apps/sim/lib/copilot/tool-executor/executor.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,10 @@
55
import { beforeEach, describe, expect, it, vi } from 'vitest'
66
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
77

8-
const { isKnownTool, isSimExecuted } = vi.hoisted(() => ({
8+
const { isKnownTool, isSimExecuted, isClientExecuted } = vi.hoisted(() => ({
99
isKnownTool: vi.fn(),
1010
isSimExecuted: vi.fn(),
11+
isClientExecuted: vi.fn(),
1112
}))
1213

1314
const { executeAppTool } = vi.hoisted(() => ({
@@ -17,17 +18,19 @@ const { executeAppTool } = vi.hoisted(() => ({
1718
vi.mock('./router', () => ({
1819
isKnownTool,
1920
isSimExecuted,
21+
isClientExecuted,
2022
}))
2123

2224
vi.mock('@/tools', () => ({
2325
executeTool: executeAppTool,
2426
}))
2527

26-
import { executeTool } from './executor'
28+
import { clearHandlers, executeTool, registerHandler } from './executor'
2729

2830
describe('copilot tool executor fallback', () => {
2931
beforeEach(() => {
3032
vi.clearAllMocks()
33+
clearHandlers()
3134
})
3235

3336
it('falls back to app tool executor for dynamic sim tools', async () => {
@@ -59,6 +62,36 @@ describe('copilot tool executor fallback', () => {
5962
expect(result).toEqual({ success: true, output: { emails: [] } })
6063
})
6164

65+
it('uses the registered handler for client-routed tools when running headless (Mothership block)', async () => {
66+
isKnownTool.mockReturnValue(true)
67+
isSimExecuted.mockReturnValue(false)
68+
isClientExecuted.mockReturnValue(true)
69+
70+
const runWorkflowHandler = vi.fn().mockResolvedValue({ success: true, output: { ran: true } })
71+
registerHandler('run_workflow', runWorkflowHandler)
72+
73+
const context = { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'ws-1' }
74+
const result = await executeTool('run_workflow', { workflow_input: {} }, context)
75+
76+
expect(runWorkflowHandler).toHaveBeenCalledWith({ workflow_input: {} }, context)
77+
expect(executeAppTool).not.toHaveBeenCalled()
78+
expect(result).toEqual({ success: true, output: { ran: true } })
79+
})
80+
81+
it('falls back to app tool executor for client-routed tools with no registered handler', async () => {
82+
isKnownTool.mockReturnValue(true)
83+
isSimExecuted.mockReturnValue(false)
84+
isClientExecuted.mockReturnValue(true)
85+
executeAppTool.mockResolvedValue({
86+
success: false,
87+
error: 'Tool not found: unknown_client_tool',
88+
})
89+
90+
await executeTool('unknown_client_tool', {}, { userId: 'user-1' })
91+
92+
expect(executeAppTool).toHaveBeenCalledWith('unknown_client_tool', expect.any(Object))
93+
})
94+
6295
it('converts function_execute timeout from seconds to milliseconds for copilot calls', async () => {
6396
isKnownTool.mockReturnValue(false)
6497
isSimExecuted.mockReturnValue(false)

apps/sim/lib/copilot/tool-executor/executor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { toError } from '@sim/utils/errors'
33
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/execution/constants'
44
import { executeTool as executeAppTool } from '@/tools'
5-
import { isKnownTool, isSimExecuted } from './router'
5+
import { isClientExecuted, isKnownTool, isSimExecuted } from './router'
66
import type {
77
ToolCallDescriptor,
88
ToolExecutionContext,
@@ -35,12 +35,22 @@ export function hasHandler(toolId: string): boolean {
3535
return handlerRegistry.has(toolId)
3636
}
3737

38+
export function clearHandlers(): void {
39+
handlerRegistry.clear()
40+
}
41+
3842
export async function executeTool(
3943
toolId: string,
4044
params: Record<string, unknown>,
4145
context: ToolExecutionContext
4246
): Promise<ToolExecutionResult> {
43-
const canUseRegisteredHandler = isKnownTool(toolId) && isSimExecuted(toolId)
47+
// Client-routed tools (e.g. run_workflow) are normally executed in the browser and never
48+
// reach this point in interactive mode. In headless mode (Mothership block, no browser) there
49+
// is no client to delegate to, so fall back to the registered server-side handler when one
50+
// exists — otherwise the call would route to executeAppTool and throw "Tool not found".
51+
const canUseRegisteredHandler =
52+
isKnownTool(toolId) &&
53+
(isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId)))
4454
if (!canUseRegisteredHandler) {
4555
const appParams = buildAppToolParams(toolId, params, context)
4656
return executeAppTool(toolId, appParams)

apps/sim/lib/copilot/tool-executor/router.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@ export function isGoExecuted(toolId: string): boolean {
3131
return getToolEntry(toolId)?.route === 'go'
3232
}
3333

34+
export function isClientExecuted(toolId: string): boolean {
35+
return getToolEntry(toolId)?.route === 'client'
36+
}
37+
3438
export function isKnownTool(toolId: string): boolean {
3539
return isToolInCatalog(toolId)
3640
}

0 commit comments

Comments
 (0)