Skip to content

Commit 452de03

Browse files
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).
1 parent c786ada commit 452de03

3 files changed

Lines changed: 46 additions & 4 deletions

File tree

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

Lines changed: 34 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,13 +18,14 @@ 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 { executeTool, registerHandler } from './executor'
2729

2830
describe('copilot tool executor fallback', () => {
2931
beforeEach(() => {
@@ -59,6 +61,36 @@ describe('copilot tool executor fallback', () => {
5961
expect(result).toEqual({ success: true, output: { emails: [] } })
6062
})
6163

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

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

Lines changed: 8 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,
@@ -40,7 +40,13 @@ export async function executeTool(
4040
params: Record<string, unknown>,
4141
context: ToolExecutionContext
4242
): Promise<ToolExecutionResult> {
43-
const canUseRegisteredHandler = isKnownTool(toolId) && isSimExecuted(toolId)
43+
// Client-routed tools (e.g. run_workflow) are normally executed in the browser and never
44+
// reach this point in interactive mode. In headless mode (Mothership block, no browser) there
45+
// is no client to delegate to, so fall back to the registered server-side handler when one
46+
// exists — otherwise the call would route to executeAppTool and throw "Tool not found".
47+
const canUseRegisteredHandler =
48+
isKnownTool(toolId) &&
49+
(isSimExecuted(toolId) || (isClientExecuted(toolId) && hasHandler(toolId)))
4450
if (!canUseRegisteredHandler) {
4551
const appParams = buildAppToolParams(toolId, params, context)
4652
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)