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.
This commit is contained in:
Theodore Li
2026-06-03 16:58:04 -04:00
committed by GitHub
parent cd66774960
commit 4b84f06108
3 changed files with 51 additions and 4 deletions
@@ -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)
+12 -2
View File
@@ -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<string, unknown>,
context: ToolExecutionContext
): Promise<ToolExecutionResult> {
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)
@@ -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)
}