diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index a96b049791..f1723ce26d 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -1,11 +1,11 @@ import type { - ServerTaskTypeEnum, ServerWorkflowCondition, ServerWorkflowStep, ServerWorkflowTask, } from './server-types'; import type { ConditionStepDefinition, StepDefinition } from '../types/validated/step-definition'; +import { ServerTaskTypeEnum } from './server-types'; import { InvalidStepDefinitionError, UnsupportedStepTypeError } from '../errors'; import { ConditionStepDefinitionSchema, @@ -18,48 +18,35 @@ import { UpdateRecordStepDefinitionSchema, } from '../types/validated/step-definition'; -const TASK_TYPE_TO_STEP_TYPE: Record = { - 'get-data': StepType.ReadRecord, - 'update-data': StepType.UpdateRecord, - 'trigger-action': StepType.TriggerAction, - 'load-related-record': StepType.LoadRelatedRecord, - 'mcp-server': StepType.Mcp, - guideline: StepType.Guidance, -}; - function mapTask(task: ServerWorkflowTask): StepDefinition { - const stepType = TASK_TYPE_TO_STEP_TYPE[task.taskType]; - - if (!stepType) { - throw new InvalidStepDefinitionError(`Unknown taskType: "${task.taskType}"`); - } - // executionType is passed through as-is; each schema's .default().catch() handles // missing or unsupported values without requiring an explicit mapping here. const base = { prompt: task.prompt, executionType: task.executionType }; - switch (stepType) { - case StepType.Mcp: + switch (task.taskType) { + case ServerTaskTypeEnum.McpServer: return McpStepDefinitionSchema.parse({ ...base, type: StepType.Mcp, - ...('mcpServerId' in task && { mcpServerId: task.mcpServerId }), + mcpServerId: task.mcpServerId, }); - case StepType.Guidance: + case ServerTaskTypeEnum.Guideline: return GuidanceStepDefinitionSchema.parse({ ...base, type: StepType.Guidance }); - case StepType.ReadRecord: + case ServerTaskTypeEnum.GetData: return ReadRecordStepDefinitionSchema.parse({ ...base, type: StepType.ReadRecord }); - case StepType.UpdateRecord: + case ServerTaskTypeEnum.UpdateData: return UpdateRecordStepDefinitionSchema.parse({ ...base, type: StepType.UpdateRecord }); - case StepType.TriggerAction: + case ServerTaskTypeEnum.TriggerAction: return TriggerActionStepDefinitionSchema.parse({ ...base, type: StepType.TriggerAction }); - case StepType.LoadRelatedRecord: + case ServerTaskTypeEnum.LoadRelatedRecord: return LoadRelatedRecordStepDefinitionSchema.parse({ ...base, type: StepType.LoadRelatedRecord, }); default: - throw new InvalidStepDefinitionError(`Unmapped step type: "${stepType}"`); + throw new InvalidStepDefinitionError( + `Unknown taskType: "${(task as { taskType: string }).taskType}"`, + ); } } diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 4a8038a610..828d2758fb 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -210,13 +210,11 @@ export class StepTimeoutError extends WorkflowExecutorError { } export class NoMcpToolsError extends WorkflowExecutorError { - constructor(requestedMcpServerId?: string, loadedMcpServerIds?: readonly string[]) { - const technical = requestedMcpServerId - ? `No MCP tools available for mcpServerId="${requestedMcpServerId}". Loaded MCP server ids: [${( - loadedMcpServerIds ?? [] - ).join(', ')}]` - : 'No MCP tools available'; - super(technical, 'No tools are available to execute this step.'); + constructor(requestedMcpServerId: string) { + super( + `No MCP tools available for mcpServerId="${requestedMcpServerId}"`, + 'Tools could not be loaded for the targeted server. Please try again, or contact your administrator if the problem persists.', + ); } } diff --git a/packages/workflow-executor/src/executors/mcp-step-executor.ts b/packages/workflow-executor/src/executors/mcp-step-executor.ts index 4477125809..063be3b022 100644 --- a/packages/workflow-executor/src/executors/mcp-step-executor.ts +++ b/packages/workflow-executor/src/executors/mcp-step-executor.ts @@ -82,7 +82,7 @@ export default class McpStepExecutor extends BaseStepExecutor } // Branches B & C -- First call - const tools = this.getFilteredTools(); + const tools = this.requireTools(); const { toolName, args } = await this.selectTool(tools); const selectedTool = tools.find(t => t.base.name === toolName); if (!selectedTool) throw new McpToolNotFoundError(toolName); @@ -107,7 +107,7 @@ export default class McpStepExecutor extends BaseStepExecutor target: McpToolCall, existingExecution?: McpStepExecutionData, ): Promise { - const tools = this.getFilteredTools(); + const tools = this.requireTools(); const tool = tools.find(t => t.base.name === target.name && t.sourceId === target.sourceId); if (!tool) throw new McpToolNotFoundError(target.name); @@ -225,27 +225,15 @@ export default class McpStepExecutor extends BaseStepExecutor ); } - private getFilteredTools(): RemoteTool[] { - const { mcpServerId } = this.context.stepDefinition; - const tools = mcpServerId - ? this.remoteTools.filter(t => t.mcpServerId === mcpServerId) - : [...this.remoteTools]; - - if (tools.length === 0) { - const loadedMcpServerIds = this.remoteTools - .map(t => t.mcpServerId) - .filter((value): value is string => !!value); - const error = new NoMcpToolsError(mcpServerId, loadedMcpServerIds); - this.context.logger.error(error.message, { - runId: this.context.runId, - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - requestedMcpServerId: mcpServerId, - loadedMcpServerIds, - }); - throw error; + // Tools are pre-scoped to step.mcpServerId upstream. An empty list means either no config + // matched, or the per-server connection failed at load time (McpClient swallows per-server + // errors). RemoteToolFetcher emits the diagnostic upstream; here we just surface the empty + // case as a domain error so BaseStepExecutor turns it into a step outcome. + private requireTools(): RemoteTool[] { + if (this.remoteTools.length === 0) { + throw new NoMcpToolsError(this.context.stepDefinition.mcpServerId); } - return tools; + return [...this.remoteTools]; } } diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts index 736b633215..e339ba83fb 100644 --- a/packages/workflow-executor/src/executors/step-executor-factory.ts +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -48,7 +48,7 @@ export default class StepExecutorFactory { step: AvailableStepExecution, contextConfig: StepContextConfig, activityLogPort: ActivityLogPort, - loadTools: () => Promise, + fetchRemoteTools: (mcpServerId: string) => Promise, incomingPendingData?: unknown, ): Promise { try { @@ -76,11 +76,16 @@ export default class StepExecutorFactory { return new LoadRelatedRecordStepExecutor( context as ExecutionContext, ); - case StepType.Mcp: + + case StepType.Mcp: { + const mcpContext = context as ExecutionContext; + return new McpStepExecutor( - context as ExecutionContext, - await loadTools(), + mcpContext, + await fetchRemoteTools(mcpContext.stepDefinition.mcpServerId), ); + } + case StepType.Guidance: return new GuidanceStepExecutor(context as ExecutionContext); default: diff --git a/packages/workflow-executor/src/remote-tool-fetcher.ts b/packages/workflow-executor/src/remote-tool-fetcher.ts new file mode 100644 index 0000000000..0d141ae2bc --- /dev/null +++ b/packages/workflow-executor/src/remote-tool-fetcher.ts @@ -0,0 +1,82 @@ +import type { AiModelPort } from './ports/ai-model-port'; +import type { Logger } from './ports/logger-port'; +import type { WorkflowPort } from './ports/workflow-port'; +import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; + +// Match by config.id, not by Record key: server names can collide across configs. +export function scopeConfigsToServer( + configs: Record, + mcpServerId: string, +): Record { + return Object.fromEntries(Object.entries(configs).filter(([, cfg]) => cfg.id === mcpServerId)); +} + +export default class RemoteToolFetcher { + private readonly workflowPort: WorkflowPort; + private readonly aiModelPort: AiModelPort; + private readonly logger: Logger; + + constructor(workflowPort: WorkflowPort, aiModelPort: AiModelPort, logger: Logger) { + this.workflowPort = workflowPort; + this.aiModelPort = aiModelPort; + this.logger = logger; + } + + async fetch(mcpServerId: string): Promise { + const configs = await this.workflowPort.getMcpServerConfigs(); + const scoped = scopeConfigsToServer(configs, mcpServerId); + + this.warnMissingTargetServer(configs, scoped, mcpServerId); + + if (Object.keys(scoped).length === 0) return []; + + const tools = await this.aiModelPort.loadRemoteTools(scoped); + + this.errorOnPartialLoadFailure(scoped, tools, mcpServerId); + + return tools; + } + + // Distinguish "no configs at all" (deployment misconfig) from "configs exist but none match" + // (orchestrator/executor drift on server id) — both yield zero tools, but ops need to know + // which one to fix. + private warnMissingTargetServer( + configs: Record, + scoped: Record, + mcpServerId: string, + ): void { + if (Object.keys(scoped).length > 0) return; + + const availableMcpServerIds = Object.values(configs) + .map(cfg => cfg.id) + .filter((id): id is string => Boolean(id)); + + this.logger.warn( + Object.keys(configs).length === 0 + ? 'MCP step targets a server but orchestrator returned no MCP configs' + : 'MCP step targets a server not advertised by the orchestrator', + { requestedMcpServerId: mcpServerId, availableMcpServerIds }, + ); + } + + // Partial-failure detection: McpClient swallows per-server load errors and returns whatever + // succeeded. Match config.id against tool.mcpServerId — both providers populate it from the + // orchestrator's persisted id, so the check is uniform across MCP and Forest connectors. + private errorOnPartialLoadFailure( + scoped: Record, + tools: RemoteTool[], + mcpServerId: string, + ): void { + const loadedMcpServerIds = new Set(tools.map(t => t.mcpServerId)); + const failedConfigNames = Object.entries(scoped) + .filter(([, cfg]) => !loadedMcpServerIds.has(cfg.id)) + .map(([name]) => name); + + if (failedConfigNames.length === 0) return; + + this.logger.error('MCP servers failed to load tools', { + requestedMcpServerId: mcpServerId, + failedConfigNames, + }); + } +} diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index ba8e423e23..c950b19f95 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -9,7 +9,6 @@ import type SchemaCache from './schema-cache'; import type { AvailableStepExecution, StepExecutionResult } from './types/execution-context'; import type { StepExecutionData } from './types/step-execution-data'; import type { StepOutcome } from './types/validated/step-outcome'; -import type { RemoteTool } from '@forestadmin/ai-proxy'; import ConsoleLogger from './adapters/console-logger'; import { DEFAULT_MAX_CHAIN_DEPTH, DEFAULT_STOP_TIMEOUT_MS } from './defaults'; @@ -22,6 +21,7 @@ import { } from './errors'; import StepExecutorFactory from './executors/step-executor-factory'; import InFlightRunRegistry from './in-flight-run-registry'; +import RemoteToolFetcher from './remote-tool-fetcher'; import { stepTypeToOutcomeType } from './types/validated/step-outcome'; import validateSecrets from './validate-secrets'; @@ -52,11 +52,17 @@ export default class Runner { private pollingTimer: NodeJS.Timeout | null = null; private readonly inFlightRuns = new InFlightRunRegistry(); private readonly logger: Logger; + private readonly remoteToolFetcher: RemoteToolFetcher; private _state: RunnerState = 'idle'; constructor(config: RunnerConfig) { this.config = config; this.logger = config.logger ?? new ConsoleLogger(); + this.remoteToolFetcher = new RemoteToolFetcher( + config.workflowPort, + config.aiModelPort, + this.logger, + ); } get state(): RunnerState { @@ -251,13 +257,6 @@ export default class Runner { } } - private async fetchRemoteTools(): Promise { - const configs = await this.config.workflowPort.getMcpServerConfigs(); - if (Object.keys(configs).length === 0) return []; - - return this.config.aiModelPort.loadRemoteTools(configs); - } - private executeStep( step: AvailableStepExecution, forestServerToken: string, @@ -295,7 +294,7 @@ export default class Runner { currentStep, this.contextConfig, this.config.activityLogPortFactory.forRun(currentToken), - () => this.fetchRemoteTools(), + mcpServerId => this.remoteToolFetcher.fetch(mcpServerId), currentIncomingData, ); result = await executor.execute(); diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 3413653d16..6c2916cb14 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -114,7 +114,7 @@ export const McpStepDefinitionSchema = z.object({ .enum([AutomatedWithConfirmation, FullyAutomated]) .default(AutomatedWithConfirmation) .catch(AutomatedWithConfirmation), - mcpServerId: z.string().optional(), + mcpServerId: z.string().min(1), }); export type McpStepDefinition = z.infer; diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts index a9a09d0929..594a8b8d07 100644 --- a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -112,14 +112,10 @@ describe('toStepDefinition', () => { }); }); - it('should map task with mcp-server taskType without mcpServerId', () => { + it('rejects an mcp-server task missing mcpServerId at the zod boundary', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.McpServer, prompt: 'run mcp' }); - expect(toStepDefinition(task)).toEqual({ - type: StepType.Mcp, - prompt: 'run mcp', - executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, - }); + expect(() => toStepDefinition(task)).toThrow(); }); it('should map task with guideline taskType to guidance', () => { diff --git a/packages/workflow-executor/test/errors.test.ts b/packages/workflow-executor/test/errors.test.ts index aa01a0423e..be03b9562d 100644 --- a/packages/workflow-executor/test/errors.test.ts +++ b/packages/workflow-executor/test/errors.test.ts @@ -70,38 +70,17 @@ describe('extractErrorMessage', () => { }); describe('NoMcpToolsError', () => { - it('produces a fully generic technical message when no mcpServerId was requested (no filter case)', () => { - const err = new NoMcpToolsError(); + it('includes the requested mcpServerId in the technical message', () => { + const err = new NoMcpToolsError('id-missing'); - expect(err.message).toBe('No MCP tools available'); - expect(err.userMessage).toBe('No tools are available to execute this step.'); + expect(err.message).toBe('No MCP tools available for mcpServerId="id-missing"'); }); - it('includes the requested mcpServerId in the technical message when a filter was active', () => { - const err = new NoMcpToolsError('id-missing', ['id-A', 'id-B']); + it('keeps the user-facing message free of internal ids', () => { + const err = new NoMcpToolsError('id-missing'); - expect(err.message).toMatch(/id-missing/); - }); - - it('lists the loaded mcpServerIds in the technical message so misconfigurations are diagnosable', () => { - const err = new NoMcpToolsError('id-missing', ['id-A', 'id-B']); - - expect(err.message).toMatch(/id-A/); - expect(err.message).toMatch(/id-B/); - }); - - it('handles an empty loaded-id list without producing a malformed message', () => { - const err = new NoMcpToolsError('id-missing', []); - - expect(err.message).toMatch(/id-missing/); - expect(err.message).not.toMatch(/undefined|null|\[object/i); - }); - - it('keeps the user-facing message generic — no internal ids must leak', () => { - const err = new NoMcpToolsError('id-missing', ['id-A', 'id-B']); - - expect(err.userMessage).toBe('No tools are available to execute this step.'); - expect(err.userMessage).not.toMatch(/id-missing|id-A|id-B/); + expect(err.userMessage).toMatch(/^Tools could not be loaded for the targeted server\./); + expect(err.userMessage).not.toMatch(/id-missing/); }); }); diff --git a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts index d77cbf67a8..fab6e8f0c4 100644 --- a/packages/workflow-executor/test/executors/mcp-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/mcp-step-executor.test.ts @@ -42,6 +42,7 @@ function makeStep(overrides: Partial = {}): McpStepDefinition type: StepType.Mcp, prompt: 'Send a notification to the user', executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'default-mcp-id', ...overrides, }; } @@ -444,90 +445,30 @@ describe('McpStepExecutor', () => { }); }); - describe('mcpServerId filter (matches by tool.mcpServerId, not tool.sourceId)', () => { - it('passes only tools whose mcpServerId matches step.mcpServerId to the AI', async () => { - const toolA = new MockRemoteTool({ - name: 'tool_a', - sourceId: 'zendesk', - mcpServerId: 'id-A', - }); - const toolB = new MockRemoteTool({ - name: 'tool_b', - sourceId: 'zendesk', - mcpServerId: 'id-B', - }); - const invokeFn = jest.fn().mockResolvedValue('ok'); - const toolB2 = new MockRemoteTool({ - name: 'tool_b2', - sourceId: 'zendesk', - mcpServerId: 'id-B', - invoke: invokeFn, - }); - - const { model, bindTools } = makeMockModel('tool_b', {}); - const runStore = makeMockRunStore(); + describe('forwards all provided remoteTools to the AI', () => { + // Tools are pre-scoped upstream — the executor must not re-filter. Mixing divergent + // mcpServerId values in the input asserts the executor passes every tool through, even + // ones that wouldn't match the step's mcpServerId on their own. + it('binds every tool it receives, including ones whose mcpServerId differs from the step', async () => { + const matchingTool = new MockRemoteTool({ name: 'tool_a', mcpServerId: 'id-A' }); + const offTargetTool = new MockRemoteTool({ name: 'tool_b', mcpServerId: 'id-B' }); + const { model, bindTools } = makeMockModel('tool_a', {}); const context = makeContext({ model, - runStore, stepDefinition: makeStep({ - mcpServerId: 'id-B', + mcpServerId: 'id-A', executionType: StepExecutionMode.FullyAutomated, }), }); - const executor = new McpStepExecutor(context, [toolA, toolB, toolB2]); - - await executor.execute(); - - const boundTools = bindTools.mock.calls[0][0] as Array<{ name: string }>; - const boundNames = boundTools.map(t => t.name); - expect(boundNames).not.toContain('tool_a'); - expect(boundNames).toContain('tool_b'); - expect(boundNames).toContain('tool_b2'); - }); - - it('does not match by sourceId — server-name collisions must not leak tools across configs', async () => { - const tool = new MockRemoteTool({ - name: 'tool_a', - sourceId: 'server-B', - mcpServerId: 'id-99', - }); - const context = makeContext({ - stepDefinition: makeStep({ mcpServerId: 'server-B' }), - }); - const executor = new McpStepExecutor(context, [tool]); - - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('No tools are available to execute this step.'); - }); - - it('returns all tools (no filter) when step.mcpServerId is absent', async () => { - const toolA = new MockRemoteTool({ - name: 'tool_a', - sourceId: 'server-A', - mcpServerId: 'id-A', - }); - const toolB = new MockRemoteTool({ - name: 'tool_b', - sourceId: 'server-B', - mcpServerId: 'id-B', - }); - const { model, bindTools } = makeMockModel('tool_a', {}); - const context = makeContext({ - model, - stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), - }); - const executor = new McpStepExecutor(context, [toolA, toolB]); + const executor = new McpStepExecutor(context, [matchingTool, offTargetTool]); await executor.execute(); const boundTools = bindTools.mock.calls[0][0] as Array<{ name: string }>; - const boundNames = boundTools.map(t => t.name); - expect(boundNames).toEqual(expect.arrayContaining(['tool_a', 'tool_b'])); + expect(boundTools.map(t => t.name)).toEqual(expect.arrayContaining(['tool_a', 'tool_b'])); }); - it('resolves a Forest-connector-backed tool when its mcpServerId is threaded through', async () => { + it('resolves a Forest-connector-backed tool end-to-end', async () => { const invokeFn = jest.fn().mockResolvedValue('done'); const forestTool = new MockRemoteTool({ name: 'zendesk_get_tickets', @@ -562,66 +503,41 @@ describe('McpStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('No tools are available to execute this step.'); - }); - - it('returns error when mcpServerId filter yields no tools', async () => { - const tool = new MockRemoteTool({ - name: 'tool_a', - sourceId: 'server-A', - mcpServerId: 'id-A', - }); - const context = makeContext({ - stepDefinition: makeStep({ mcpServerId: 'id-B' }), - }); - const executor = new McpStepExecutor(context, [tool]); - - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('No tools are available to execute this step.'); + expect(result.stepOutcome.error).toMatch( + /^Tools could not be loaded for the targeted server\./, + ); }); - it('keeps the user-facing error message generic regardless of the misconfigured mcpServerId', async () => { - const tool = new MockRemoteTool({ - name: 'tool_a', - sourceId: 'server-A', - mcpServerId: 'id-A', - }); + it('keeps the user-facing error message free of internal ids', async () => { const context = makeContext({ stepDefinition: makeStep({ mcpServerId: 'id-B' }) }); - const executor = new McpStepExecutor(context, [tool]); + const executor = new McpStepExecutor(context, []); const result = await executor.execute(); - expect(result.stepOutcome.error).toBe('No tools are available to execute this step.'); + expect(result.stepOutcome.error).toMatch( + /^Tools could not be loaded for the targeted server\./, + ); expect(result.stepOutcome.error).not.toMatch(/id-B/); }); - it('logs the technical message with the requested mcpServerId and loaded mcpServerIds when filter misses', async () => { + it('logs the technical message with the requested mcpServerId when tools are empty', async () => { const logger = { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; - const toolA = new MockRemoteTool({ - name: 'tool_a', - sourceId: 'server-A', - mcpServerId: 'id-A', - }); - const toolB = new MockRemoteTool({ - name: 'tool_b', - sourceId: 'server-B', - mcpServerId: 'id-B', - }); const context = makeContext({ logger, stepDefinition: makeStep({ mcpServerId: 'id-missing' }), }); - const executor = new McpStepExecutor(context, [toolA, toolB]); + const executor = new McpStepExecutor(context, []); await executor.execute(); + // BaseStepExecutor catches NoMcpToolsError and logs error.message (which encodes the + // requested mcpServerId) along with the step correlation context. expect(logger.error).toHaveBeenCalledWith( - expect.stringMatching(/id-missing/), + 'No MCP tools available for mcpServerId="id-missing"', expect.objectContaining({ - requestedMcpServerId: 'id-missing', - loadedMcpServerIds: expect.arrayContaining(['id-A', 'id-B']), + runId: expect.any(String), + stepId: expect.any(String), + stepIndex: expect.any(Number), }), ); }); @@ -761,7 +677,7 @@ describe('McpStepExecutor', () => { await expect(executor.execute()).resolves.toMatchObject({ stepOutcome: { status: 'error', - error: 'No tools are available to execute this step.', + error: expect.stringMatching(/^Tools could not be loaded for the targeted server\./), }, }); }); diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index 80f86192e4..82f02cc237 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -583,6 +583,7 @@ describe('workflow execution (integration)', () => { type: StepType.Mcp, executionType: StepExecutionMode.AutomatedWithConfirmation, prompt: 'Send a notification', + mcpServerId: 'mcp-1', }, }); @@ -590,7 +591,12 @@ describe('workflow execution (integration)', () => { getAvailableRun: jest .fn() .mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' } }), - getMcpServerConfigs: jest.fn().mockResolvedValue({ 'mcp-1': { url: 'http://fake' } }), + // Two configs but only one matches step.mcpServerId — the assertion below proves + // RemoteToolFetcher actually scopes the Record before calling loadRemoteTools. + getMcpServerConfigs: jest.fn().mockResolvedValue({ + 'mcp-server-1': { id: 'mcp-1', url: 'http://fake' }, + 'mcp-server-2': { id: 'mcp-2', url: 'http://other' }, + }), }); const { server, runStore } = createIntegrationSetup({ @@ -626,6 +632,10 @@ describe('workflow execution (integration)', () => { 'run-1', expect.objectContaining({ type: 'mcp', status: 'success' }), ); + // Scoping must reach the AI port — only the matching server is forwarded, not the full map. + expect(aiClient.loadRemoteTools).toHaveBeenCalledWith({ + 'mcp-server-1': expect.objectContaining({ id: 'mcp-1' }), + }); }); // ------------------------------------------------------------------------- diff --git a/packages/workflow-executor/test/remote-tool-fetcher.test.ts b/packages/workflow-executor/test/remote-tool-fetcher.test.ts new file mode 100644 index 0000000000..0beedc9f3e --- /dev/null +++ b/packages/workflow-executor/test/remote-tool-fetcher.test.ts @@ -0,0 +1,259 @@ +import type { AiModelPort } from '../src/ports/ai-model-port'; +import type { Logger } from '../src/ports/logger-port'; +import type { WorkflowPort } from '../src/ports/workflow-port'; +import type { RemoteTool, ToolConfig } from '@forestadmin/ai-proxy'; + +import RemoteToolFetcher, { scopeConfigsToServer } from '../src/remote-tool-fetcher'; + +function createMockWorkflowPort(): jest.Mocked> { + return { getMcpServerConfigs: jest.fn().mockResolvedValue({}) }; +} + +function createMockAiModelPort(): jest.Mocked> { + return { loadRemoteTools: jest.fn().mockResolvedValue([]) }; +} + +function createMockLogger(): jest.Mocked> { + return { info: jest.fn(), warn: jest.fn(), error: jest.fn() }; +} + +function makeRemoteTool(sourceId: string, mcpServerId?: string): RemoteTool { + return { sourceId, mcpServerId } as unknown as RemoteTool; +} + +function makeFetcher(overrides?: { + workflowPort?: Partial>>; + aiModelPort?: Partial>>; + logger?: jest.Mocked>; +}) { + const workflowPort = { ...createMockWorkflowPort(), ...overrides?.workflowPort }; + const aiModelPort = { ...createMockAiModelPort(), ...overrides?.aiModelPort }; + const logger = overrides?.logger ?? createMockLogger(); + const fetcher = new RemoteToolFetcher( + workflowPort as unknown as WorkflowPort, + aiModelPort as unknown as AiModelPort, + logger, + ); + + return { fetcher, workflowPort, aiModelPort, logger }; +} + +const cfg = (id: string | undefined): ToolConfig => + ({ id, url: 'https://x.example', type: 'http' as const, headers: {} } as unknown as ToolConfig); + +// --------------------------------------------------------------------------- +// scopeConfigsToServer (pure) +// --------------------------------------------------------------------------- + +describe('scopeConfigsToServer', () => { + it('keeps only entries whose config.id matches the requested mcpServerId', () => { + const configs = { 'srv-a': cfg('id-A'), 'srv-b': cfg('id-B') }; + + expect(scopeConfigsToServer(configs, 'id-A')).toEqual({ 'srv-a': cfg('id-A') }); + }); + + // Matching by Record key would let a renamed server collide with another config's id. + it('matches by config.id, not by Record key', () => { + const configs = { 'server-A': cfg('id-A'), 'server-B': cfg('server-A') }; + + expect(scopeConfigsToServer(configs, 'server-A')).toEqual({ + 'server-B': cfg('server-A'), + }); + }); + + it('returns an empty object when no config matches', () => { + const configs = { 'srv-a': cfg('id-A') }; + + expect(scopeConfigsToServer(configs, 'id-missing')).toEqual({}); + }); +}); + +// --------------------------------------------------------------------------- +// RemoteToolFetcher.fetch +// --------------------------------------------------------------------------- + +describe('RemoteToolFetcher.fetch', () => { + it('passes only the matching config to loadRemoteTools when mcpServerId is set', async () => { + const { fetcher, aiModelPort } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest + .fn() + .mockResolvedValue({ 'srv-a': cfg('id-A'), 'srv-b': cfg('id-B') }), + }, + }); + + await fetcher.fetch('id-A'); + + expect(aiModelPort.loadRemoteTools).toHaveBeenCalledWith({ 'srv-a': cfg('id-A') }); + }); + + it('returns an empty array and skips loadRemoteTools when the scoped Record is empty', async () => { + const { fetcher, aiModelPort } = makeFetcher({ + workflowPort: { getMcpServerConfigs: jest.fn().mockResolvedValue({}) }, + }); + + const tools = await fetcher.fetch('id-A'); + + expect(tools).toEqual([]); + expect(aiModelPort.loadRemoteTools).not.toHaveBeenCalled(); + }); + + it('warns about the missing target with the list of advertised ids when no config matches', async () => { + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest + .fn() + .mockResolvedValue({ 'srv-a': cfg('id-A'), 'srv-b': cfg('id-B') }), + }, + }); + + await fetcher.fetch('id-missing'); + + expect(logger.warn).toHaveBeenCalledWith( + 'MCP step targets a server not advertised by the orchestrator', + { requestedMcpServerId: 'id-missing', availableMcpServerIds: ['id-A', 'id-B'] }, + ); + }); + + it('warns distinctly when orchestrator returns no MCP configs at all', async () => { + const { fetcher, logger } = makeFetcher({ + workflowPort: { getMcpServerConfigs: jest.fn().mockResolvedValue({}) }, + }); + + await fetcher.fetch('id-A'); + + expect(logger.warn).toHaveBeenCalledWith( + 'MCP step targets a server but orchestrator returned no MCP configs', + { requestedMcpServerId: 'id-A', availableMcpServerIds: [] }, + ); + expect(logger.warn).not.toHaveBeenCalledWith( + 'MCP step targets a server not advertised by the orchestrator', + expect.anything(), + ); + }); + + it('does not warn about the missing target when the scoped Record is non-empty', async () => { + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'srv-a': cfg('id-A') }), + }, + }); + + await fetcher.fetch('id-A'); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('flags the scoped MCP config when no tool was loaded for its id', async () => { + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'srv-a': cfg('id-A') }), + }, + aiModelPort: { loadRemoteTools: jest.fn().mockResolvedValue([]) }, + }); + + await fetcher.fetch('id-A'); + + expect(logger.error).toHaveBeenCalledWith('MCP servers failed to load tools', { + requestedMcpServerId: 'id-A', + failedConfigNames: ['srv-a'], + }); + }); + + it('does not log a partial-failure error when a tool carries the scoped config id', async () => { + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'srv-a': cfg('id-A') }), + }, + aiModelPort: { + loadRemoteTools: jest.fn().mockResolvedValue([makeRemoteTool('srv-a', 'id-A')]), + }, + }); + + await fetcher.fetch('id-A'); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + // Forest integrations carry a hardcoded sourceId (e.g. 'zendesk'); the partial-failure check + // discriminates on tool.mcpServerId, which both providers populate from the orchestrator id. + it('does not flag a Forest connector whose sourceId differs from the Record key', async () => { + const forestConfig = { + id: 'id-zendesk', + isForestConnector: true as const, + integrationName: 'Zendesk', + } as unknown as ToolConfig; + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'zendesk-prod': forestConfig }), + }, + aiModelPort: { + loadRemoteTools: jest.fn().mockResolvedValue([makeRemoteTool('zendesk', 'id-zendesk')]), + }, + }); + + await fetcher.fetch('id-zendesk'); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('flags a Forest connector that fails to load entirely', async () => { + const forestConfig = { + id: 'id-zendesk', + isForestConnector: true as const, + integrationName: 'Zendesk', + } as unknown as ToolConfig; + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'zendesk-prod': forestConfig }), + }, + aiModelPort: { loadRemoteTools: jest.fn().mockResolvedValue([]) }, + }); + + await fetcher.fetch('id-zendesk'); + + expect(logger.error).toHaveBeenCalledWith('MCP servers failed to load tools', { + requestedMcpServerId: 'id-zendesk', + failedConfigNames: ['zendesk-prod'], + }); + }); + + it('returns the tools produced by loadRemoteTools verbatim', async () => { + const remoteTools = [makeRemoteTool('srv-a', 'id-A')]; + const { fetcher } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'srv-a': cfg('id-A') }), + }, + aiModelPort: { loadRemoteTools: jest.fn().mockResolvedValue(remoteTools) }, + }); + + const result = await fetcher.fetch('id-A'); + + expect(result).toBe(remoteTools); + }); + + it('propagates a rejection from loadRemoteTools without logging partial-failure', async () => { + const { fetcher, logger } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockResolvedValue({ 'srv-a': cfg('id-A') }), + }, + aiModelPort: { + loadRemoteTools: jest.fn().mockRejectedValue(new Error('MCP unreachable')), + }, + }); + + await expect(fetcher.fetch('id-A')).rejects.toThrow('MCP unreachable'); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('propagates a rejection from getMcpServerConfigs without calling loadRemoteTools', async () => { + const { fetcher, aiModelPort } = makeFetcher({ + workflowPort: { + getMcpServerConfigs: jest.fn().mockRejectedValue(new Error('orchestrator down')), + }, + }); + + await expect(fetcher.fetch('id-A')).rejects.toThrow('orchestrator down'); + expect(aiModelPort.loadRemoteTools).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index f578c5daa9..01af2c0d6e 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -126,7 +126,11 @@ function makeStepDefinition(stepType: StepType): StepDefinition { } if (stepType === StepType.Mcp) { - return { type: StepType.Mcp, executionType: StepExecutionMode.AutomatedWithConfirmation }; + return { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'default-mcp-id', + }; } if (stepType === StepType.Guidance) { @@ -1155,7 +1159,7 @@ describe('MCP lazy loading (via once thunk)', () => { expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); }); - it('passes the orchestrator Record-shape configs directly to loadRemoteTools', async () => { + it('skips loadRemoteTools when the orchestrator returns an empty Record', async () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ @@ -1167,10 +1171,7 @@ describe('MCP lazy loading (via once thunk)', () => { step, auth: { forestServerToken: 'test-forest-token' }, }); - const realConfigs = { - 'mcp-server-1': { url: 'https://mcp.example.com', type: 'http' as const, headers: {} }, - }; - workflowPort.getMcpServerConfigs.mockResolvedValue(realConfigs); + workflowPort.getMcpServerConfigs.mockResolvedValue({}); runner = new Runner( createRunnerConfig({ workflowPort, aiModelPort: aiClient as unknown as AiModelPort }), @@ -1178,38 +1179,262 @@ describe('MCP lazy loading (via once thunk)', () => { await runner.triggerPoll('run-1'); expect(workflowPort.getMcpServerConfigs).toHaveBeenCalledTimes(1); + expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); + // Distinguish the short-circuit from a regression that throws before reaching the guard: + // the step must actually have executed and reported a success outcome. + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ status: 'success' }), + ); + }); +}); + +describe('MCP fetch scoping', () => { + it('passes only the matching config to loadRemoteTools when step.mcpServerId is set', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-1', + stepType: StepType.Mcp, + stepDefinition: { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'id-A', + }, + }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + workflowPort.getMcpServerConfigs.mockResolvedValue({ + 'server-A': { id: 'id-A', url: 'https://a.example', type: 'http', headers: {} }, + 'server-B': { id: 'id-B', url: 'https://b.example', type: 'http', headers: {} }, + }); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiModelPort: aiClient as unknown as AiModelPort }), + ); + await runner.triggerPoll('run-1'); + expect(aiClient.loadRemoteTools).toHaveBeenCalledTimes(1); - expect(aiClient.loadRemoteTools).toHaveBeenCalledWith(realConfigs); + expect(aiClient.loadRemoteTools).toHaveBeenCalledWith({ + 'server-A': expect.objectContaining({ id: 'id-A' }), + }); }); - it('skips loadRemoteTools when the orchestrator returns an empty Record', async () => { + // Matching by Record key would let a renamed server collide with another config's id. + it('matches by config.id, not by the Record key (server name)', async () => { const workflowPort = createMockWorkflowPort(); const aiClient = createMockAiClient(); const step = makePendingStep({ runId: 'run-1', stepId: 'step-mcp-1', stepType: StepType.Mcp, + stepDefinition: { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + // mcpServerId resembles a server name from another entry, but must match by id. + mcpServerId: 'server-A', + }, }); workflowPort.getAvailableRun.mockResolvedValue({ step, auth: { forestServerToken: 'test-forest-token' }, }); - workflowPort.getMcpServerConfigs.mockResolvedValue({}); + workflowPort.getMcpServerConfigs.mockResolvedValue({ + 'server-A': { id: 'id-A', url: 'https://a.example', type: 'http', headers: {} }, + 'server-B': { id: 'server-A', url: 'https://b.example', type: 'http', headers: {} }, + }); runner = new Runner( createRunnerConfig({ workflowPort, aiModelPort: aiClient as unknown as AiModelPort }), ); await runner.triggerPoll('run-1'); + expect(aiClient.loadRemoteTools).toHaveBeenCalledWith({ + 'server-B': expect.objectContaining({ id: 'server-A' }), + }); + }); + + it('skips loadRemoteTools and warns with availableMcpServerIds when no config matches', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const logger = createMockLogger(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-1', + stepType: StepType.Mcp, + stepDefinition: { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'id-missing', + }, + }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + workflowPort.getMcpServerConfigs.mockResolvedValue({ + 'server-A': { id: 'id-A', url: 'https://a.example', type: 'http', headers: {} }, + 'server-B': { id: 'id-B', url: 'https://b.example', type: 'http', headers: {} }, + }); + + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiModelPort: aiClient as unknown as AiModelPort, + logger, + }), + ); + await runner.triggerPoll('run-1'); + expect(workflowPort.getMcpServerConfigs).toHaveBeenCalledTimes(1); expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); - // Distinguish the short-circuit from a regression that throws before reaching the guard: - // the step must actually have executed and reported a success outcome. - expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( - 'run-1', - expect.objectContaining({ status: 'success' }), + expect(logger.warn).toHaveBeenCalledWith( + 'MCP step targets a server not advertised by the orchestrator', + { + requestedMcpServerId: 'id-missing', + availableMcpServerIds: expect.arrayContaining(['id-A', 'id-B']), + }, ); }); + + it('warns distinctly when orchestrator returns no MCP configs at all', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const logger = createMockLogger(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-1', + stepType: StepType.Mcp, + stepDefinition: { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'id-A', + }, + }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + workflowPort.getMcpServerConfigs.mockResolvedValue({}); + + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiModelPort: aiClient as unknown as AiModelPort, + logger, + }), + ); + await runner.triggerPoll('run-1'); + + expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + 'MCP step targets a server but orchestrator returned no MCP configs', + { requestedMcpServerId: 'id-A', availableMcpServerIds: [] }, + ); + expect(logger.warn).not.toHaveBeenCalledWith( + 'MCP step targets a server not advertised by the orchestrator', + expect.anything(), + ); + }); + + // The diagnostic must not short-circuit dispatch — the executor is still constructed (and + // will surface NoMcpToolsError downstream). Asserting on executeSpy.mock.instances bypasses + // the global execute() spy to confirm the executor saw the (empty) tool list. + it('logs partial-failure and still dispatches to the executor when the scoped server loaded zero tools', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const logger = createMockLogger(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-1', + stepType: StepType.Mcp, + stepDefinition: { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'id-A', + }, + }); + workflowPort.getAvailableRun.mockResolvedValue({ + step, + auth: { forestServerToken: 'test-forest-token' }, + }); + workflowPort.getMcpServerConfigs.mockResolvedValue({ + 'server-A': { id: 'id-A', url: 'https://a.example', type: 'http', headers: {} }, + }); + aiClient.loadRemoteTools.mockResolvedValue([]); + + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiModelPort: aiClient as unknown as AiModelPort, + logger, + }), + ); + await runner.triggerPoll('run-1'); + + expect(logger.error).toHaveBeenCalledWith('MCP servers failed to load tools', { + requestedMcpServerId: 'id-A', + failedConfigNames: ['server-A'], + }); + expect(executeSpy).toHaveBeenCalledTimes(1); + const executorInstance = executeSpy.mock.instances[0]; + expect(executorInstance).toBeInstanceOf(McpStepExecutor); + expect( + (executorInstance as unknown as { remoteTools: readonly unknown[] }).remoteTools, + ).toEqual([]); + }); + + it('re-scopes loadRemoteTools per dispatch when chained MCP steps target different servers', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const mcpDef = (id: string) => + ({ + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: id, + } as const); + const initial = makePendingStep({ + runId: 'run-1', + stepId: 'step-0', + stepIndex: 0, + stepType: StepType.Mcp, + stepDefinition: mcpDef('id-A'), + }); + const chained = makePendingStep({ + runId: 'run-1', + stepId: 'step-1', + stepIndex: 1, + stepType: StepType.Mcp, + stepDefinition: mcpDef('id-B'), + }); + workflowPort.getAvailableRun.mockResolvedValueOnce({ + step: initial, + auth: { forestServerToken: 'token-0' }, + }); + workflowPort.updateStepExecution + .mockResolvedValueOnce({ step: chained, auth: { forestServerToken: 'token-1' } }) + .mockResolvedValueOnce(null); + workflowPort.getMcpServerConfigs.mockResolvedValue({ + 'server-A': { id: 'id-A', url: 'https://a.example', type: 'http', headers: {} }, + 'server-B': { id: 'id-B', url: 'https://b.example', type: 'http', headers: {} }, + }); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiModelPort: aiClient as unknown as AiModelPort }), + ); + await runner.triggerPoll('run-1'); + + expect(aiClient.loadRemoteTools).toHaveBeenCalledTimes(2); + expect(aiClient.loadRemoteTools).toHaveBeenNthCalledWith(1, { + 'server-A': expect.objectContaining({ id: 'id-A' }), + }); + expect(aiClient.loadRemoteTools).toHaveBeenNthCalledWith(2, { + 'server-B': expect.objectContaining({ id: 'id-B' }), + }); + }); }); // --------------------------------------------------------------------------- @@ -1289,17 +1514,24 @@ describe('StepExecutorFactory.create — factory', () => { expect(executor).toBeInstanceOf(LoadRelatedRecordStepExecutor); }); - it('dispatches McpTask steps to McpStepExecutor and calls loadTools', async () => { - const step = makePendingStep({ stepType: StepType.Mcp }); - const loadTools = jest.fn().mockResolvedValue([]); + it('dispatches McpTask steps to McpStepExecutor and forwards step.mcpServerId to fetchRemoteTools', async () => { + const step = makePendingStep({ + stepType: StepType.Mcp, + stepDefinition: { + type: StepType.Mcp, + executionType: StepExecutionMode.AutomatedWithConfirmation, + mcpServerId: 'srv-42', + }, + }); + const fetchRemoteTools = jest.fn().mockResolvedValue([]); const executor = await StepExecutorFactory.create( step, makeContextConfig(), makeRunLogger(), - loadTools, + fetchRemoteTools, ); expect(executor).toBeInstanceOf(McpStepExecutor); - expect(loadTools).toHaveBeenCalledTimes(1); + expect(fetchRemoteTools).toHaveBeenCalledWith('srv-42'); }); it('dispatches Guidance steps to GuidanceStepExecutor', async () => {