diff --git a/WORKFLOW-EXECUTOR-CONTRACT.md b/WORKFLOW-EXECUTOR-CONTRACT.md new file mode 100644 index 000000000..1313a0b13 --- /dev/null +++ b/WORKFLOW-EXECUTOR-CONTRACT.md @@ -0,0 +1,232 @@ +# Workflow Executor — Contract Types + +> Types exchanged between the **orchestrator (server)**, the **executor (agent-nodejs)**, and the **frontend**. +> Last updated: 2026-03-24 + +--- + +## 1. Polling + +**`GET /liana/v1/workflow-step-executions/pending?runId=`** + +The executor polls for the current pending step of a run. The server must return **one object** (not an array), or `null` if the run is not found. + +```typescript +interface PendingStepExecution { + runId: string; + stepId: string; + stepIndex: number; + baseRecordRef: RecordRef; + stepDefinition: StepDefinition; + previousSteps: Step[]; + userConfirmed?: boolean; // true = user confirmed a pending action on this step +} +``` + +> **`null` response** → executor throws `RunNotFoundError` → HTTP 404 returned to caller. + +### RecordRef + +Lightweight pointer to a specific record. + +```typescript +interface RecordRef { + collectionName: string; + recordId: Array; + stepIndex: number; // index of the workflow step that loaded this record +} +``` + +### Step + +History entry for an already-executed step (used in `previousSteps`). + +```typescript +interface Step { + stepDefinition: StepDefinition; + stepOutcome: StepOutcome; +} +``` + +### StepDefinition + +Discriminated union on `type`. + +```typescript +type StepDefinition = + | ConditionStepDefinition + | RecordTaskStepDefinition + | McpTaskStepDefinition; + +interface ConditionStepDefinition { + type: "condition"; + options: [string, ...string[]]; // at least one option required + prompt?: string; + aiConfigName?: string; +} + +interface RecordTaskStepDefinition { + type: "read-record" + | "update-record" + | "trigger-action" + | "load-related-record"; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; +} + +interface McpTaskStepDefinition { + type: "mcp-task"; + mcpServerId?: string; + prompt?: string; + aiConfigName?: string; + automaticExecution?: boolean; +} +``` + +### StepOutcome + +What the executor previously reported for each past step (used in `previousSteps`). + +```typescript +type StepOutcome = + | ConditionStepOutcome + | RecordTaskStepOutcome + | McpTaskStepOutcome; + +interface ConditionStepOutcome { + type: "condition"; + stepId: string; + stepIndex: number; + status: "success" | "error" | "manual-decision"; + selectedOption?: string; // present when status = "success" + error?: string; // present when status = "error" +} + +interface RecordTaskStepOutcome { + type: "record-task"; + stepId: string; + stepIndex: number; + status: "success" | "error" | "awaiting-input"; + error?: string; // present when status = "error" +} + +interface McpTaskStepOutcome { + type: "mcp-task"; + stepId: string; + stepIndex: number; + status: "success" | "error" | "awaiting-input"; + error?: string; // present when status = "error" +} +``` + +--- + +## 2. Step Result + +**`POST /liana/v1/workflow-step-executions//complete`** + +After executing a step, the executor posts the outcome back to the server. The body is one of the `StepOutcome` shapes above. + +> ⚠️ **NEVER contains client data** (field values, AI reasoning, etc.) — those stay in the `RunStore` on the client side. + +--- + +## 3. Pending Data + +Steps that require user input pause with `status: "awaiting-input"`. The frontend writes `pendingData` to unblock them via a dedicated endpoint on the executor HTTP server. + +> **TODO** — The pending-data write endpoint is not yet implemented. Route, method, and per-step-type body shapes are TBD (PRD-240). + +Once written, the frontend calls `POST /runs/:runId/trigger` and the executor resumes with `userConfirmed: true`. + +### update-record — user picks a field + value to write + +> **TODO** — Pending-data write endpoint TBD (PRD-240). + +```typescript +interface UpdateRecordPendingData { + name: string; // technical field name + displayName: string; // label shown in the UI + value: string; // value chosen by the user +} +``` + +### trigger-action — user confirmation only + +No payload required from the frontend. The executor selects the action and writes `pendingData` itself (action name + displayName) to the RunStore. The frontend just confirms: + +``` +POST /runs/:runId/trigger +``` + +### load-related-record — user picks the relation and/or the record + +The frontend can override **both** the relation (field) and the selected record. + +> **Current status** — The frontend cannot yet override the AI selection. The executor HTTP server does not yet expose the pending-data write endpoint. Until it is implemented, the executor writes the AI's pick directly into `selectedRecordId`. + +```typescript +// Written by the executor; overwritable by the frontend via the pending-data endpoint (TBD) +interface LoadRelatedRecordPendingData { + name: string; // technical relation name + displayName: string; // label shown in the UI + relatedCollectionName: string; // collection of the related record + suggestedFields?: string[]; // fields suggested for display + selectedRecordId: Array; // AI's pick; overwritten by the frontend via the pending-data endpoint +} +``` + +The executor initially writes the AI's pick into `selectedRecordId`. The pending-data endpoint overwrites it (and optionally `name`, `displayName`, `relatedCollectionName`) when the user changes the selection. + +#### Future endpoint — pending-data write (not yet implemented) + +> **TODO** — Route and method TBD (PRD-240). + +Request body: + +```typescript +{ + selectedRecordId?: Array; // record chosen by the user + name?: string; // relation changed + displayName?: string; // relation changed + relatedCollectionName?: string; // required if name is provided +} +``` + +Response: `204 No Content`. + +The frontend calls this endpoint **before** `POST /runs/:runId/trigger`. On the next poll, `userConfirmed: true` and the executor reads `selectedRecordId` from the RunStore. + +### mcp-task — user confirmation only + +No payload required from the frontend. The executor selects the tool and writes `pendingData` itself (tool name + input) to the RunStore. The frontend just confirms: + +``` +POST /runs/:runId/trigger +``` + +The executor resumes with `userConfirmed: true` and executes the pre-selected tool. + +--- + +## Flow Summary + +``` +Orchestrator ──► GET pending?runId=X ──► Executor + │ + executes step + │ + ┌───────────────┴───────────────┐ + needs input done + │ │ + status: awaiting-input POST /complete + │ (StepOutcome) + Frontend writes pendingData + to executor HTTP server TODO: route TBD + │ + POST /runs/:runId/trigger + (next poll: userConfirmed = true) + │ + Executor resumes +``` diff --git a/packages/ai-proxy/src/index.ts b/packages/ai-proxy/src/index.ts index c355e0f9b..6fa9cb86a 100644 --- a/packages/ai-proxy/src/index.ts +++ b/packages/ai-proxy/src/index.ts @@ -8,6 +8,7 @@ export { default as ProviderDispatcher } from './provider-dispatcher'; export * from './provider-dispatcher'; export * from './ai-client'; export * from './remote-tools'; +export { default as RemoteTool } from './remote-tool'; export * from './router'; export * from './mcp-client'; export * from './oauth-token-injector'; @@ -16,3 +17,9 @@ export * from './errors'; export function validMcpConfigurationOrThrow(mcpConfig: McpConfiguration) { return McpConfigChecker.check(mcpConfig); } + +export type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +export type { BaseMessage } from '@langchain/core/messages'; +export { HumanMessage, SystemMessage } from '@langchain/core/messages'; +export type { StructuredToolInterface } from '@langchain/core/tools'; +export { DynamicStructuredTool } from '@langchain/core/tools'; diff --git a/packages/ai-proxy/test/ai-client.test.ts b/packages/ai-proxy/test/ai-client.test.ts index 6c6929dd2..f926eecbf 100644 --- a/packages/ai-proxy/test/ai-client.test.ts +++ b/packages/ai-proxy/test/ai-client.test.ts @@ -23,9 +23,7 @@ describe('Model validation', () => { expect( () => new AiClient({ - aiConfigurations: [ - { name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4' }, - ], + aiConfigurations: [{ name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4' }], }), ).toThrow(AIModelNotSupportedError); }); @@ -34,9 +32,7 @@ describe('Model validation', () => { expect( () => new AiClient({ - aiConfigurations: [ - { name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }, - ], + aiConfigurations: [{ name: 'test', provider: 'openai', apiKey: 'dev', model: 'gpt-4o' }], }), ).not.toThrow(); }); @@ -143,9 +139,7 @@ describe('getModel', () => { 'Warn', expect.stringContaining("AI configuration 'non-existent' not found"), ); - expect(createBaseChatModelMock).toHaveBeenCalledWith( - expect.objectContaining({ name: 'gpt4' }), - ); + expect(createBaseChatModelMock).toHaveBeenCalledWith(expect.objectContaining({ name: 'gpt4' })); expect(result).toBe(fakeModel); }); }); diff --git a/packages/ai-proxy/test/errors.test.ts b/packages/ai-proxy/test/errors.test.ts index c0817275a..8cfab73fe 100644 --- a/packages/ai-proxy/test/errors.test.ts +++ b/packages/ai-proxy/test/errors.test.ts @@ -17,9 +17,9 @@ import { AIProviderError, AIProviderUnavailableError, AITooManyRequestsError, - AIUnauthorizedError, AIToolNotFoundError, AIToolUnprocessableError, + AIUnauthorizedError, McpConfigError, McpConflictError, McpConnectionError, diff --git a/packages/ai-proxy/test/langchain-adapter.test.ts b/packages/ai-proxy/test/langchain-adapter.test.ts index cc7ca89ae..1adfd64fe 100644 --- a/packages/ai-proxy/test/langchain-adapter.test.ts +++ b/packages/ai-proxy/test/langchain-adapter.test.ts @@ -90,15 +90,11 @@ describe('LangChainAdapter', () => { { role: 'assistant', content: '', - tool_calls: [ - { id: 'call_1', function: { name: 'my_tool', arguments: 'not-json' } }, - ], + tool_calls: [{ id: 'call_1', function: { name: 'my_tool', arguments: 'not-json' } }], }, ]), ).toThrow( - new AIBadRequestError( - "Invalid JSON in tool_calls arguments for tool 'my_tool': not-json", - ), + new AIBadRequestError("Invalid JSON in tool_calls arguments for tool 'my_tool': not-json"), ); }); }); @@ -256,5 +252,4 @@ describe('LangChainAdapter', () => { ); }); }); - }); diff --git a/packages/ai-proxy/test/provider-dispatcher.test.ts b/packages/ai-proxy/test/provider-dispatcher.test.ts index 7cefcf3dc..0138fa0fd 100644 --- a/packages/ai-proxy/test/provider-dispatcher.test.ts +++ b/packages/ai-proxy/test/provider-dispatcher.test.ts @@ -181,9 +181,7 @@ describe('ProviderDispatcher', () => { const thrown = await dispatcher.dispatch(buildBody()).catch(e => e); expect(thrown).toBeInstanceOf(AIProviderUnavailableError); - expect(thrown.message).toBe( - 'OpenAI server error (HTTP 500): Internal Server Error', - ); + expect(thrown.message).toBe('OpenAI server error (HTTP 500): Internal Server Error'); expect(thrown.provider).toBe('OpenAI'); expect(thrown.providerStatusCode).toBe(500); expect(thrown.baseBusinessErrorName).toBe('InternalServerError'); @@ -468,9 +466,7 @@ describe('ProviderDispatcher', () => { .catch(e => e); expect(thrown).toBeInstanceOf(AIProviderUnavailableError); - expect(thrown.message).toBe( - 'Anthropic server error (HTTP 503): Service Unavailable', - ); + expect(thrown.message).toBe('Anthropic server error (HTTP 503): Service Unavailable'); expect(thrown.provider).toBe('Anthropic'); expect(thrown.providerStatusCode).toBe(503); expect(thrown.baseBusinessErrorName).toBe('InternalServerError'); diff --git a/packages/workflow-executor/CLAUDE.md b/packages/workflow-executor/CLAUDE.md index 333bfdee1..a54d59988 100644 --- a/packages/workflow-executor/CLAUDE.md +++ b/packages/workflow-executor/CLAUDE.md @@ -42,7 +42,7 @@ Front ◀──▶ Orchestrator ◀──pull/push──▶ Executor ── ``` src/ -├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError +├── errors.ts # WorkflowExecutorError, MissingToolCallError, MalformedToolCallError, NoRecordsError, NoReadableFieldsError, NoWritableFieldsError, NoActionsError, StepPersistenceError, NoRelationshipFieldsError, RelatedRecordNotFoundError ├── runner.ts # Runner class — main entry point (start/stop/triggerPoll, HTTP server wiring) ├── types/ # Core type definitions (@draft) │ ├── step-definition.ts # StepType enum + step definition interfaces @@ -60,7 +60,10 @@ src/ ├── executors/ # Step executor implementations │ ├── base-step-executor.ts # Abstract base class (context injection + shared helpers) │ ├── condition-step-executor.ts # AI-powered condition step (chooses among options) -│ └── read-record-step-executor.ts # AI-powered record field reading step +│ ├── read-record-step-executor.ts # AI-powered record field reading step +│ ├── update-record-step-executor.ts # AI-powered record field update step (with confirmation flow) +│ ├── trigger-record-action-step-executor.ts # AI-powered action trigger step (with confirmation flow) +│ └── load-related-record-step-executor.ts # AI-powered relation loading step (with confirmation flow) ├── http/ # HTTP server (optional, for frontend data access) │ └── executor-http-server.ts # Koa server: GET /runs/:runId, POST /runs/:runId/trigger └── index.ts # Barrel exports @@ -73,7 +76,11 @@ src/ - **Privacy** — Zero client data leaves the client's infrastructure. `StepOutcome` is sent to the orchestrator and must NEVER contain client data. Privacy-sensitive information (e.g. AI reasoning) must stay in `StepExecutionData` (persisted in the RunStore, client-side only). - **Ports (IO injection)** — All external IO goes through injected port interfaces, keeping the core pure and testable. - **AI integration** — Uses `@langchain/core` (`BaseChatModel`, `DynamicStructuredTool`) for AI-powered steps. `ExecutionContext.model` is a `BaseChatModel`. +- **Error hierarchy** — All domain errors must extend `WorkflowExecutorError` (defined in `src/errors.ts`). This ensures executors can distinguish domain errors (caught → error outcome) from infrastructure errors (uncaught → propagate to caller). Never throw a plain `Error` for a domain error case. +- **Dual error messages** — `WorkflowExecutorError` carries two messages: `message` (technical, for dev logs) and `userMessage` (human-readable, surfaced to the Forest Admin UI via `stepOutcome.error`). The mapping happens in a single place: `base-step-executor.ts` uses `error.userMessage` when building the error outcome. When adding a new error subclass, always provide a distinct `userMessage` oriented toward end-users (no collection names, field names, or AI internals). If `userMessage` is omitted in the constructor call, it falls back to `message`. +- **displayName in AI tools** — All `DynamicStructuredTool` schemas and system message prompts must use `displayName`, never `fieldName`. `displayName` is a Forest Admin frontend feature that replaces the technical field/relation/action name with a product-oriented label configured by the Forest Admin admin. End users write their workflow prompts using these display names, not the underlying technical names. After an AI tool call returns display names, map them back to `fieldName`/`name` before using them in datasource operations (e.g. filtering record values, calling `getRecord`). - **No recovery/retry** — Once the executor returns a step result to the orchestrator, the step is considered executed. There is no mechanism to re-dispatch a step, so executors must NOT include recovery checks (e.g. checking the RunStore for cached results before executing). Each step executes exactly once. +- **Fetched steps must be executed** — Any step retrieved from the orchestrator via `getPendingStepExecutions()` must be executed. Silently discarding a fetched step (e.g. filtering it out by `runId` after fetching) violates the executor contract: the orchestrator assumes execution is guaranteed once the step is dispatched. The only valid filter before executing is deduplication via `inFlightSteps` (to avoid running the same step twice concurrently). ## Commands diff --git a/packages/workflow-executor/jest.config.ts b/packages/workflow-executor/jest.config.ts index d622773e8..695cb0997 100644 --- a/packages/workflow-executor/jest.config.ts +++ b/packages/workflow-executor/jest.config.ts @@ -1,8 +1,17 @@ /* eslint-disable import/no-relative-packages */ +import path from 'path'; + import jestConfig from '../../jest.config'; +// Jest < 30 doesn't resolve wildcard exports in package.json. +// @anthropic-ai/sdk uses "./lib/*" exports that need this workaround. +const anthropicSdkDir = path.dirname(require.resolve('@anthropic-ai/sdk')); + export default { ...jestConfig, collectCoverageFrom: ['/src/**/*.ts'], testMatch: ['/test/**/*.test.ts'], + moduleNameMapper: { + '^@anthropic-ai/sdk/(.*)$': `${anthropicSdkDir}/$1`, + }, }; diff --git a/packages/workflow-executor/package.json b/packages/workflow-executor/package.json index cf94a502e..4a6a93b0a 100644 --- a/packages/workflow-executor/package.json +++ b/packages/workflow-executor/package.json @@ -23,10 +23,10 @@ "test": "jest" }, "dependencies": { + "@forestadmin/ai-proxy": "1.6.1", "@forestadmin/agent-client": "1.4.13", "@forestadmin/forestadmin-client": "1.37.17", "@koa/router": "^13.1.0", - "@langchain/core": "1.1.33", "koa": "^3.0.1", "zod": "4.3.6" }, diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index cf8949a1a..963e7de4b 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -1,4 +1,10 @@ -import type { AgentPort } from '../ports/agent-port'; +import type { + AgentPort, + ExecuteActionQuery, + GetRecordQuery, + GetRelatedDataQuery, + UpdateRecordQuery, +} from '../ports/agent-port'; import type { CollectionSchema } from '../types/record'; import type { RemoteAgentClient, SelectOptions } from '@forestadmin/agent-client'; @@ -6,10 +12,10 @@ import { RecordNotFoundError } from '../errors'; function buildPkFilter( primaryKeyFields: string[], - recordId: Array, + id: Array, ): SelectOptions['filters'] { if (primaryKeyFields.length === 1) { - return { field: primaryKeyFields[0], operator: 'Equal', value: recordId[0] }; + return { field: primaryKeyFields[0], operator: 'Equal', value: id[0] }; } return { @@ -17,14 +23,14 @@ function buildPkFilter( conditions: primaryKeyFields.map((field, i) => ({ field, operator: 'Equal', - value: recordId[i], + value: id[i], })), }; } // agent-client methods (update, relation, action) still expect the pipe-encoded string format -function encodePk(recordId: Array): string { - return recordId.map(v => String(v)).join('|'); +function encodePk(id: Array): string { + return id.map(v => String(v)).join('|'); } function extractRecordId( @@ -46,44 +52,39 @@ export default class AgentClientAgentPort implements AgentPort { this.collectionSchemas = params.collectionSchemas; } - async getRecord(collectionName: string, recordId: Array, fieldNames?: string[]) { - const schema = this.resolveSchema(collectionName); - const records = await this.client.collection(collectionName).list>({ - filters: buildPkFilter(schema.primaryKeyFields, recordId), + async getRecord({ collection, id, fields }: GetRecordQuery) { + const schema = this.resolveSchema(collection); + const records = await this.client.collection(collection).list>({ + filters: buildPkFilter(schema.primaryKeyFields, id), pagination: { size: 1, number: 1 }, - ...(fieldNames?.length && { fields: fieldNames }), + ...(fields?.length && { fields }), }); if (records.length === 0) { - throw new RecordNotFoundError(collectionName, encodePk(recordId)); + throw new RecordNotFoundError(collection, encodePk(id)); } - return { collectionName, recordId, values: records[0] }; + return { collectionName: collection, recordId: id, values: records[0] }; } - async updateRecord( - collectionName: string, - recordId: Array, - values: Record, - ) { + async updateRecord({ collection, id, values }: UpdateRecordQuery) { const updatedRecord = await this.client - .collection(collectionName) - .update>(encodePk(recordId), values); + .collection(collection) + .update>(encodePk(id), values); - return { collectionName, recordId, values: updatedRecord }; + return { collectionName: collection, recordId: id, values: updatedRecord }; } - async getRelatedData( - collectionName: string, - recordId: Array, - relationName: string, - ) { - const relatedSchema = this.resolveSchema(relationName); + async getRelatedData({ collection, id, relation, limit, fields }: GetRelatedDataQuery) { + const relatedSchema = this.resolveSchema(relation); const records = await this.client - .collection(collectionName) - .relation(relationName, encodePk(recordId)) - .list>(); + .collection(collection) + .relation(relation, encodePk(id)) + .list>({ + ...(limit !== null && { pagination: { size: limit, number: 1 } }), + ...(fields?.length && { fields }), + }); return records.map(record => ({ collectionName: relatedSchema.collectionName, @@ -92,17 +93,11 @@ export default class AgentClientAgentPort implements AgentPort { })); } - async executeAction( - collectionName: string, - actionName: string, - recordIds: Array[], - ): Promise { - const encodedIds = recordIds.map(id => encodePk(id)); - const action = await this.client - .collection(collectionName) - .action(actionName, { recordIds: encodedIds }); - - return action.execute(); + async executeAction({ collection, action, id }: ExecuteActionQuery): Promise { + const encodedId = id?.length ? [encodePk(id)] : []; + const act = await this.client.collection(collection).action(action, { recordIds: encodedId }); + + return act.execute(); } private resolveSchema(collectionName: string): CollectionSchema { diff --git a/packages/workflow-executor/src/adapters/console-logger.ts b/packages/workflow-executor/src/adapters/console-logger.ts new file mode 100644 index 000000000..cbe989ab3 --- /dev/null +++ b/packages/workflow-executor/src/adapters/console-logger.ts @@ -0,0 +1,7 @@ +import type { Logger } from '../ports/logger-port'; + +export default class ConsoleLogger implements Logger { + error(message: string, context: Record): void { + console.error(JSON.stringify({ message, timestamp: new Date().toISOString(), ...context })); + } +} diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index 16037570b..47f45a6c1 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -9,6 +9,8 @@ import { ServerUtils } from '@forestadmin/forestadmin-client'; // TODO: finalize route paths with the team — these are placeholders const ROUTES = { pendingStepExecutions: '/liana/v1/workflow-step-executions/pending', + pendingStepExecutionForRun: (runId: string) => + `/liana/v1/workflow-step-executions/pending?runId=${encodeURIComponent(runId)}`, updateStepExecution: (runId: string) => `/liana/v1/workflow-step-executions/${runId}/complete`, collectionSchema: (collectionName: string) => `/liana/v1/collections/${collectionName}`, mcpServerConfigs: '/liana/mcp-server-configs-with-details', @@ -29,6 +31,14 @@ export default class ForestServerWorkflowPort implements WorkflowPort { ); } + async getPendingStepExecutionsForRun(runId: string): Promise { + return ServerUtils.query( + this.options, + 'get', + ROUTES.pendingStepExecutionForRun(runId), + ); + } + async updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise { await ServerUtils.query( this.options, diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index b835c391f..4560bafde 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -1,15 +1,28 @@ /* eslint-disable max-classes-per-file */ -export class WorkflowExecutorError extends Error { - constructor(message: string) { +export function causeMessage(error: unknown): string | undefined { + const { cause } = error as { cause?: unknown }; + + return cause instanceof Error ? cause.message : undefined; +} + +export abstract class WorkflowExecutorError extends Error { + readonly userMessage: string; + cause?: unknown; + + constructor(message: string, userMessage?: string) { super(message); this.name = this.constructor.name; + this.userMessage = userMessage ?? message; } } export class MissingToolCallError extends WorkflowExecutorError { constructor() { - super('AI did not return a tool call'); + super( + 'AI did not return a tool call', + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); } } @@ -17,14 +30,20 @@ export class MalformedToolCallError extends WorkflowExecutorError { readonly toolName: string; constructor(toolName: string, details: string) { - super(`AI returned a malformed tool call for "${toolName}": ${details}`); + super( + `AI returned a malformed tool call for "${toolName}": ${details}`, + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); this.toolName = toolName; } } export class RecordNotFoundError extends WorkflowExecutorError { constructor(collectionName: string, recordId: string) { - super(`Record not found: collection "${collectionName}", id "${recordId}"`); + super( + `Record not found: collection "${collectionName}", id "${recordId}"`, + 'The record no longer exists. It may have been deleted.', + ); } } @@ -36,12 +55,156 @@ export class NoRecordsError extends WorkflowExecutorError { export class NoReadableFieldsError extends WorkflowExecutorError { constructor(collectionName: string) { - super(`No readable fields on record from collection "${collectionName}"`); + super( + `No readable fields on record from collection "${collectionName}"`, + 'This record type has no readable fields configured in Forest Admin.', + ); } } export class NoResolvedFieldsError extends WorkflowExecutorError { constructor(fieldNames: string[]) { - super(`None of the requested fields could be resolved: ${fieldNames.join(', ')}`); + super( + `None of the requested fields could be resolved: ${fieldNames.join(', ')}`, + "The AI selected fields that don't exist on this record. Try rephrasing the step's prompt.", + ); + } +} + +export class NoWritableFieldsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super( + `No writable fields on record from collection "${collectionName}"`, + 'This record type has no editable fields configured in Forest Admin.', + ); + } +} + +export class NoActionsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super( + `No actions available on collection "${collectionName}"`, + 'No actions are available on this record.', + ); + } +} + +/** + * Thrown when a step's side effect succeeded (action/update/decision) + * but the resulting state could not be persisted to the RunStore. + */ +export class StepPersistenceError extends WorkflowExecutorError { + constructor(message: string, cause?: unknown) { + super(message, 'The step result could not be saved. Please retry.'); + if (cause !== undefined) this.cause = cause; + } +} + +export class NoRelationshipFieldsError extends WorkflowExecutorError { + constructor(collectionName: string) { + super( + `No relationship fields on record from collection "${collectionName}"`, + 'This record type has no relations configured in Forest Admin.', + ); + } +} + +export class RelatedRecordNotFoundError extends WorkflowExecutorError { + constructor(collectionName: string, relationName: string) { + super( + `No related record found for relation "${relationName}" on collection "${collectionName}"`, + 'The related record could not be found. It may have been deleted.', + ); + } +} + +/** Thrown when the AI returns a response that violates expected constraints (bad index, empty selection, unknown identifier, etc.). */ +export class InvalidAIResponseError extends WorkflowExecutorError { + constructor(message: string) { + super(message, "The AI made an unexpected choice. Try rephrasing the step's prompt."); + } +} + +/** Thrown when a named relation is not found in the collection schema. */ +export class RelationNotFoundError extends WorkflowExecutorError { + constructor(name: string, collectionName: string) { + super( + `Relation "${name}" not found in collection "${collectionName}"`, + "The AI selected a relation that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + } +} + +/** Thrown when a named field is not found in the collection schema. */ +export class FieldNotFoundError extends WorkflowExecutorError { + constructor(name: string, collectionName: string) { + super( + `Field "${name}" not found in collection "${collectionName}"`, + "The AI selected a field that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + } +} + +/** Thrown when a named action is not found in the collection schema. */ +export class ActionNotFoundError extends WorkflowExecutorError { + constructor(name: string, collectionName: string) { + super( + `Action "${name}" not found in collection "${collectionName}"`, + "The AI selected an action that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + } +} + +/** Thrown when step execution state is invalid (missing execution record, missing pending data, etc.). */ +export class StepStateError extends WorkflowExecutorError { + constructor(message: string) { + super(message, 'An unexpected error occurred while processing this step.'); + } +} + +export class NoMcpToolsError extends WorkflowExecutorError { + constructor() { + super('No MCP tools available', 'No tools are available to execute this step.'); + } +} + +export class McpToolNotFoundError extends WorkflowExecutorError { + constructor(name: string) { + super( + `MCP tool "${name}" not found`, + "The AI selected a tool that doesn't exist. Try rephrasing the step's prompt.", + ); + } +} + +export class AgentPortError extends WorkflowExecutorError { + constructor(operation: string, cause: unknown) { + super( + `Agent port "${operation}" failed: ${cause instanceof Error ? cause.message : String(cause)}`, + 'An error occurred while accessing your data. Please try again.', + ); + this.cause = cause; + } +} + +export class McpToolInvocationError extends WorkflowExecutorError { + constructor(toolName: string, cause: unknown) { + super( + `MCP tool "${toolName}" invocation failed: ${ + cause instanceof Error ? cause.message : String(cause) + }`, + 'The tool failed to execute. Please try again or contact your administrator.', + ); + this.cause = cause; + } +} + +export class RunNotFoundError extends Error { + cause?: unknown; + + constructor(runId: string, cause?: unknown) { + super(`Run "${runId}" not found or unavailable`); + this.name = 'RunNotFoundError'; + if (cause !== undefined) this.cause = cause; } } diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 2197843be..4472ff6ba 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -1,77 +1,177 @@ -import type { ExecutionContext, StepExecutionResult } from '../types/execution'; +import type { AgentPort } from '../ports/agent-port'; +import type { ExecutionContext, IStepExecutor, StepExecutionResult } from '../types/execution'; +import type { CollectionSchema, FieldSchema, RecordRef } from '../types/record'; import type { StepDefinition } from '../types/step-definition'; import type { StepExecutionData } from '../types/step-execution-data'; -import type { StepOutcome } from '../types/step-outcome'; -import type { AIMessage, BaseMessage } from '@langchain/core/messages'; -import type { DynamicStructuredTool } from '@langchain/core/tools'; +import type { BaseStepStatus } from '../types/step-outcome'; +import type { BaseMessage, StructuredToolInterface } from '@forestadmin/ai-proxy'; -import { SystemMessage } from '@langchain/core/messages'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; -import { MalformedToolCallError, MissingToolCallError } from '../errors'; -import { isExecutedStepOnExecutor } from '../types/step-execution-data'; +import { + InvalidAIResponseError, + MalformedToolCallError, + MissingToolCallError, + NoRecordsError, + StepStateError, + WorkflowExecutorError, +} from '../errors'; +import SafeAgentPort from './safe-agent-port'; +import StepSummaryBuilder from './summary/step-summary-builder'; -export default abstract class BaseStepExecutor { +type WithPendingData = StepExecutionData & { pendingData?: object }; + +export default abstract class BaseStepExecutor + implements IStepExecutor +{ protected readonly context: ExecutionContext; + protected readonly agentPort: AgentPort; + + protected readonly schemaCache = new Map(); + constructor(context: ExecutionContext) { this.context = context; + this.agentPort = new SafeAgentPort(context.agentPort); + } + + async execute(): Promise { + try { + return await this.doExecute(); + } catch (error) { + if (error instanceof WorkflowExecutorError) { + if (error.cause !== undefined) { + this.context.logger.error(error.message, { + runId: this.context.runId, + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + cause: error.cause instanceof Error ? error.cause.message : String(error.cause), + stack: error.cause instanceof Error ? error.cause.stack : undefined, + }); + } + + return this.buildOutcomeResult({ status: 'error', error: error.userMessage }); + } + + const { cause: errorCause } = error as { cause?: unknown }; + this.context.logger.error('Unexpected error during step execution', { + runId: this.context.runId, + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + error: error instanceof Error ? error.message : String(error), + cause: errorCause instanceof Error ? errorCause.message : undefined, + stack: error instanceof Error ? error.stack : undefined, + }); + + return this.buildOutcomeResult({ + status: 'error', + error: 'Unexpected error during step execution', + }); + } } - abstract execute(): Promise; + protected abstract doExecute(): Promise; + + /** Find a field by displayName first, then fallback to fieldName. */ + protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { + return ( + schema.fields.find(f => f.displayName === name) ?? + schema.fields.find(f => f.fieldName === name) + ); + } + + /** Builds a StepExecutionResult with the step-type-specific outcome shape. */ + protected abstract buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult; /** - * Returns a SystemMessage array summarizing previously executed steps. - * Empty array when there is no history. Ready to spread into a messages array. + * Shared confirmation flow for executors that require user approval before acting. + * Handles the find → guard → skipped → delegate pattern. */ - protected async buildPreviousStepsMessages(): Promise { - if (!this.context.history.length) return []; + protected async handleConfirmationFlow( + typeDiscriminator: string, + resolveAndExecute: (execution: TExec) => Promise, + ): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const execution = stepExecutions.find( + (e): e is TExec => + (e as TExec).type === typeDiscriminator && e.stepIndex === this.context.stepIndex, + ); + + if (!execution) { + throw new StepStateError( + `No execution record found for step at index ${this.context.stepIndex}`, + ); + } - const summary = await this.summarizePreviousSteps(); + if (!execution.pendingData) { + throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); + } - return [new SystemMessage(summary)]; + if (!this.context.userConfirmed) { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...execution, + executionResult: { skipped: true }, + } as StepExecutionData); + + return this.buildOutcomeResult({ status: 'success' }); + } + + return resolveAndExecute(execution); } /** - * Builds a text summary of previously executed steps for AI prompts. - * Correlates history entries (step + stepOutcome pairs) with execution data - * from the RunStore (matched by stepOutcome.stepIndex). - * When no execution data is available, falls back to StepOutcome details. + * Returns a SystemMessage array summarizing previously executed steps. + * Empty array when there is no history. Ready to spread into a messages array. */ - private async summarizePreviousSteps(): Promise { - const allStepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + protected async buildPreviousStepsMessages(): Promise { + if (!this.context.previousSteps.length) return []; - return this.context.history + const allStepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const summary = this.context.previousSteps .map(({ stepDefinition, stepOutcome }) => { const execution = allStepExecutions.find(e => e.stepIndex === stepOutcome.stepIndex); - return this.buildStepSummary(stepDefinition, stepOutcome, execution); + return StepSummaryBuilder.build(stepDefinition, stepOutcome, execution); }) .join('\n\n'); + + return [new SystemMessage(summary)]; } - private buildStepSummary( - step: StepDefinition, - stepOutcome: StepOutcome, - execution: StepExecutionData | undefined, - ): string { - const prompt = step.prompt ?? '(no prompt)'; - const header = `Step "${stepOutcome.stepId}" (index ${stepOutcome.stepIndex}):`; - const lines = [header, ` Prompt: ${prompt}`]; - - if (isExecutedStepOnExecutor(execution)) { - if (execution.executionParams !== undefined) { - lines.push(` Input: ${JSON.stringify(execution.executionParams)}`); - } + /** + * Binds multiple tools to the model, invokes it, and returns the selected tool name + args. + * Throws MalformedToolCallError or MissingToolCallError on invalid AI responses. + */ + protected async invokeWithTools>( + messages: BaseMessage[], + tools: StructuredToolInterface[], + ): Promise<{ toolName: string; args: T }> { + const modelWithTools = this.context.model.bindTools(tools, { tool_choice: 'any' }); + const response = await modelWithTools.invoke(messages); + const toolCall = response.tool_calls?.[0]; - if (execution.executionResult) { - lines.push(` Output: ${JSON.stringify(execution.executionResult)}`); + if (toolCall !== undefined) { + if (toolCall.args !== undefined && toolCall.args !== null) { + return { toolName: toolCall.name, args: toolCall.args as T }; } - } else { - const { stepId, stepIndex, type, ...historyDetails } = stepOutcome; - lines.push(` History: ${JSON.stringify(historyDetails)}`); + + throw new MalformedToolCallError(toolCall.name ?? 'unknown', 'args field is missing or null'); + } + + const invalidCall = response.invalid_tool_calls?.[0]; + + if (invalidCall) { + throw new MalformedToolCallError( + invalidCall.name ?? 'unknown', + invalidCall.error ?? 'no details available', + ); } - return lines.join('\n'); + throw new MissingToolCallError(); } /** @@ -82,29 +182,88 @@ export default abstract class BaseStepExecutor { - const modelWithTool = this.context.model.bindTools([tool], { tool_choice: 'any' }); - const response = await modelWithTool.invoke(messages); + return (await this.invokeWithTools(messages, [tool])).args; + } - return this.extractToolCallArgs(response); + /** Returns baseRecordRef + any related records loaded by previous steps. */ + protected async getAvailableRecordRefs(): Promise { + const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); + const relatedRecords = stepExecutions.flatMap(e => { + if ( + e.type === 'load-related-record' && + e.executionResult !== undefined && + 'record' in e.executionResult + ) { + return [e.executionResult.record]; + } + + return []; + }); + + return [this.context.baseRecordRef, ...relatedRecords]; } - /** - * Extracts the first tool call's args from an AI response. - * Throws if the AI returned a malformed tool call (invalid_tool_calls) or no tool call at all. - */ - private extractToolCallArgs>(response: AIMessage): T { - const toolCall = response.tool_calls?.[0]; - if (toolCall?.args) return toolCall.args as T; + /** Selects a record ref via AI when multiple are available, returns directly when only one. */ + protected async selectRecordRef( + records: RecordRef[], + prompt: string | undefined, + ): Promise { + if (records.length === 0) throw new NoRecordsError(); + if (records.length === 1) return records[0]; - const invalidCall = response.invalid_tool_calls?.[0]; + const identifiers = await Promise.all(records.map(r => this.toRecordIdentifier(r))); + const identifierTuple = identifiers as [string, ...string[]]; - if (invalidCall) { - throw new MalformedToolCallError( - invalidCall.name ?? 'unknown', - invalidCall.error ?? 'no details available', + const tool = new DynamicStructuredTool({ + name: 'select-record', + description: 'Select the most relevant record for this workflow step.', + schema: z.object({ + recordIdentifier: z.enum(identifierTuple), + }), + func: undefined, + }); + + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage( + 'You are an AI agent selecting the most relevant record for a workflow step.\n' + + 'Choose the record whose collection best matches the user request.\n' + + 'Pay attention to the collection name of each record.', + ), + new HumanMessage(prompt ?? 'Select the most relevant record.'), + ]; + + const { recordIdentifier } = await this.invokeWithTool<{ recordIdentifier: string }>( + messages, + tool, + ); + + const selectedIndex = identifiers.indexOf(recordIdentifier); + + if (selectedIndex === -1) { + throw new InvalidAIResponseError( + `AI selected record "${recordIdentifier}" which does not match any available record`, ); } - throw new MissingToolCallError(); + return records[selectedIndex]; + } + + /** Fetches a collection schema from WorkflowPort, with caching. */ + protected async getCollectionSchema(collectionName: string): Promise { + const cached = this.schemaCache.get(collectionName); + if (cached) return cached; + + const schema = await this.context.workflowPort.getCollectionSchema(collectionName); + this.schemaCache.set(collectionName, schema); + + return schema; + } + + /** Formats a record ref as "Step X - CollectionDisplayName #id". */ + protected async toRecordIdentifier(record: RecordRef): Promise { + const schema = await this.getCollectionSchema(record.collectionName); + + return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; } } diff --git a/packages/workflow-executor/src/executors/condition-step-executor.ts b/packages/workflow-executor/src/executors/condition-step-executor.ts index 217abdcff..43fd995e3 100644 --- a/packages/workflow-executor/src/executors/condition-step-executor.ts +++ b/packages/workflow-executor/src/executors/condition-step-executor.ts @@ -1,10 +1,11 @@ import type { StepExecutionResult } from '../types/execution'; import type { ConditionStepDefinition } from '../types/step-definition'; +import type { ConditionStepStatus } from '../types/step-outcome'; -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; +import { StepPersistenceError } from '../errors'; import BaseStepExecutor from './base-step-executor'; interface GatewayToolArgs { @@ -36,7 +37,22 @@ const GATEWAY_SYSTEM_PROMPT = `You are an AI agent selecting the correct option - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; export default class ConditionStepExecutor extends BaseStepExecutor { - async execute(): Promise { + protected buildOutcomeResult(outcome: { + status: ConditionStepStatus; + error?: string; + selectedOption?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'condition', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + ...outcome, + }, + }; + } + + protected async doExecute(): Promise { const { stepDefinition: step } = this.context; const tool = new DynamicStructuredTool({ @@ -62,50 +78,28 @@ export default class ConditionStepExecutor extends BaseStepExecutor(messages, tool); + const { option: selectedOption, reasoning } = args; try { - args = await this.invokeWithTool(messages, tool); - } catch (error: unknown) { - return { - stepOutcome: { - type: 'condition', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'error', - error: (error as Error).message, - }, - }; + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'condition', + stepIndex: this.context.stepIndex, + executionParams: { answer: selectedOption, reasoning }, + executionResult: selectedOption ? { answer: selectedOption } : undefined, + }); + } catch (cause) { + throw new StepPersistenceError( + `Condition step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); } - const { option: selectedOption, reasoning } = args; - - await this.context.runStore.saveStepExecution(this.context.runId, { - type: 'condition', - stepIndex: this.context.stepIndex, - executionParams: { answer: selectedOption, reasoning }, - executionResult: selectedOption ? { answer: selectedOption } : undefined, - }); - if (!selectedOption) { - return { - stepOutcome: { - type: 'condition', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'manual-decision', - }, - }; + return this.buildOutcomeResult({ status: 'manual-decision' }); } - return { - stepOutcome: { - type: 'condition', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'success', - selectedOption, - }, - }; + return this.buildOutcomeResult({ status: 'success', selectedOption }); } } diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts new file mode 100644 index 000000000..998157bb6 --- /dev/null +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -0,0 +1,413 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { CollectionSchema, RecordData, RecordRef } from '../types/record'; +import type { RecordTaskStepDefinition } from '../types/step-definition'; +import type { LoadRelatedRecordStepExecutionData, RelationRef } from '../types/step-execution-data'; + +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; + +import { + InvalidAIResponseError, + NoRelationshipFieldsError, + RelatedRecordNotFoundError, + RelationNotFoundError, + StepPersistenceError, + StepStateError, +} from '../errors'; +import RecordTaskStepExecutor from './record-task-step-executor'; + +const SELECT_RELATION_SYSTEM_PROMPT = `You are an AI agent loading a related record based on a user request. +Select the relation to follow. + +Important rules: +- Be precise: only select the relation directly relevant to the request. +- Final answer is definitive, you won't receive any other input from the user. +- Do not refer to yourself as "I" in the response, use a passive formulation instead.`; + +const SELECT_FIELDS_SYSTEM_PROMPT = `You are an AI agent selecting the most relevant fields to identify a related record. +Choose the fields that are most useful for determining which record best matches the user request.`; + +const SELECT_RECORD_SYSTEM_PROMPT = `You are an AI agent selecting the most relevant related record from a list of candidates. +Choose the record that best matches the user request based on the provided field values.`; + +interface RelationTarget extends RelationRef { + selectedRecordRef: RecordRef; + relationType?: 'BelongsTo' | 'HasMany' | 'HasOne'; +} + +export default class LoadRelatedRecordStepExecutor extends RecordTaskStepExecutor { + protected async doExecute(): Promise { + // Branch A -- Re-entry with user confirmation + if (this.context.userConfirmed !== undefined) { + return this.handleConfirmation(); + } + + // Branches B & C -- First call + return this.handleFirstCall(); + } + + private async handleConfirmation(): Promise { + return this.handleConfirmationFlow( + 'load-related-record', + async execution => this.resolveFromSelection(execution), + ); + } + + private async handleFirstCall(): Promise { + const { stepDefinition: step } = this.context; + const records = await this.getAvailableRecordRefs(); + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectRelation(schema, step.prompt); + const target = this.buildTarget(schema, args.relationName, selectedRecordRef); + + // Branch B -- automaticExecution + if (step.automaticExecution) { + return this.resolveAndLoadAutomatic(target); + } + + // Branch C -- pre-fetch candidates, await user confirmation + return this.saveAndAwaitInput(target); + } + + private buildTarget( + schema: CollectionSchema, + relationName: string, + selectedRecordRef: RecordRef, + ): RelationTarget { + const field = this.findField(schema, relationName); + + if (!field) { + throw new RelationNotFoundError(relationName, schema.collectionName); + } + + return { + selectedRecordRef, + displayName: field.displayName, + name: field.fieldName, + relationType: field.relationType, + }; + } + + /** + * Branch C: uses AI to select the best candidate, persists pendingData with suggestion, returns awaiting-input. + * Unlike persistAndReturn (Branches A/B), storage errors propagate directly here: + * the relation-load has not yet happened so the step can safely be retried. + */ + private async saveAndAwaitInput(target: RelationTarget): Promise { + const { selectedRecordRef, name, displayName } = target; + + const { relatedData, bestIndex, suggestedFields } = await this.selectBestFromRelatedData( + target, + 50, + ); + + const relatedCollectionName = relatedData[0].collectionName; + const selectedRecordId = relatedData[bestIndex].recordId; + + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'load-related-record', + stepIndex: this.context.stepIndex, + pendingData: { displayName, name, relatedCollectionName, suggestedFields, selectedRecordId }, + selectedRecordRef, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + /** Branch B: automatic execution. HasMany uses 2 AI calls; others take the first result. */ + private async resolveAndLoadAutomatic(target: RelationTarget): Promise { + const record = + target.relationType === 'HasMany' + ? await this.selectBestRelatedRecord(target) + : await this.fetchFirstCandidate(target); + + return this.persistAndReturn(record, target, undefined); + } + + /** + * Branch A: builds RecordRef from pendingData.selectedRecordId. + * No additional getRelatedData call. + */ + private async resolveFromSelection( + execution: LoadRelatedRecordStepExecutionData, + ): Promise { + const { selectedRecordRef, pendingData } = execution; + + if (!pendingData) { + throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); + } + + const { name, displayName, relatedCollectionName, selectedRecordId } = pendingData; + + const record: RecordRef = { + collectionName: relatedCollectionName, + recordId: selectedRecordId, + stepIndex: this.context.stepIndex, + }; + + return this.persistAndReturn(record, { selectedRecordRef, name, displayName }, execution); + } + + /** + * Fetches up to `limit` related records and uses AI to select the best one when multiple exist. + * Returns the full RecordData array, the best index, and the AI-selected fields. + */ + private async selectBestFromRelatedData( + target: Pick, + limit: number, + ): Promise<{ relatedData: RecordData[]; bestIndex: number; suggestedFields: string[] }> { + const { selectedRecordRef, name } = target; + + const relatedData = await this.agentPort.getRelatedData({ + collection: selectedRecordRef.collectionName, + id: selectedRecordRef.recordId, + relation: name, + limit, + }); + + if (relatedData.length === 0) { + throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); + } + + if (relatedData.length === 1) { + return { relatedData, bestIndex: 0, suggestedFields: [] }; + } + + const relatedSchema = await this.getCollectionSchema(relatedData[0].collectionName); + const suggestedFields = await this.selectRelevantFields( + relatedSchema, + this.context.stepDefinition.prompt, + ); + const bestIndex = await this.selectBestRecordIndex( + relatedData, + suggestedFields, + this.context.stepDefinition.prompt, + ); + + return { relatedData, bestIndex, suggestedFields }; + } + + /** HasMany + automaticExecution: fetch top 50, then AI calls to select the best record. */ + private async selectBestRelatedRecord(target: RelationTarget): Promise { + const { relatedData, bestIndex } = await this.selectBestFromRelatedData(target, 50); + + return this.toRecordRef(relatedData[bestIndex]); + } + + /** BelongsTo / HasOne: fetch 1 record and take it directly. */ + private async fetchFirstCandidate(target: RelationTarget): Promise { + const candidates = await this.fetchCandidates(target, 1); + + return candidates[0]; + } + + /** + * Fetches related records and converts them to RecordRefs. + * Throws RelatedRecordNotFoundError when the result is empty. + */ + private async fetchCandidates( + target: Pick, + limit: number, + ): Promise { + const { selectedRecordRef, name } = target; + const relatedData = await this.agentPort.getRelatedData({ + collection: selectedRecordRef.collectionName, + id: selectedRecordRef.recordId, + relation: name, + limit, + }); + + if (relatedData.length === 0) { + throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); + } + + return relatedData.map(r => this.toRecordRef(r)); + } + + /** Persists the loaded record ref and returns a success outcome. */ + private async persistAndReturn( + record: RecordRef, + target: Pick, + existingExecution: LoadRelatedRecordStepExecutionData | undefined, + ): Promise { + const { selectedRecordRef, name, displayName } = target; + + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'load-related-record', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { relation: { name, displayName }, record }, + selectedRecordRef, + }); + } catch (cause) { + throw new StepPersistenceError( + `Related record loaded but step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'success' }); + } + + private async selectRelation( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise<{ relationName: string; reasoning: string }> { + const tool = this.buildSelectRelationTool(schema); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(SELECT_RELATION_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Load the relevant related record.'}`), + ]; + + return this.invokeWithTool<{ relationName: string; reasoning: string }>(messages, tool); + } + + private buildSelectRelationTool(schema: CollectionSchema): DynamicStructuredTool { + const relationFields = schema.fields.filter(f => f.isRelationship); + + if (relationFields.length === 0) { + throw new NoRelationshipFieldsError(schema.collectionName); + } + + const displayNames = relationFields.map(f => f.displayName) as [string, ...string[]]; + const technicalNames = relationFields + .map(f => `${f.displayName} (technical name: ${f.fieldName})`) + .join(', '); + + return new DynamicStructuredTool({ + name: 'select-relation', + description: 'Select the relation to follow from the record.', + schema: z.object({ + relationName: z + .enum(displayNames) + .describe(`The name of the relation to follow. Available: ${technicalNames}`), + reasoning: z.string().describe('Why this relation was chosen'), + }), + func: undefined, + }); + } + + /** AI call 1 for HasMany: selects the most relevant fields to compare candidates. */ + private async selectRelevantFields( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise { + const nonRelationFields = schema.fields.filter(f => !f.isRelationship); + + if (nonRelationFields.length === 0) return []; + + // Use displayName in both the enum and the prompt for consistency — the AI sees human-readable + // names throughout. Results are mapped back to technical fieldNames before returning. + const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; + + const tool = new DynamicStructuredTool({ + name: 'select-fields', + description: 'Select the most relevant fields to identify the right record.', + schema: z.object({ + fieldNames: z + .array(z.enum(displayNames)) + .min(1) + .describe('Field names most useful for identifying the relevant record'), + }), + func: undefined, + }); + + const messages = [ + new SystemMessage(SELECT_FIELDS_SYSTEM_PROMPT), + new SystemMessage( + `The related records are from the "${schema.collectionDisplayName}" collection. ` + + `Available fields: ${nonRelationFields.map(f => f.displayName).join(', ')}.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Select the most relevant record.'}`), + ]; + + const { fieldNames: selectedDisplayNames } = await this.invokeWithTool<{ + fieldNames: string[]; + }>(messages, tool); + + // Zod's .min(1) shapes the prompt but is NOT validated against the AI response. + // Guard explicitly to avoid silently passing all fields to selectBestRecordIndex. + if (selectedDisplayNames.length === 0) { + throw new InvalidAIResponseError( + `AI returned no field names for field selection in collection "${schema.collectionName}"`, + ); + } + + // Map display names back to technical field names — values in RecordData are keyed by fieldName. + return selectedDisplayNames.map( + dn => nonRelationFields.find(f => f.displayName === dn)?.fieldName ?? dn, + ); + } + + /** AI call 2 for HasMany: selects the best record by index from the candidate list. */ + private async selectBestRecordIndex( + candidates: RecordData[], + fieldNames: string[], + prompt: string | undefined, + ): Promise { + const maxIndex = candidates.length - 1; + const filteredCandidates = candidates.map((c, i) => ({ + index: i, + values: + fieldNames.length > 0 + ? Object.fromEntries(Object.entries(c.values).filter(([k]) => fieldNames.includes(k))) + : c.values, + })); + + const tool = new DynamicStructuredTool({ + name: 'select-record-by-content', + description: 'Select the most relevant related record by its index.', + schema: z.object({ + recordIndex: z + .number() + .int() + .min(0) + .max(maxIndex) + .describe(`0-based index of the most relevant record (0 to ${maxIndex})`), + reasoning: z.string().describe('Why this record was chosen'), + }), + func: undefined, + }); + + const recordList = filteredCandidates + .map(c => `[${c.index}] ${JSON.stringify(c.values)}`) + .join('\n'); + + const messages = [ + new SystemMessage(SELECT_RECORD_SYSTEM_PROMPT), + new SystemMessage(`Candidates:\n${recordList}`), + new HumanMessage(`**Request**: ${prompt ?? 'Select the most relevant record.'}`), + ]; + + const { recordIndex } = await this.invokeWithTool<{ recordIndex: number; reasoning: string }>( + messages, + tool, + ); + + // NOTE: The Zod schema's .min(0).max(maxIndex) shapes the tool prompt only — it is NOT + // validated against the AI response. This guard is the sole runtime enforcement. + if (recordIndex < 0 || recordIndex > maxIndex) { + throw new InvalidAIResponseError( + `AI selected record index ${recordIndex} which is out of range (0-${maxIndex})`, + ); + } + + return recordIndex; + } + + private toRecordRef(data: RecordData): RecordRef { + return { + collectionName: data.collectionName, + recordId: data.recordId, + stepIndex: this.context.stepIndex, + }; + } +} diff --git a/packages/workflow-executor/src/executors/mcp-task-step-executor.ts b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts new file mode 100644 index 000000000..fe9c84e9c --- /dev/null +++ b/packages/workflow-executor/src/executors/mcp-task-step-executor.ts @@ -0,0 +1,202 @@ +import type { ExecutionContext, StepExecutionResult } from '../types/execution'; +import type { McpTaskStepDefinition } from '../types/step-definition'; +import type { McpTaskStepExecutionData, McpToolCall } from '../types/step-execution-data'; +import type { RecordTaskStepStatus } from '../types/step-outcome'; +import type { RemoteTool } from '@forestadmin/ai-proxy'; + +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; + +import { + McpToolInvocationError, + McpToolNotFoundError, + NoMcpToolsError, + StepPersistenceError, +} from '../errors'; +import BaseStepExecutor from './base-step-executor'; + +const MCP_TASK_SYSTEM_PROMPT = `You are an AI agent selecting and executing a tool to fulfill a user request. +Select the most appropriate tool and fill in its parameters precisely. + +Important rules: +- Select only the tool directly relevant to the request. +- Final answer is definitive, you won't receive any other input from the user.`; + +export default class McpTaskStepExecutor extends BaseStepExecutor { + private readonly remoteTools: readonly RemoteTool[]; + + constructor( + context: ExecutionContext, + remoteTools: readonly RemoteTool[], + ) { + super(context); + this.remoteTools = remoteTools; + } + + protected buildOutcomeResult(outcome: { + status: RecordTaskStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'mcp-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + ...outcome, + }, + }; + } + + protected async doExecute(): Promise { + if (this.context.userConfirmed !== undefined) { + // Branch A -- Re-entry with user confirmation + return this.handleConfirmationFlow('mcp-task', execution => + this.executeToolAndPersist(execution.pendingData as McpToolCall, execution), + ); + } + + // Branches B & C -- First call + const tools = this.getFilteredTools(); + const { toolName, args } = await this.selectTool(tools); + const target: McpToolCall = { name: toolName, input: args }; + + if (this.context.stepDefinition.automaticExecution) { + // Branch B -- direct execution + return this.executeToolAndPersist(target); + } + + // Branch C -- Awaiting confirmation + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'mcp-task', + stepIndex: this.context.stepIndex, + pendingData: target, + }); + } catch (cause) { + throw new StepPersistenceError( + `MCP task step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + private async executeToolAndPersist( + target: McpToolCall, + existingExecution?: McpTaskStepExecutionData, + ): Promise { + const tools = this.getFilteredTools(); + const tool = tools.find(t => t.base.name === target.name); + if (!tool) throw new McpToolNotFoundError(target.name); + + let toolResult: unknown; + + try { + toolResult = await tool.base.invoke(target.input); + } catch (cause) { + throw new McpToolInvocationError(target.name, cause); + } + + // 1. Persist raw result immediately — safe state before any further network calls + const baseExecutionResult = { success: true as const, toolResult }; + const baseData: McpTaskStepExecutionData = { + ...existingExecution, + type: 'mcp-task', + stepIndex: this.context.stepIndex, + executionParams: { name: target.name, input: target.input }, + executionResult: baseExecutionResult, + }; + + try { + await this.context.runStore.saveStepExecution(this.context.runId, baseData); + } catch (cause) { + throw new StepPersistenceError( + `MCP tool "${target.name}" executed but step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + // 2. AI formatting — non-blocking; errors are logged but do not fail the step + try { + const formattedResponse = await this.formatToolResult(target, toolResult); + + if (formattedResponse) { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...baseData, + executionResult: { ...baseExecutionResult, formattedResponse }, + }); + } + } catch (cause) { + this.context.logger.error('Failed to format MCP tool result, using generic fallback', { + runId: this.context.runId, + stepIndex: this.context.stepIndex, + toolName: target.name, + cause: cause instanceof Error ? cause.message : String(cause), + }); + } + + return this.buildOutcomeResult({ status: 'success' }); + } + + private async formatToolResult(tool: McpToolCall, toolResult: unknown): Promise { + if (toolResult === null || toolResult === undefined) return null; + + const resultStr = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult); + const truncatedResult = + resultStr.length > 20_000 ? `${resultStr.slice(0, 20_000)}\n... [truncated]` : resultStr; + + const summaryTool = new DynamicStructuredTool({ + name: 'summarize-result', + description: 'Provides a human-readable summary of the tool execution result.', + schema: z.object({ + summary: z.string().min(1).describe('Concise human-readable summary of the tool result.'), + }), + func: undefined, + }); + + const messages = [ + new SystemMessage( + 'You are summarizing the result of a workflow tool execution for the end user. ' + + 'Be concise and factual. Do not include raw JSON or technical identifiers.', + ), + new HumanMessage( + `Tool "${tool.name}" was executed with input: ${JSON.stringify(tool.input)}.\n` + + `Result: ${truncatedResult}\n\n` + + `Provide a concise human-readable summary.`, + ), + ]; + + const { summary } = await this.invokeWithTool<{ summary: string }>(messages, summaryTool); + + return summary || null; + } + + private async selectTool(tools: RemoteTool[]) { + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(MCP_TASK_SYSTEM_PROMPT), + new HumanMessage( + `**Request**: ${this.context.stepDefinition.prompt ?? 'Execute the relevant tool.'}`, + ), + ]; + + return this.invokeWithTools( + messages, + tools.map(t => t.base), + ); + } + + /** Returns tools filtered by mcpServerId (if specified). Throws NoMcpToolsError if empty. */ + private getFilteredTools(): RemoteTool[] { + const { mcpServerId } = this.context.stepDefinition; + const tools = mcpServerId + ? this.remoteTools.filter(t => t.sourceId === mcpServerId) + : [...this.remoteTools]; + if (tools.length === 0) throw new NoMcpToolsError(); + + return tools; + } +} diff --git a/packages/workflow-executor/src/executors/read-record-step-executor.ts b/packages/workflow-executor/src/executors/read-record-step-executor.ts index 6f7248c3c..4b8622014 100644 --- a/packages/workflow-executor/src/executors/read-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/read-record-step-executor.ts @@ -1,22 +1,13 @@ import type { StepExecutionResult } from '../types/execution'; -import type { CollectionSchema, RecordRef } from '../types/record'; -import type { AiTaskStepDefinition } from '../types/step-definition'; -import type { - FieldReadResult, - LoadRelatedRecordStepExecutionData, -} from '../types/step-execution-data'; - -import { HumanMessage, SystemMessage } from '@langchain/core/messages'; -import { DynamicStructuredTool } from '@langchain/core/tools'; +import type { CollectionSchema } from '../types/record'; +import type { RecordTaskStepDefinition } from '../types/step-definition'; +import type { FieldReadResult } from '../types/step-execution-data'; + +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; import { z } from 'zod'; -import { - NoReadableFieldsError, - NoRecordsError, - NoResolvedFieldsError, - WorkflowExecutorError, -} from '../errors'; -import BaseStepExecutor from './base-step-executor'; +import { NoReadableFieldsError, NoResolvedFieldsError } from '../errors'; +import RecordTaskStepExecutor from './record-task-step-executor'; const READ_RECORD_SYSTEM_PROMPT = `You are an AI agent reading fields from a record to answer a user request. Select the field(s) that best answer the request. You can read one field or multiple fields at once. @@ -26,70 +17,40 @@ Important rules: - Final answer is definitive, you won't receive any other input from the user. - Do not refer to yourself as "I" in the response, use a passive formulation instead.`; -export default class ReadRecordStepExecutor extends BaseStepExecutor { - private readonly schemaCache = new Map(); - - async execute(): Promise { +export default class ReadRecordStepExecutor extends RecordTaskStepExecutor { + protected async doExecute(): Promise { const { stepDefinition: step } = this.context; const records = await this.getAvailableRecordRefs(); - let selectedRecordRef: RecordRef; - let schema: CollectionSchema; - let fieldResults: FieldReadResult[]; - - try { - selectedRecordRef = await this.selectRecordRef(records, step.prompt); - schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const selectedDisplayNames = await this.selectFields(schema, step.prompt); - const resolvedFieldNames = selectedDisplayNames - .map( - name => - schema.fields.find(f => f.fieldName === name || f.displayName === name)?.fieldName, - ) - .filter((name): name is string => name !== undefined); - - if (resolvedFieldNames.length === 0) { - throw new NoResolvedFieldsError(selectedDisplayNames); - } - - const recordData = await this.context.agentPort.getRecord( - selectedRecordRef.collectionName, - selectedRecordRef.recordId, - resolvedFieldNames, - ); - fieldResults = this.formatFieldResults(recordData.values, schema, selectedDisplayNames); - } catch (error) { - if (error instanceof WorkflowExecutorError) { - return { - stepOutcome: { - type: 'ai-task', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'error', - error: error.message, - }, - }; - } - - throw error; + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const selectedDisplayNames = await this.selectFields(schema, step.prompt); + const resolvedFieldNames = selectedDisplayNames + .map(name => this.findField(schema, name)?.fieldName) + .filter((name): name is string => name !== undefined); + + if (resolvedFieldNames.length === 0) { + throw new NoResolvedFieldsError(selectedDisplayNames); } + const recordData = await this.agentPort.getRecord({ + collection: selectedRecordRef.collectionName, + id: selectedRecordRef.recordId, + fields: resolvedFieldNames, + }); + const fieldResults = this.formatFieldResults(recordData.values, schema, selectedDisplayNames); + await this.context.runStore.saveStepExecution(this.context.runId, { type: 'read-record', stepIndex: this.context.stepIndex, - executionParams: { fieldNames: fieldResults.map(f => f.fieldName) }, + executionParams: { + fields: fieldResults.map(({ name, displayName }) => ({ name, displayName })), + }, executionResult: { fields: fieldResults }, selectedRecordRef, }); - return { - stepOutcome: { - type: 'ai-task', - stepId: this.context.stepId, - stepIndex: this.context.stepIndex, - status: 'success', - }, - }; + return this.buildOutcomeResult({ status: 'success' }); } private async selectFields( @@ -111,51 +72,6 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor { - if (records.length === 0) throw new NoRecordsError(); - if (records.length === 1) return records[0]; - - const identifiers = await Promise.all(records.map(r => this.toRecordIdentifier(r))); - const identifierTuple = identifiers as [string, ...string[]]; - - const tool = new DynamicStructuredTool({ - name: 'select-record', - description: 'Select the most relevant record for this workflow step.', - schema: z.object({ - recordIdentifier: z.enum(identifierTuple), - }), - func: undefined, - }); - - const messages = [ - ...(await this.buildPreviousStepsMessages()), - new SystemMessage( - 'You are an AI agent selecting the most relevant record for a workflow step.\n' + - 'Choose the record whose collection best matches the user request.\n' + - 'Pay attention to the collection name of each record.', - ), - new HumanMessage(prompt ?? 'Select the most relevant record.'), - ]; - - const { recordIdentifier } = await this.invokeWithTool<{ recordIdentifier: string }>( - messages, - tool, - ); - - const selectedIndex = identifiers.indexOf(recordIdentifier); - - if (selectedIndex === -1) { - throw new WorkflowExecutorError( - `AI selected record "${recordIdentifier}" which does not match any available record`, - ); - } - - return records[selectedIndex]; - } - private buildReadFieldTool(schema: CollectionSchema): DynamicStructuredTool { const nonRelationFields = schema.fields.filter(f => !f.isRelationship); @@ -187,43 +103,18 @@ export default class ReadRecordStepExecutor extends BaseStepExecutor, schema: CollectionSchema, - fieldNames: string[], + fieldDisplayNames: string[], ): FieldReadResult[] { - return fieldNames.map(name => { - const field = schema.fields.find(f => f.fieldName === name || f.displayName === name); + return fieldDisplayNames.map(name => { + const field = this.findField(schema, name); - if (!field) return { error: `Field not found: ${name}`, fieldName: name, displayName: name }; + if (!field) return { error: `Field not found: ${name}`, name, displayName: name }; return { value: values[field.fieldName], - fieldName: field.fieldName, + name: field.fieldName, displayName: field.displayName, }; }); } - - private async getAvailableRecordRefs(): Promise { - const stepExecutions = await this.context.runStore.getStepExecutions(this.context.runId); - const relatedRecords = stepExecutions - .filter((e): e is LoadRelatedRecordStepExecutionData => e.type === 'load-related-record') - .map(e => e.record); - - return [this.context.baseRecordRef, ...relatedRecords]; - } - - private async getCollectionSchema(collectionName: string): Promise { - const cached = this.schemaCache.get(collectionName); - if (cached) return cached; - - const schema = await this.context.workflowPort.getCollectionSchema(collectionName); - this.schemaCache.set(collectionName, schema); - - return schema; - } - - private async toRecordIdentifier(record: RecordRef): Promise { - const schema = await this.getCollectionSchema(record.collectionName); - - return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId}`; - } } diff --git a/packages/workflow-executor/src/executors/record-task-step-executor.ts b/packages/workflow-executor/src/executors/record-task-step-executor.ts new file mode 100644 index 000000000..86b884109 --- /dev/null +++ b/packages/workflow-executor/src/executors/record-task-step-executor.ts @@ -0,0 +1,23 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { StepDefinition } from '../types/step-definition'; +import type { RecordTaskStepStatus } from '../types/step-outcome'; + +import BaseStepExecutor from './base-step-executor'; + +export default abstract class RecordTaskStepExecutor< + TStep extends StepDefinition = StepDefinition, +> extends BaseStepExecutor { + protected buildOutcomeResult(outcome: { + status: RecordTaskStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + ...outcome, + }, + }; + } +} diff --git a/packages/workflow-executor/src/executors/safe-agent-port.ts b/packages/workflow-executor/src/executors/safe-agent-port.ts new file mode 100644 index 000000000..4ed4d3ef5 --- /dev/null +++ b/packages/workflow-executor/src/executors/safe-agent-port.ts @@ -0,0 +1,39 @@ +import type { + AgentPort, + ExecuteActionQuery, + GetRecordQuery, + GetRelatedDataQuery, + UpdateRecordQuery, +} from '../ports/agent-port'; +import type { RecordData } from '../types/record'; + +import { AgentPortError, WorkflowExecutorError } from '../errors'; + +export default class SafeAgentPort implements AgentPort { + constructor(private readonly port: AgentPort) {} + + async getRecord(query: GetRecordQuery): Promise { + return this.call('getRecord', () => this.port.getRecord(query)); + } + + async updateRecord(query: UpdateRecordQuery): Promise { + return this.call('updateRecord', () => this.port.updateRecord(query)); + } + + async getRelatedData(query: GetRelatedDataQuery): Promise { + return this.call('getRelatedData', () => this.port.getRelatedData(query)); + } + + async executeAction(query: ExecuteActionQuery): Promise { + return this.call('executeAction', () => this.port.executeAction(query)); + } + + private async call(operation: string, fn: () => Promise): Promise { + try { + return await fn(); + } catch (cause) { + if (cause instanceof WorkflowExecutorError) throw cause; + throw new AgentPortError(operation, cause); + } + } +} diff --git a/packages/workflow-executor/src/executors/step-executor-factory.ts b/packages/workflow-executor/src/executors/step-executor-factory.ts new file mode 100644 index 000000000..2f321dd63 --- /dev/null +++ b/packages/workflow-executor/src/executors/step-executor-factory.ts @@ -0,0 +1,109 @@ +import type { AgentPort } from '../ports/agent-port'; +import type { Logger } from '../ports/logger-port'; +import type { RunStore } from '../ports/run-store'; +import type { WorkflowPort } from '../ports/workflow-port'; +import type { + ExecutionContext, + IStepExecutor, + PendingStepExecution, + StepExecutionResult, +} from '../types/execution'; +import type { + ConditionStepDefinition, + McpTaskStepDefinition, + RecordTaskStepDefinition, +} from '../types/step-definition'; +import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; + +import { StepStateError, causeMessage } from '../errors'; +import ConditionStepExecutor from './condition-step-executor'; +import LoadRelatedRecordStepExecutor from './load-related-record-step-executor'; +import McpTaskStepExecutor from './mcp-task-step-executor'; +import ReadRecordStepExecutor from './read-record-step-executor'; +import TriggerRecordActionStepExecutor from './trigger-record-action-step-executor'; +import UpdateRecordStepExecutor from './update-record-step-executor'; +import { StepType } from '../types/step-definition'; +import { stepTypeToOutcomeType } from '../types/step-outcome'; + +export interface StepContextConfig { + aiClient: AiClient; + agentPort: AgentPort; + workflowPort: WorkflowPort; + runStore: RunStore; + logger: Logger; +} + +export default class StepExecutorFactory { + static async create( + step: PendingStepExecution, + contextConfig: StepContextConfig, + loadTools: () => Promise, + ): Promise { + try { + const context = StepExecutorFactory.buildContext(step, contextConfig); + + switch (step.stepDefinition.type) { + case StepType.Condition: + return new ConditionStepExecutor(context as ExecutionContext); + case StepType.ReadRecord: + return new ReadRecordStepExecutor(context as ExecutionContext); + case StepType.UpdateRecord: + return new UpdateRecordStepExecutor( + context as ExecutionContext, + ); + case StepType.TriggerAction: + return new TriggerRecordActionStepExecutor( + context as ExecutionContext, + ); + case StepType.LoadRelatedRecord: + return new LoadRelatedRecordStepExecutor( + context as ExecutionContext, + ); + case StepType.McpTask: + return new McpTaskStepExecutor( + context as ExecutionContext, + await loadTools(), + ); + default: + throw new StepStateError( + `Unknown step type: ${(step.stepDefinition as { type: string }).type}`, + ); + } + } catch (error) { + contextConfig.logger.error('Step execution failed unexpectedly', { + runId: step.runId, + stepId: step.stepId, + stepIndex: step.stepIndex, + error: error instanceof Error ? error.message : String(error), + cause: causeMessage(error), + stack: error instanceof Error ? error.stack : undefined, + }); + + return { + execute: async (): Promise => ({ + stepOutcome: { + type: stepTypeToOutcomeType(step.stepDefinition.type), + stepId: step.stepId, + stepIndex: step.stepIndex, + status: 'error', + error: 'An unexpected error occurred.', + }, + }), + }; + } + } + + private static buildContext( + step: PendingStepExecution, + cfg: StepContextConfig, + ): ExecutionContext { + return { + ...step, + model: cfg.aiClient.getModel(step.stepDefinition.aiConfigName), + agentPort: cfg.agentPort, + workflowPort: cfg.workflowPort, + runStore: cfg.runStore, + logger: cfg.logger, + }; + } +} diff --git a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts new file mode 100644 index 000000000..55fdda59a --- /dev/null +++ b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts @@ -0,0 +1,60 @@ +import type { + LoadRelatedRecordStepExecutionData, + McpTaskStepExecutionData, + StepExecutionData, +} from '../../types/step-execution-data'; + +/** + * Stateless utility class — all methods are static. + * Provides type-specific formatting for step execution results. + * Add one private static method per step type that needs a non-generic display format, + * and dispatch from `format`. + */ +export default class StepExecutionFormatters { + /** + * Returns the full output line (indent + label + content) for the given execution, or null when: + * - No custom format is defined for this step type (switch default) — caller uses generic fallback, or + * - The execution data does not satisfy the formatter's preconditions (e.g. skipped/incomplete). + * In both cases, `StepSummaryBuilder` renders the generic Input:/Output: fallback. + */ + static format(execution: StepExecutionData): string | null { + switch (execution.type) { + case 'load-related-record': + return StepExecutionFormatters.formatLoadRelatedRecord(execution); + case 'mcp-task': + return StepExecutionFormatters.formatMcpTask(execution as McpTaskStepExecutionData); + default: + return null; + } + } + + private static formatMcpTask(execution: McpTaskStepExecutionData): string | null { + const { executionResult } = execution; + if (!executionResult) return null; + if ('skipped' in executionResult) return null; + + if (executionResult.formattedResponse) { + return ` Result: ${executionResult.formattedResponse}`; + } + + const toolName = execution.executionParams?.name ?? 'unknown tool'; + + return ` Executed: ${toolName} (result not summarized)`; + } + + private static formatLoadRelatedRecord( + execution: LoadRelatedRecordStepExecutionData, + ): string | null { + const { executionResult } = execution; + + if (!executionResult) return null; // pending phase — no result yet + if ('skipped' in executionResult) return null; // user skipped — generic fallback + + const { selectedRecordRef } = execution; + const { relation, record } = executionResult; + const sourceId = selectedRecordRef.recordId.join(', '); + const recordId = record.recordId.join(', '); + + return ` Loaded: ${selectedRecordRef.collectionName} #${sourceId} → [${relation.displayName}] → ${record.collectionName} #${recordId} (step ${record.stepIndex})`; + } +} diff --git a/packages/workflow-executor/src/executors/summary/step-summary-builder.ts b/packages/workflow-executor/src/executors/summary/step-summary-builder.ts new file mode 100644 index 000000000..abe155924 --- /dev/null +++ b/packages/workflow-executor/src/executors/summary/step-summary-builder.ts @@ -0,0 +1,43 @@ +import type { StepDefinition } from '../../types/step-definition'; +import type { StepExecutionData } from '../../types/step-execution-data'; +import type { StepOutcome } from '../../types/step-outcome'; + +import StepExecutionFormatters from './step-execution-formatters'; + +export default class StepSummaryBuilder { + static build( + step: StepDefinition, + stepOutcome: StepOutcome, + execution: StepExecutionData | undefined, + ): string { + const prompt = step.prompt ?? '(no prompt)'; + const header = `Step "${stepOutcome.stepId}" (index ${stepOutcome.stepIndex}):`; + const lines = [header, ` Prompt: ${prompt}`]; + + if (execution !== undefined) { + // Try custom formatting — if it fires, it owns the entire output section (no Input: line) + const customLine = execution.executionResult + ? StepExecutionFormatters.format(execution) + : null; + + if (customLine !== null) { + lines.push(customLine); + } else { + if (execution.executionParams !== undefined) { + lines.push(` Input: ${JSON.stringify(execution.executionParams)}`); + } else if ('pendingData' in execution && execution.pendingData !== undefined) { + lines.push(` Pending: ${JSON.stringify(execution.pendingData)}`); + } + + if (execution.executionResult) { + lines.push(` Output: ${JSON.stringify(execution.executionResult)}`); + } + } + } else { + const { stepId, stepIndex, type, ...historyDetails } = stepOutcome; + lines.push(` History: ${JSON.stringify(historyDetails)}`); + } + + return lines.join('\n'); + } +} diff --git a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts new file mode 100644 index 000000000..167e3f018 --- /dev/null +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -0,0 +1,164 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { CollectionSchema, RecordRef } from '../types/record'; +import type { RecordTaskStepDefinition } from '../types/step-definition'; +import type { ActionRef, TriggerRecordActionStepExecutionData } from '../types/step-execution-data'; + +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; + +import { ActionNotFoundError, NoActionsError, StepPersistenceError } from '../errors'; +import RecordTaskStepExecutor from './record-task-step-executor'; + +const TRIGGER_ACTION_SYSTEM_PROMPT = `You are an AI agent triggering an action on a record based on a user request. +Select the action to trigger. + +Important rules: +- Be precise: only trigger the action directly relevant to the request. +- Final answer is definitive, you won't receive any other input from the user. +- Do not refer to yourself as "I" in the response, use a passive formulation instead.`; + +interface ActionTarget extends ActionRef { + selectedRecordRef: RecordRef; +} + +export default class TriggerRecordActionStepExecutor extends RecordTaskStepExecutor { + protected async doExecute(): Promise { + // Branch A -- Re-entry with user confirmation + if (this.context.userConfirmed !== undefined) { + return this.handleConfirmation(); + } + + // Branches B & C -- First call + return this.handleFirstCall(); + } + + private async handleConfirmation(): Promise { + return this.handleConfirmationFlow( + 'trigger-action', + async execution => { + const { selectedRecordRef, pendingData } = execution; + const target: ActionTarget = { + selectedRecordRef, + ...(pendingData as ActionRef), + }; + + return this.resolveAndExecute(target, execution); + }, + ); + } + + private async handleFirstCall(): Promise { + const { stepDefinition: step } = this.context; + const records = await this.getAvailableRecordRefs(); + + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectAction(schema, step.prompt); + const name = this.resolveActionName(schema, args.actionName); + const target: ActionTarget = { selectedRecordRef, displayName: args.actionName, name }; + + // Branch B -- automaticExecution + if (step.automaticExecution) { + return this.resolveAndExecute(target); + } + + // Branch C -- Awaiting confirmation + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'trigger-action', + stepIndex: this.context.stepIndex, + pendingData: { displayName: target.displayName, name: target.name }, + selectedRecordRef: target.selectedRecordRef, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + /** + * Resolves the action name, calls executeAction, and persists execution data. + * When `existingExecution` is provided (confirmation flow), it is spread into the + * saved execution to preserve pendingData for traceability. + */ + private async resolveAndExecute( + target: ActionTarget, + existingExecution?: TriggerRecordActionStepExecutionData, + ): Promise { + const { selectedRecordRef, displayName, name } = target; + + const actionResult = await this.agentPort.executeAction({ + collection: selectedRecordRef.collectionName, + action: name, + id: selectedRecordRef.recordId, + }); + + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'trigger-action', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name }, + executionResult: { success: true, actionResult }, + selectedRecordRef, + }); + } catch (cause) { + throw new StepPersistenceError( + `Action "${name}" executed but step state could not be persisted ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'success' }); + } + + private async selectAction( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise<{ actionName: string; reasoning: string }> { + const tool = this.buildSelectActionTool(schema); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(TRIGGER_ACTION_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Trigger the relevant action.'}`), + ]; + + return this.invokeWithTool<{ actionName: string; reasoning: string }>(messages, tool); + } + + private buildSelectActionTool(schema: CollectionSchema): DynamicStructuredTool { + if (schema.actions.length === 0) { + throw new NoActionsError(schema.collectionName); + } + + const displayNames = schema.actions.map(a => a.displayName) as [string, ...string[]]; + const technicalNames = schema.actions + .map(a => `${a.displayName} (technical name: ${a.name})`) + .join(', '); + + return new DynamicStructuredTool({ + name: 'select-action', + description: 'Select the action to trigger on the record.', + schema: z.object({ + actionName: z + .enum(displayNames) + .describe(`The name of the action to trigger. Available: ${technicalNames}`), + reasoning: z.string().describe('Why this action was chosen'), + }), + func: undefined, + }); + } + + private resolveActionName(schema: CollectionSchema, displayName: string): string { + const action = + schema.actions.find(a => a.displayName === displayName) ?? + schema.actions.find(a => a.name === displayName); + + if (!action) { + throw new ActionNotFoundError(displayName, schema.collectionName); + } + + return action.name; + } +} diff --git a/packages/workflow-executor/src/executors/update-record-step-executor.ts b/packages/workflow-executor/src/executors/update-record-step-executor.ts new file mode 100644 index 000000000..97c0cb1d4 --- /dev/null +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -0,0 +1,175 @@ +import type { StepExecutionResult } from '../types/execution'; +import type { CollectionSchema, RecordRef } from '../types/record'; +import type { RecordTaskStepDefinition } from '../types/step-definition'; +import type { FieldRef, UpdateRecordStepExecutionData } from '../types/step-execution-data'; + +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; + +import { FieldNotFoundError, NoWritableFieldsError, StepPersistenceError } from '../errors'; +import RecordTaskStepExecutor from './record-task-step-executor'; + +const UPDATE_RECORD_SYSTEM_PROMPT = `You are an AI agent updating a field on a record based on a user request. +Select the field to update and provide the new value. + +Important rules: +- Be precise: only update the field that is directly relevant to the request. +- Final answer is definitive, you won't receive any other input from the user. +- Do not refer to yourself as "I" in the response, use a passive formulation instead.`; + +interface UpdateTarget extends FieldRef { + selectedRecordRef: RecordRef; + value: string; +} + +export default class UpdateRecordStepExecutor extends RecordTaskStepExecutor { + protected async doExecute(): Promise { + // Branch A -- Re-entry with user confirmation + if (this.context.userConfirmed !== undefined) { + return this.handleConfirmation(); + } + + // Branches B & C -- First call + return this.handleFirstCall(); + } + + private async handleConfirmation(): Promise { + return this.handleConfirmationFlow( + 'update-record', + async execution => { + const { selectedRecordRef, pendingData } = execution; + const target: UpdateTarget = { + selectedRecordRef, + ...(pendingData as FieldRef & { value: string }), + }; + + return this.resolveAndUpdate(target, execution); + }, + ); + } + + private async handleFirstCall(): Promise { + const { stepDefinition: step } = this.context; + const records = await this.getAvailableRecordRefs(); + + const selectedRecordRef = await this.selectRecordRef(records, step.prompt); + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const args = await this.selectFieldAndValue(schema, step.prompt); + const name = this.resolveFieldName(schema, args.fieldName); + const target: UpdateTarget = { + selectedRecordRef, + displayName: args.fieldName, + name, + value: args.value, + }; + + // Branch B -- automaticExecution + if (step.automaticExecution) { + return this.resolveAndUpdate(target); + } + + // Branch C -- Awaiting confirmation + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'update-record', + stepIndex: this.context.stepIndex, + pendingData: { + displayName: target.displayName, + name: target.name, + value: target.value, + }, + selectedRecordRef: target.selectedRecordRef, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + /** + * Resolves the field name, calls updateRecord, and persists execution data. + * When `existingExecution` is provided (confirmation flow), it is spread into the + * saved execution to preserve pendingData for traceability. + */ + private async resolveAndUpdate( + target: UpdateTarget, + existingExecution?: UpdateRecordStepExecutionData, + ): Promise { + const { selectedRecordRef, displayName, name, value } = target; + + const updated = await this.agentPort.updateRecord({ + collection: selectedRecordRef.collectionName, + id: selectedRecordRef.recordId, + values: { [name]: value }, + }); + + try { + await this.context.runStore.saveStepExecution(this.context.runId, { + ...existingExecution, + type: 'update-record', + stepIndex: this.context.stepIndex, + executionParams: { displayName, name, value }, + executionResult: { updatedValues: updated.values }, + selectedRecordRef, + }); + } catch (cause) { + throw new StepPersistenceError( + `Record update persisted but step state could not be saved ` + + `(run "${this.context.runId}", step ${this.context.stepIndex})`, + cause, + ); + } + + return this.buildOutcomeResult({ status: 'success' }); + } + + private async selectFieldAndValue( + schema: CollectionSchema, + prompt: string | undefined, + ): Promise<{ fieldName: string; value: string; reasoning: string }> { + const tool = this.buildUpdateFieldTool(schema); + const messages = [ + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(UPDATE_RECORD_SYSTEM_PROMPT), + new SystemMessage( + `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + ), + new HumanMessage(`**Request**: ${prompt ?? 'Update the relevant field.'}`), + ]; + + return this.invokeWithTool<{ fieldName: string; value: string; reasoning: string }>( + messages, + tool, + ); + } + + private buildUpdateFieldTool(schema: CollectionSchema): DynamicStructuredTool { + const nonRelationFields = schema.fields.filter(f => !f.isRelationship); + + if (nonRelationFields.length === 0) { + throw new NoWritableFieldsError(schema.collectionName); + } + + const displayNames = nonRelationFields.map(f => f.displayName) as [string, ...string[]]; + + return new DynamicStructuredTool({ + name: 'update-record-field', + description: 'Update a field on the selected record.', + schema: z.object({ + fieldName: z.enum(displayNames), + // z.string() intentionally: the value is always transmitted as string + // to updateRecord; data typing is handled by the agent/datasource layer. + value: z.string().describe('The new value for the field'), + reasoning: z.string().describe('Why this field and value were chosen'), + }), + func: undefined, + }); + } + + private resolveFieldName(schema: CollectionSchema, displayName: string): string { + const field = this.findField(schema, displayName); + + if (!field) { + throw new FieldNotFoundError(displayName, schema.collectionName); + } + + return field.fieldName; + } +} diff --git a/packages/workflow-executor/src/http/executor-http-server.ts b/packages/workflow-executor/src/http/executor-http-server.ts index 72ea42936..10062dedd 100644 --- a/packages/workflow-executor/src/http/executor-http-server.ts +++ b/packages/workflow-executor/src/http/executor-http-server.ts @@ -1,3 +1,4 @@ +import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type Runner from '../runner'; import type { Server } from 'http'; @@ -6,10 +7,13 @@ import Router from '@koa/router'; import http from 'http'; import Koa from 'koa'; +import { RunNotFoundError } from '../errors'; + export interface ExecutorHttpServerOptions { port: number; runStore: RunStore; runner: Runner; + logger?: Logger; } export default class ExecutorHttpServer { @@ -26,8 +30,14 @@ export default class ExecutorHttpServer { try { await next(); } catch (err: unknown) { + this.options.logger?.error('Unhandled HTTP error', { + method: ctx.method, + path: ctx.path, + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); ctx.status = 500; - ctx.body = { error: err instanceof Error ? err.message : 'Internal server error' }; + ctx.body = { error: 'Internal server error' }; } }); @@ -80,7 +90,18 @@ export default class ExecutorHttpServer { private async handleTrigger(ctx: Koa.Context): Promise { const { runId } = ctx.params; - await this.options.runner.triggerPoll(runId); + try { + await this.options.runner.triggerPoll(runId); + } catch (err) { + if (err instanceof RunNotFoundError) { + ctx.status = 404; + ctx.body = { error: 'Run not found or unavailable' }; + + return; + } + + throw err; + } ctx.status = 200; ctx.body = { triggered: true }; diff --git a/packages/workflow-executor/src/index.ts b/packages/workflow-executor/src/index.ts index 916bbc075..3075c5f41 100644 --- a/packages/workflow-executor/src/index.ts +++ b/packages/workflow-executor/src/index.ts @@ -1,14 +1,16 @@ export { StepType } from './types/step-definition'; export type { ConditionStepDefinition, - AiTaskStepDefinition, + RecordTaskStepDefinition, + McpTaskStepDefinition, StepDefinition, } from './types/step-definition'; export type { StepStatus, ConditionStepOutcome, - AiTaskStepOutcome, + RecordTaskStepOutcome, + McpTaskStepOutcome, StepOutcome, } from './types/step-outcome'; @@ -16,16 +18,23 @@ export type { FieldReadSuccess, FieldReadError, FieldReadResult, + ActionRef, + RelationRef, + FieldRef, ConditionStepExecutionData, ReadRecordStepExecutionData, - AiTaskStepExecutionData, + UpdateRecordStepExecutionData, + TriggerRecordActionStepExecutionData, + RecordTaskStepExecutionData, + LoadRelatedRecordPendingData, LoadRelatedRecordStepExecutionData, + McpToolRef, + McpToolCall, + McpTaskStepExecutionData, ExecutedStepExecutionData, StepExecutionData, } from './types/step-execution-data'; -export { isExecutedStepOnExecutor } from './types/step-execution-data'; - export type { FieldSchema, ActionSchema, @@ -36,15 +45,24 @@ export type { export type { Step, - UserInput, PendingStepExecution, StepExecutionResult, ExecutionContext, } from './types/execution'; -export type { AgentPort } from './ports/agent-port'; +export type { + AgentPort, + ExecuteActionQuery, + GetRecordQuery, + GetRelatedDataQuery, + Id, + Limit, + UpdateRecordQuery, +} from './ports/agent-port'; export type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; export type { RunStore } from './ports/run-store'; +export type { Logger } from './ports/logger-port'; +export { default as ConsoleLogger } from './adapters/console-logger'; export { WorkflowExecutorError, @@ -54,10 +72,28 @@ export { NoRecordsError, NoReadableFieldsError, NoResolvedFieldsError, + NoWritableFieldsError, + NoActionsError, + StepPersistenceError, + NoRelationshipFieldsError, + RelatedRecordNotFoundError, + InvalidAIResponseError, + RelationNotFoundError, + FieldNotFoundError, + ActionNotFoundError, + StepStateError, + NoMcpToolsError, + McpToolNotFoundError, + McpToolInvocationError, + AgentPortError, } from './errors'; export { default as BaseStepExecutor } from './executors/base-step-executor'; export { default as ConditionStepExecutor } from './executors/condition-step-executor'; export { default as ReadRecordStepExecutor } from './executors/read-record-step-executor'; +export { default as UpdateRecordStepExecutor } from './executors/update-record-step-executor'; +export { default as TriggerRecordActionStepExecutor } from './executors/trigger-record-action-step-executor'; +export { default as LoadRelatedRecordStepExecutor } from './executors/load-related-record-step-executor'; +export { default as McpTaskStepExecutor } from './executors/mcp-task-step-executor'; export { default as AgentClientAgentPort } from './adapters/agent-client-agent-port'; export { default as ForestServerWorkflowPort } from './adapters/forest-server-workflow-port'; export { default as ExecutorHttpServer } from './http/executor-http-server'; diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index a0964e250..4a95c92cd 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -2,25 +2,26 @@ import type { RecordData } from '../types/record'; +export type Id = string | number; + +export type Limit = { limit: number } | { limit: null }; + +export type GetRecordQuery = { collection: string; id: Id[]; fields?: string[] }; + +export type UpdateRecordQuery = { collection: string; id: Id[]; values: Record }; + +export type GetRelatedDataQuery = { + collection: string; + id: Id[]; + relation: string; + fields?: string[]; +} & Limit; + +export type ExecuteActionQuery = { collection: string; action: string; id?: Id[] }; + export interface AgentPort { - getRecord( - collectionName: string, - recordId: Array, - fieldNames?: string[], - ): Promise; - updateRecord( - collectionName: string, - recordId: Array, - values: Record, - ): Promise; - getRelatedData( - collectionName: string, - recordId: Array, - relationName: string, - ): Promise; - executeAction( - collectionName: string, - actionName: string, - recordIds: Array[], - ): Promise; + getRecord(query: GetRecordQuery): Promise; + updateRecord(query: UpdateRecordQuery): Promise; + getRelatedData(query: GetRelatedDataQuery): Promise; + executeAction(query: ExecuteActionQuery): Promise; } diff --git a/packages/workflow-executor/src/ports/logger-port.ts b/packages/workflow-executor/src/ports/logger-port.ts new file mode 100644 index 000000000..017f8742a --- /dev/null +++ b/packages/workflow-executor/src/ports/logger-port.ts @@ -0,0 +1,3 @@ +export interface Logger { + error(message: string, context: Record): void; +} diff --git a/packages/workflow-executor/src/ports/workflow-port.ts b/packages/workflow-executor/src/ports/workflow-port.ts index 392473a95..223123b75 100644 --- a/packages/workflow-executor/src/ports/workflow-port.ts +++ b/packages/workflow-executor/src/ports/workflow-port.ts @@ -3,12 +3,13 @@ import type { PendingStepExecution } from '../types/execution'; import type { CollectionSchema } from '../types/record'; import type { StepOutcome } from '../types/step-outcome'; +import type { McpConfiguration } from '@forestadmin/ai-proxy'; -/** Placeholder -- will be typed as McpConfiguration from @forestadmin/ai-proxy/mcp-client once added as dependency. */ -export type McpConfiguration = unknown; +export type { McpConfiguration }; export interface WorkflowPort { getPendingStepExecutions(): Promise; + getPendingStepExecutionsForRun(runId: string): Promise; updateStepExecution(runId: string, stepOutcome: StepOutcome): Promise; getCollectionSchema(collectionName: string): Promise; getMcpServerConfigs(): Promise; diff --git a/packages/workflow-executor/src/runner.ts b/packages/workflow-executor/src/runner.ts index 652772c71..b97a8260a 100644 --- a/packages/workflow-executor/src/runner.ts +++ b/packages/workflow-executor/src/runner.ts @@ -1,9 +1,14 @@ -// TODO: implement polling loop, execution dispatch, AI wiring (see spec section 4.1) - +import type { StepContextConfig } from './executors/step-executor-factory'; import type { AgentPort } from './ports/agent-port'; +import type { Logger } from './ports/logger-port'; import type { RunStore } from './ports/run-store'; -import type { WorkflowPort } from './ports/workflow-port'; +import type { McpConfiguration, WorkflowPort } from './ports/workflow-port'; +import type { PendingStepExecution, StepExecutionResult } from './types/execution'; +import type { AiClient, RemoteTool } from '@forestadmin/ai-proxy'; +import ConsoleLogger from './adapters/console-logger'; +import { RunNotFoundError, causeMessage } from './errors'; +import StepExecutorFactory from './executors/step-executor-factory'; import ExecutorHttpServer from './http/executor-http-server'; export interface RunnerConfig { @@ -11,42 +16,169 @@ export interface RunnerConfig { workflowPort: WorkflowPort; runStore: RunStore; pollingIntervalMs: number; + aiClient: AiClient; + logger?: Logger; httpPort?: number; } export default class Runner { private readonly config: RunnerConfig; private httpServer: ExecutorHttpServer | null = null; + private pollingTimer: NodeJS.Timeout | null = null; + private readonly inFlightSteps = new Set(); + private isRunning = false; + private readonly logger: Logger; + + private static once(fn: () => Promise): () => Promise { + let cached: Promise | undefined; + + return () => { + cached ??= fn(); + + return cached; + }; + } + + private static stepKey(step: PendingStepExecution): string { + return `${step.runId}:${step.stepId}`; + } constructor(config: RunnerConfig) { this.config = config; + this.logger = config.logger ?? new ConsoleLogger(); } async start(): Promise { - if (this.config.httpPort !== undefined && !this.httpServer) { - const server = new ExecutorHttpServer({ - port: this.config.httpPort, - runStore: this.config.runStore, - runner: this, - }); - await server.start(); - this.httpServer = server; + if (this.isRunning) return; + this.isRunning = true; + + try { + if (this.config.httpPort !== undefined && !this.httpServer) { + const server = new ExecutorHttpServer({ + port: this.config.httpPort, + runStore: this.config.runStore, + runner: this, + }); + await server.start(); + this.httpServer = server; + } + } catch (error) { + this.isRunning = false; + throw error; } - // TODO: start polling loop + this.schedulePoll(); } async stop(): Promise { + this.isRunning = false; + + if (this.pollingTimer !== null) { + clearTimeout(this.pollingTimer); + this.pollingTimer = null; + } + if (this.httpServer) { await this.httpServer.stop(); this.httpServer = null; } - // TODO: stop polling loop, close connections + await this.config.aiClient.closeConnections(); + + // TODO: graceful drain of in-flight steps (out of scope PRD-223) + } + + async triggerPoll(runId: string): Promise { + const step = await this.config.workflowPort.getPendingStepExecutionsForRun(runId); + + if (!step) throw new RunNotFoundError(runId); + + if (this.inFlightSteps.has(Runner.stepKey(step))) return; + + const loadTools = Runner.once(() => this.fetchRemoteTools()); + await this.executeStep(step, loadTools); + } + + private schedulePoll(): void { + if (!this.isRunning) return; + this.pollingTimer = setTimeout(() => this.runPollCycle(), this.config.pollingIntervalMs); + } + + private async runPollCycle(): Promise { + try { + const steps = await this.config.workflowPort.getPendingStepExecutions(); + const pending = steps.filter(s => !this.inFlightSteps.has(Runner.stepKey(s))); + const loadTools = Runner.once(() => this.fetchRemoteTools()); + await Promise.allSettled(pending.map(s => this.executeStep(s, loadTools))); + } catch (error) { + this.logger.error('Poll cycle failed', { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } finally { + this.schedulePoll(); + } + } + + private async fetchRemoteTools(): Promise { + const configs = await this.config.workflowPort.getMcpServerConfigs(); + if (configs.length === 0) return []; + + const mergedConfig: McpConfiguration = { + ...configs[0], + configs: Object.assign({}, ...configs.map(c => c.configs)), + }; + + return this.config.aiClient.loadRemoteTools(mergedConfig); + } + + private async executeStep( + step: PendingStepExecution, + loadTools: () => Promise, + ): Promise { + const key = Runner.stepKey(step); + this.inFlightSteps.add(key); + + let result: StepExecutionResult; + + try { + const executor = await StepExecutorFactory.create(step, this.contextConfig, loadTools); + result = await executor.execute(); + } catch (error) { + // This block should never execute: the factory and executor contracts guarantee no rejection. + // It guards against future regressions. + this.logger.error('FATAL: executor contract violated — step outcome not reported', { + runId: step.runId, + stepId: step.stepId, + error: error instanceof Error ? error.message : String(error), + }); + + return; // Cannot report an outcome: the orchestrator will timeout on this step + } finally { + this.inFlightSteps.delete(key); + } + + try { + await this.config.workflowPort.updateStepExecution(step.runId, result.stepOutcome); + } catch (error) { + this.logger.error('Failed to report step outcome', { + runId: step.runId, + stepId: step.stepId, + stepIndex: step.stepIndex, + error: error instanceof Error ? error.message : String(error), + cause: causeMessage(error), + stack: error instanceof Error ? error.stack : undefined, + }); + } } - // eslint-disable-next-line class-methods-use-this, @typescript-eslint/no-unused-vars - async triggerPoll(_runId: string): Promise { - // TODO: trigger immediate poll cycle for this runId + private get contextConfig(): StepContextConfig { + return { + aiClient: this.config.aiClient, + agentPort: this.config.agentPort, + workflowPort: this.config.workflowPort, + runStore: this.config.runStore, + logger: this.logger, + }; } } diff --git a/packages/workflow-executor/src/types/execution.ts b/packages/workflow-executor/src/types/execution.ts index 406d1e4f0..3ec08b334 100644 --- a/packages/workflow-executor/src/types/execution.ts +++ b/packages/workflow-executor/src/types/execution.ts @@ -4,17 +4,16 @@ import type { RecordRef } from './record'; import type { StepDefinition } from './step-definition'; import type { StepOutcome } from './step-outcome'; import type { AgentPort } from '../ports/agent-port'; +import type { Logger } from '../ports/logger-port'; import type { RunStore } from '../ports/run-store'; import type { WorkflowPort } from '../ports/workflow-port'; -import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; +import type { BaseChatModel } from '@forestadmin/ai-proxy'; export interface Step { stepDefinition: StepDefinition; stepOutcome: StepOutcome; } -export type UserInput = { type: 'confirmation'; confirmed: boolean }; - export interface PendingStepExecution { readonly runId: string; readonly stepId: string; @@ -22,13 +21,17 @@ export interface PendingStepExecution { readonly baseRecordRef: RecordRef; readonly stepDefinition: StepDefinition; readonly previousSteps: ReadonlyArray; - readonly userInput?: UserInput; + readonly userConfirmed?: boolean; } export interface StepExecutionResult { stepOutcome: StepOutcome; } +export interface IStepExecutor { + execute(): Promise; +} + export interface ExecutionContext { readonly runId: string; readonly stepId: string; @@ -39,6 +42,7 @@ export interface ExecutionContext readonly agentPort: AgentPort; readonly workflowPort: WorkflowPort; readonly runStore: RunStore; - readonly history: ReadonlyArray>; - readonly remoteTools: readonly unknown[]; + readonly previousSteps: ReadonlyArray>; + readonly userConfirmed?: boolean; + readonly logger: Logger; } diff --git a/packages/workflow-executor/src/types/record.ts b/packages/workflow-executor/src/types/record.ts index b5070c39f..2237600fb 100644 --- a/packages/workflow-executor/src/types/record.ts +++ b/packages/workflow-executor/src/types/record.ts @@ -6,6 +6,8 @@ export interface FieldSchema { fieldName: string; displayName: string; isRelationship: boolean; + /** Cardinality of the relation. Absent for non-relationship fields. */ + relationType?: 'BelongsTo' | 'HasMany' | 'HasOne'; } export interface ActionSchema { diff --git a/packages/workflow-executor/src/types/step-definition.ts b/packages/workflow-executor/src/types/step-definition.ts index ca23e5b41..e2a324618 100644 --- a/packages/workflow-executor/src/types/step-definition.ts +++ b/packages/workflow-executor/src/types/step-definition.ts @@ -6,6 +6,7 @@ export enum StepType { UpdateRecord = 'update-record', TriggerAction = 'trigger-action', LoadRelatedRecord = 'load-related-record', + McpTask = 'mcp-task', } interface BaseStepDefinition { @@ -19,12 +20,18 @@ export interface ConditionStepDefinition extends BaseStepDefinition { options: [string, ...string[]]; } -export interface AiTaskStepDefinition extends BaseStepDefinition { - type: Exclude; - recordSourceStepId?: string; - automaticCompletion?: boolean; - allowedTools?: string[]; - remoteToolsSourceId?: string; +export interface RecordTaskStepDefinition extends BaseStepDefinition { + type: Exclude; + automaticExecution?: boolean; } -export type StepDefinition = ConditionStepDefinition | AiTaskStepDefinition; +export interface McpTaskStepDefinition extends BaseStepDefinition { + type: StepType.McpTask; + mcpServerId?: string; + automaticExecution?: boolean; +} + +export type StepDefinition = + | ConditionStepDefinition + | RecordTaskStepDefinition + | McpTaskStepDefinition; diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index eb022a273..edd5f8df0 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -13,21 +13,23 @@ interface BaseStepExecutionData { export interface ConditionStepExecutionData extends BaseStepExecutionData { type: 'condition'; executionParams: { answer: string | null; reasoning?: string }; - executionResult: { answer: string }; + executionResult?: { answer: string }; } -// -- Read Record -- +// -- Shared -- -interface FieldReadBase { - fieldName: string; +export interface FieldRef { + name: string; displayName: string; } -export interface FieldReadSuccess extends FieldReadBase { +// -- Read Record -- + +export interface FieldReadSuccess extends FieldRef { value: unknown; } -export interface FieldReadError extends FieldReadBase { +export interface FieldReadError extends FieldRef { error: string; } @@ -35,15 +37,72 @@ export type FieldReadResult = FieldReadSuccess | FieldReadError; export interface ReadRecordStepExecutionData extends BaseStepExecutionData { type: 'read-record'; - executionParams: { fieldNames: string[] }; + executionParams: { fields: FieldRef[] }; executionResult: { fields: FieldReadResult[] }; selectedRecordRef: RecordRef; } +// -- Update Record -- + +export interface UpdateRecordStepExecutionData extends BaseStepExecutionData { + type: 'update-record'; + executionParams?: FieldRef & { value: string }; + /** User confirmed → values returned by updateRecord. User rejected → skipped. */ + executionResult?: { updatedValues: Record } | { skipped: true }; + /** AI-selected field and value awaiting user confirmation. Used in the confirmation flow only. */ + pendingData?: FieldRef & { value: string }; + selectedRecordRef: RecordRef; +} + +// -- Trigger Action -- + +export interface ActionRef { + name: string; + displayName: string; +} + +// Intentionally separate from ActionRef/FieldRef: expected to gain relation-specific +// fields (e.g. relationType) in a future iteration. +export interface RelationRef { + name: string; + displayName: string; +} + +export interface TriggerRecordActionStepExecutionData extends BaseStepExecutionData { + type: 'trigger-action'; + /** Display name and technical name of the executed action. */ + executionParams?: ActionRef; + executionResult?: { success: true; actionResult: unknown } | { skipped: true }; + /** AI-selected action awaiting user confirmation. Used in the confirmation flow only. */ + pendingData?: ActionRef; + selectedRecordRef: RecordRef; +} + +// -- Mcp Task -- + +/** Reference to an MCP tool by its sanitized name (OpenAI-safe, alphanumeric + underscores/hyphens). */ +export interface McpToolRef { + name: string; +} + +/** A resolved tool call: sanitized tool name + input parameters sent to the tool. */ +export interface McpToolCall extends McpToolRef { + input: Record; +} + +export interface McpTaskStepExecutionData extends BaseStepExecutionData { + type: 'mcp-task'; + executionParams?: McpToolCall; + executionResult?: + | { success: true; toolResult: unknown; formattedResponse?: string } + | { skipped: true }; + pendingData?: McpToolCall; +} + // -- Generic AI Task (fallback for untyped steps) -- -export interface AiTaskStepExecutionData extends BaseStepExecutionData { - type: 'ai-task'; +export interface RecordTaskStepExecutionData extends BaseStepExecutionData { + type: 'record-task'; executionParams?: Record; executionResult?: Record; toolConfirmationInterruption?: Record; @@ -51,9 +110,30 @@ export interface AiTaskStepExecutionData extends BaseStepExecutionData { // -- Load Related Record -- +export interface LoadRelatedRecordPendingData extends RelationRef { + /** Collection name of the related records — needed to build RecordRef in Branch A. */ + relatedCollectionName: string; + /** AI-selected fields suggested for display on the frontend. undefined = not computed (no non-relation fields). */ + suggestedFields?: string[]; + /** + * The record id to load. Initially set by the AI; overwritten by the frontend via + * PATCH /runs/:runId/steps/:stepIndex/pending-data (not yet implemented). + */ + selectedRecordId: Array; +} + export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionData { type: 'load-related-record'; - record: RecordRef; + /** AI-selected relation with pre-fetched candidates awaiting user confirmation. */ + pendingData?: LoadRelatedRecordPendingData; + /** The record ref used to load the relation. Required for handleConfirmationFlow. */ + selectedRecordRef: RecordRef; + executionParams?: RelationRef; + /** + * Navigation path captured at execution time — used by StepSummaryBuilder for AI context. + * Source is not repeated here — it is always selectedRecordRef, consistent with other step types. + */ + executionResult?: { relation: RelationRef; record: RecordRef } | { skipped: true }; } // -- Union -- @@ -61,18 +141,11 @@ export interface LoadRelatedRecordStepExecutionData extends BaseStepExecutionDat export type StepExecutionData = | ConditionStepExecutionData | ReadRecordStepExecutionData - | AiTaskStepExecutionData - | LoadRelatedRecordStepExecutionData; - -export type ExecutedStepExecutionData = - | ConditionStepExecutionData - | ReadRecordStepExecutionData - | AiTaskStepExecutionData; - -// TODO: this condition should change when load-related-record gets its own executor -// and produces executionParams/executionResult like other steps. -export function isExecutedStepOnExecutor( - data: StepExecutionData | undefined, -): data is ExecutedStepExecutionData { - return !!data && data.type !== 'load-related-record'; -} + | UpdateRecordStepExecutionData + | TriggerRecordActionStepExecutionData + | RecordTaskStepExecutionData + | LoadRelatedRecordStepExecutionData + | McpTaskStepExecutionData; + +/** Alias for StepExecutionData — kept for backwards-compatible consumption at the call sites. */ +export type ExecutedStepExecutionData = StepExecutionData; diff --git a/packages/workflow-executor/src/types/step-outcome.ts b/packages/workflow-executor/src/types/step-outcome.ts index 9a564748e..3421b6017 100644 --- a/packages/workflow-executor/src/types/step-outcome.ts +++ b/packages/workflow-executor/src/types/step-outcome.ts @@ -1,15 +1,17 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ -type BaseStepStatus = 'success' | 'error'; +import { StepType } from './step-definition'; + +export type BaseStepStatus = 'success' | 'error'; /** Condition steps can fall back to human decision when the AI is uncertain. */ export type ConditionStepStatus = BaseStepStatus | 'manual-decision'; -/** AI task steps can pause mid-execution to await user input (e.g. tool confirmation). */ -export type AiTaskStepStatus = BaseStepStatus | 'awaiting-input'; +/** AI task steps can pause mid-execution to await user input (e.g. awaiting-input). */ +export type RecordTaskStepStatus = BaseStepStatus | 'awaiting-input'; /** Union of all step statuses. */ -export type StepStatus = ConditionStepStatus | AiTaskStepStatus; +export type StepStatus = ConditionStepStatus | RecordTaskStepStatus; /** * StepOutcome is sent to the orchestrator — it must NEVER contain client data. @@ -30,9 +32,21 @@ export interface ConditionStepOutcome extends BaseStepOutcome { selectedOption?: string; } -export interface AiTaskStepOutcome extends BaseStepOutcome { - type: 'ai-task'; - status: AiTaskStepStatus; +export interface RecordTaskStepOutcome extends BaseStepOutcome { + type: 'record-task'; + status: RecordTaskStepStatus; +} + +export interface McpTaskStepOutcome extends BaseStepOutcome { + type: 'mcp-task'; + status: RecordTaskStepStatus; } -export type StepOutcome = ConditionStepOutcome | AiTaskStepOutcome; +export type StepOutcome = ConditionStepOutcome | RecordTaskStepOutcome | McpTaskStepOutcome; + +export function stepTypeToOutcomeType(type: StepType): 'condition' | 'record-task' | 'mcp-task' { + if (type === StepType.Condition) return 'condition'; + if (type === StepType.McpTask) return 'mcp-task'; + + return 'record-task'; +} diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index b564eeaf5..cca7c3b4f 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -77,7 +77,7 @@ describe('AgentClientAgentPort', () => { it('should return a RecordData for a simple PK', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - const result = await port.getRecord('users', [42]); + const result = await port.getRecord({ collection: 'users', id: [42] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -93,7 +93,7 @@ describe('AgentClientAgentPort', () => { it('should build a composite And filter for composite PKs', async () => { mockCollection.list.mockResolvedValue([{ tenantId: 1, orderId: 2 }]); - await port.getRecord('orders', [1, 2]); + await port.getRecord({ collection: 'orders', id: [1, 2] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { @@ -110,13 +110,15 @@ describe('AgentClientAgentPort', () => { it('should throw a RecordNotFoundError when no record is found', async () => { mockCollection.list.mockResolvedValue([]); - await expect(port.getRecord('users', [999])).rejects.toThrow(RecordNotFoundError); + await expect(port.getRecord({ collection: 'users', id: [999] })).rejects.toThrow( + RecordNotFoundError, + ); }); - it('should pass fields to list when fieldNames is provided', async () => { + it('should pass fields to list when fields is provided', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord('users', [42], ['id', 'name']); + await port.getRecord({ collection: 'users', id: [42], fields: ['id', 'name'] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -125,10 +127,10 @@ describe('AgentClientAgentPort', () => { }); }); - it('should not pass fields to list when fieldNames is an empty array', async () => { + it('should not pass fields to list when fields is an empty array', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord('users', [42], []); + await port.getRecord({ collection: 'users', id: [42], fields: [] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -136,10 +138,10 @@ describe('AgentClientAgentPort', () => { }); }); - it('should not pass fields to list when fieldNames is undefined', async () => { + it('should not pass fields to list when fields is undefined', async () => { mockCollection.list.mockResolvedValue([{ id: 42, name: 'Alice' }]); - await port.getRecord('users', [42]); + await port.getRecord({ collection: 'users', id: [42] }); expect(mockCollection.list).toHaveBeenCalledWith({ filters: { field: 'id', operator: 'Equal', value: 42 }, @@ -150,7 +152,7 @@ describe('AgentClientAgentPort', () => { it('should fallback to pk field "id" when collection is unknown', async () => { mockCollection.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRecord('unknown', [1]); + const result = await port.getRecord({ collection: 'unknown', id: [1] }); expect(mockCollection.list).toHaveBeenCalledWith( expect.objectContaining({ @@ -165,7 +167,11 @@ describe('AgentClientAgentPort', () => { it('should call update with pipe-encoded id and return a RecordData', async () => { mockCollection.update.mockResolvedValue({ id: 42, name: 'Bob' }); - const result = await port.updateRecord('users', [42], { name: 'Bob' }); + const result = await port.updateRecord({ + collection: 'users', + id: [42], + values: { name: 'Bob' }, + }); expect(mockCollection.update).toHaveBeenCalledWith('42', { name: 'Bob' }); expect(result).toEqual({ @@ -178,7 +184,7 @@ describe('AgentClientAgentPort', () => { it('should encode composite PK to pipe format for update', async () => { mockCollection.update.mockResolvedValue({ tenantId: 1, orderId: 2 }); - await port.updateRecord('orders', [1, 2], { status: 'done' }); + await port.updateRecord({ collection: 'orders', id: [1, 2], values: { status: 'done' } }); expect(mockCollection.update).toHaveBeenCalledWith('1|2', { status: 'done' }); }); @@ -191,7 +197,12 @@ describe('AgentClientAgentPort', () => { { id: 11, title: 'Post B' }, ]); - const result = await port.getRelatedData('users', [42], 'posts'); + const result = await port.getRelatedData({ + collection: 'users', + id: [42], + relation: 'posts', + limit: null, + }); expect(mockCollection.relation).toHaveBeenCalledWith('posts', '42'); expect(result).toEqual([ @@ -208,10 +219,33 @@ describe('AgentClientAgentPort', () => { ]); }); + it('should apply pagination when limit is a number', async () => { + mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); + + await port.getRelatedData({ collection: 'users', id: [42], relation: 'posts', limit: 5 }); + + expect(mockRelation.list).toHaveBeenCalledWith( + expect.objectContaining({ pagination: { size: 5, number: 1 } }), + ); + }); + + it('should not apply pagination when limit is null', async () => { + mockRelation.list.mockResolvedValue([]); + + await port.getRelatedData({ collection: 'users', id: [42], relation: 'posts', limit: null }); + + expect(mockRelation.list).toHaveBeenCalledWith({}); + }); + it('should fallback to relationName when no CollectionSchema exists', async () => { mockRelation.list.mockResolvedValue([{ id: 1 }]); - const result = await port.getRelatedData('users', [42], 'unknownRelation'); + const result = await port.getRelatedData({ + collection: 'users', + id: [42], + relation: 'unknownRelation', + limit: null, + }); expect(result[0].collectionName).toBe('unknownRelation'); expect(result[0].recordId).toEqual([1]); @@ -220,26 +254,72 @@ describe('AgentClientAgentPort', () => { it('should return an empty array when no related data exists', async () => { mockRelation.list.mockResolvedValue([]); - expect(await port.getRelatedData('users', [42], 'posts')).toEqual([]); + expect( + await port.getRelatedData({ + collection: 'users', + id: [42], + relation: 'posts', + limit: null, + }), + ).toEqual([]); + }); + + it('should forward fields to the list call when provided', async () => { + mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); + + await port.getRelatedData({ + collection: 'users', + id: [42], + relation: 'posts', + limit: null, + fields: ['title'], + }); + + expect(mockRelation.list).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['title'] }), + ); + }); + + it('should omit fields from the list call when not provided', async () => { + mockRelation.list.mockResolvedValue([{ id: 10 }]); + + await port.getRelatedData({ collection: 'users', id: [42], relation: 'posts', limit: null }); + + expect(mockRelation.list).toHaveBeenCalledWith( + expect.not.objectContaining({ fields: expect.anything() }), + ); }); }); describe('executeAction', () => { - it('should encode recordIds to pipe format and call execute', async () => { + it('should encode ids to pipe format and call execute', async () => { mockAction.execute.mockResolvedValue({ success: 'done' }); - const result = await port.executeAction('users', 'sendEmail', [[1], [2]]); + const result = await port.executeAction({ + collection: 'users', + action: 'sendEmail', + id: [1], + }); - expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1', '2'] }); + expect(mockCollection.action).toHaveBeenCalledWith('sendEmail', { recordIds: ['1'] }); expect(result).toEqual({ success: 'done' }); }); + it('should call execute with empty recordIds when ids is not provided', async () => { + mockAction.execute.mockResolvedValue(undefined); + + await port.executeAction({ collection: 'users', action: 'archive' }); + + expect(mockCollection.action).toHaveBeenCalledWith('archive', { recordIds: [] }); + expect(mockAction.execute).toHaveBeenCalled(); + }); + it('should propagate errors from action execution', async () => { mockAction.execute.mockRejectedValue(new Error('Action failed')); - await expect(port.executeAction('users', 'sendEmail', [[1]])).rejects.toThrow( - 'Action failed', - ); + await expect( + port.executeAction({ collection: 'users', action: 'sendEmail', id: [1] }), + ).rejects.toThrow('Action failed'); }); }); }); diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index 9e69a04ea..8b38812df 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -38,6 +38,34 @@ describe('ForestServerWorkflowPort', () => { }); }); + describe('getPendingStepExecutionsForRun', () => { + it('calls the pending step execution route with the runId query param', async () => { + const step = { runId: 'run-42' } as PendingStepExecution; + mockQuery.mockResolvedValue(step); + + const result = await port.getPendingStepExecutionsForRun('run-42'); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/liana/v1/workflow-step-executions/pending?runId=run-42', + ); + expect(result).toBe(step); + }); + + it('encodes special characters in the runId', async () => { + mockQuery.mockResolvedValue({} as PendingStepExecution); + + await port.getPendingStepExecutionsForRun('run/42 special'); + + expect(mockQuery).toHaveBeenCalledWith( + options, + 'get', + '/liana/v1/workflow-step-executions/pending?runId=run%2F42%20special', + ); + }); + }); + describe('updateStepExecution', () => { it('should post step outcome to the complete route', async () => { mockQuery.mockResolvedValue(undefined); @@ -101,5 +129,11 @@ describe('ForestServerWorkflowPort', () => { await expect(port.getPendingStepExecutions()).rejects.toThrow('Network error'); }); + + it('should propagate errors from getPendingStepExecutionsForRun', async () => { + mockQuery.mockRejectedValue(new Error('Network error')); + + await expect(port.getPendingStepExecutionsForRun('run-1')).rejects.toThrow('Network error'); + }); }); }); diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts index 86491fbb8..8c3f5c254 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -1,20 +1,49 @@ +/* eslint-disable max-classes-per-file */ +import type { Logger } from '../../src/ports/logger-port'; import type { RunStore } from '../../src/ports/run-store'; import type { ExecutionContext, StepExecutionResult } from '../../src/types/execution'; import type { RecordRef } from '../../src/types/record'; import type { StepDefinition } from '../../src/types/step-definition'; import type { StepExecutionData } from '../../src/types/step-execution-data'; -import type { StepOutcome } from '../../src/types/step-outcome'; -import type { BaseMessage, SystemMessage } from '@langchain/core/messages'; -import type { DynamicStructuredTool } from '@langchain/core/tools'; +import type { BaseStepStatus, StepOutcome } from '../../src/types/step-outcome'; +import type { BaseMessage, DynamicStructuredTool } from '@forestadmin/ai-proxy'; -import { MalformedToolCallError, MissingToolCallError } from '../../src/errors'; +import { SystemMessage } from '@forestadmin/ai-proxy'; + +import { + MalformedToolCallError, + MissingToolCallError, + NoRecordsError, + StepPersistenceError, +} from '../../src/errors'; import BaseStepExecutor from '../../src/executors/base-step-executor'; import { StepType } from '../../src/types/step-definition'; /** Concrete subclass that exposes protected methods for testing. */ class TestableExecutor extends BaseStepExecutor { - async execute(): Promise { - throw new Error('not used'); + constructor(context: ExecutionContext, private readonly errorToThrow?: unknown) { + super(context); + } + + protected async doExecute(): Promise { + if (this.errorToThrow !== undefined) throw this.errorToThrow; + + return this.buildOutcomeResult({ status: 'success' }); + } + + protected buildOutcomeResult(outcome: { + status: BaseStepStatus; + error?: string; + }): StepExecutionResult { + return { + stepOutcome: { + type: 'record-task', + stepId: this.context.stepId, + stepIndex: this.context.stepIndex, + status: outcome.status, + ...(outcome.error !== undefined && { error: outcome.error }), + }, + }; } override buildPreviousStepsMessages(): Promise { @@ -54,6 +83,10 @@ function makeMockRunStore(stepExecutions: StepExecutionData[] = []): RunStore { }; } +function makeMockLogger(): Logger { + return { error: jest.fn() }; +} + function makeContext(overrides: Partial = {}): ExecutionContext { return { runId: 'run-1', @@ -73,8 +106,8 @@ function makeContext(overrides: Partial = {}): ExecutionContex agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), - history: [], - remoteTools: [], + previousSteps: [], + logger: makeMockLogger(), ...overrides, }; } @@ -87,7 +120,7 @@ describe('BaseStepExecutor', () => { expect(await executor.buildPreviousStepsMessages()).toEqual([]); }); - it('includes prompt and executionParams from previous steps', async () => { + it('calls getStepExecutions with runId and returns a SystemMessage with step content', async () => { const runStore = makeMockRunStore([ { type: 'condition', @@ -98,286 +131,140 @@ describe('BaseStepExecutor', () => { ]); const executor = new TestableExecutor( makeContext({ - history: [makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Approve?' })], + previousSteps: [makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Approve?' })], runStore, }), ); - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); + const messages = await executor.buildPreviousStepsMessages(); - expect(result).toContain('Step "cond-1"'); - expect(result).toContain('Prompt: Approve?'); - expect(result).toContain('Input: {"answer":"Yes","reasoning":"Order is valid"}'); - expect(result).toContain('Output: {"answer":"Yes"}'); expect(runStore.getStepExecutions).toHaveBeenCalledWith('run-1'); + expect(messages).toHaveLength(1); + expect(messages[0]).toBeInstanceOf(SystemMessage); + expect(messages[0].content).toContain('Step "cond-1"'); + expect(messages[0].content).toContain('Prompt: Approve?'); }); - it('uses Input for matched steps and History for unmatched steps', async () => { + it('separates multiple previous steps with a blank line', async () => { + const runStore = makeMockRunStore([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Valid' }, + executionResult: { answer: 'Yes' }, + }, + { + type: 'condition', + stepIndex: 1, + executionParams: { answer: 'No', reasoning: 'Wrong' }, + executionResult: { answer: 'No' }, + }, + ]); const executor = new TestableExecutor( makeContext({ - history: [ - makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0 }), + previousSteps: [ + makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'First?' }), makeHistoryEntry({ stepId: 'cond-2', stepIndex: 1, prompt: 'Second?' }), ], - // Only step 1 has an execution entry — step 0 has no match - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 1, - executionParams: { answer: 'No', reasoning: 'Clearly no' }, - executionResult: { answer: 'No' }, - }, - ]), + runStore, }), ); - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); + const messages = await executor.buildPreviousStepsMessages(); + const content = messages[0].content as string; - expect(result).toContain('Step "cond-1"'); - expect(result).toContain('History: {"status":"success"}'); - expect(result).toContain('Step "cond-2"'); - expect(result).toContain('Input: {"answer":"No","reasoning":"Clearly no"}'); - expect(result).toContain('Output: {"answer":"No"}'); + expect(content).toContain('Step "cond-1"'); + expect(content).toContain('Step "cond-2"'); + expect(content).toContain('\n\nStep "cond-2"'); }); + }); - it('falls back to History when no matching step execution in RunStore', async () => { - const executor = new TestableExecutor( - makeContext({ - history: [ - makeHistoryEntry({ stepId: 'orphan', stepIndex: 5, prompt: 'Orphan step' }), - makeHistoryEntry({ stepId: 'matched', stepIndex: 1, prompt: 'Matched step' }), - ], - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 1, - executionParams: { answer: 'B', reasoning: 'Option B fits' }, - executionResult: { answer: 'B' }, - }, - ]), - }), - ); + describe('execute error handling', () => { + it('converts NoRecordsError to error outcome', async () => { + const executor = new TestableExecutor(makeContext(), new NoRecordsError()); - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); + const result = await executor.execute(); - expect(result).toContain('Step "orphan"'); - expect(result).toContain('History: {"status":"success"}'); - expect(result).toContain('Step "matched"'); - expect(result).toContain('Input: {"answer":"B","reasoning":"Option B fits"}'); - expect(result).toContain('Output: {"answer":"B"}'); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No records available'); }); - it('includes selectedOption in History for condition steps', async () => { - const entry = makeHistoryEntry({ - stepId: 'cond-approval', - stepIndex: 0, - prompt: 'Approved?', + describe('unexpected error handling', () => { + it('returns error outcome instead of rethrowing', async () => { + const executor = new TestableExecutor(makeContext(), new Error('db connection refused')); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('Unexpected error during step execution'); }); - (entry.stepOutcome as { selectedOption?: string }).selectedOption = 'Yes'; - - const executor = new TestableExecutor( - makeContext({ - history: [entry], - runStore: makeMockRunStore([]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - expect(result).toContain('Step "cond-approval"'); - expect(result).toContain('"selectedOption":"Yes"'); - }); - - it('includes error in History for failed steps', async () => { - const entry = makeHistoryEntry({ - stepId: 'failing-step', - stepIndex: 0, - prompt: 'Do something', + it('logs the full error context when logger is provided', async () => { + const logger = makeMockLogger(); + const executor = new TestableExecutor( + makeContext({ logger }), + new Error('db connection refused'), + ); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'Unexpected error during step execution', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-0', + stepIndex: 0, + error: 'db connection refused', + }), + ); }); - entry.stepOutcome.status = 'error'; - (entry.stepOutcome as { error?: string }).error = 'AI could not match an option'; - - const executor = new TestableExecutor( - makeContext({ - history: [entry], - runStore: makeMockRunStore([]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('"status":"error"'); - expect(result).toContain('"error":"AI could not match an option"'); - }); - - it('includes status in History for ai-task steps without RunStore data', async () => { - const entry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { - stepDefinition: { - type: StepType.ReadRecord, - prompt: 'Run task', - }, - stepOutcome: { - type: 'ai-task', - stepId: 'ai-step', - stepIndex: 0, - status: 'awaiting-input', - }, - }; - - const executor = new TestableExecutor( - makeContext({ - history: [entry], - runStore: makeMockRunStore([]), - }), - ); - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "ai-step"'); - expect(result).toContain('History: {"status":"awaiting-input"}'); - }); - - it('uses Input when RunStore has executionParams, History otherwise', async () => { - const condEntry = makeHistoryEntry({ - stepId: 'cond-1', - stepIndex: 0, - prompt: 'Approved?', + it('includes stack trace in log context', async () => { + const logger = makeMockLogger(); + const err = new Error('db connection refused'); + const executor = new TestableExecutor(makeContext({ logger }), err); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'Unexpected error during step execution', + expect.objectContaining({ stack: err.stack }), + ); }); - (condEntry.stepOutcome as { selectedOption?: string }).selectedOption = 'Yes'; - - const aiEntry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { - stepDefinition: { - type: StepType.ReadRecord, - prompt: 'Read name', - }, - stepOutcome: { - type: 'ai-task', - stepId: 'read-customer', - stepIndex: 1, - status: 'success', - }, - }; - - const executor = new TestableExecutor( - makeContext({ - history: [condEntry, aiEntry], - runStore: makeMockRunStore([ - { - type: 'ai-task', - stepIndex: 1, - executionParams: { answer: 'John Doe' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "cond-1"'); - expect(result).toContain('History: {"status":"success","selectedOption":"Yes"}'); - expect(result).toContain('Step "read-customer"'); - expect(result).toContain('Input: {"answer":"John Doe"}'); - }); - - it('prefers RunStore execution data over History fallback', async () => { - const entry = makeHistoryEntry({ stepId: 'cond-1', stepIndex: 0, prompt: 'Pick one' }); - (entry.stepOutcome as { selectedOption?: string }).selectedOption = 'A'; - const executor = new TestableExecutor( - makeContext({ - history: [entry], - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 0, - executionParams: { answer: 'A', reasoning: 'Best fit' }, - executionResult: { answer: 'A' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); + it('handles non-Error throwables without crashing', async () => { + const executor = new TestableExecutor(makeContext(), 'a raw string thrown'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); - expect(result).toContain('Input: {"answer":"A","reasoning":"Best fit"}'); - expect(result).toContain('Output: {"answer":"A"}'); - expect(result).not.toContain('History:'); + it('includes cause in log when non-WorkflowExecutorError has a cause', async () => { + const logger = makeMockLogger(); + const cause = new Error('root cause'); + const error = Object.assign(new Error('wrapper error'), { cause }); + const executor = new TestableExecutor(makeContext({ logger }), error); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'Unexpected error during step execution', + expect.objectContaining({ cause: 'root cause' }), + ); + }); }); - it('omits Input line when executionParams is undefined', async () => { - const entry: { stepDefinition: StepDefinition; stepOutcome: StepOutcome } = { - stepDefinition: { - type: StepType.ReadRecord, - prompt: 'Do something', - }, - stepOutcome: { - type: 'ai-task', - stepId: 'ai-step', - stepIndex: 0, - status: 'success', - }, - }; - - const executor = new TestableExecutor( - makeContext({ - history: [entry], - runStore: makeMockRunStore([ - { - type: 'ai-task', - stepIndex: 0, - }, - ]), + it('logs cause when WorkflowExecutorError has a cause', async () => { + const logger = makeMockLogger(); + const cause = new Error('db timeout'); + const error = new StepPersistenceError('write failed', cause); + const executor = new TestableExecutor(makeContext({ logger }), error); + await executor.execute(); + expect(logger.error).toHaveBeenCalledWith( + 'write failed', + expect.objectContaining({ + cause: 'db timeout', + stack: cause.stack, }), ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Step "ai-step"'); - expect(result).toContain('Prompt: Do something'); - expect(result).not.toContain('Input:'); }); - it('shows "(no prompt)" when step has no prompt', async () => { - const entry = makeHistoryEntry({ stepIndex: 0 }); - entry.stepDefinition.prompt = undefined; - - const executor = new TestableExecutor( - makeContext({ - history: [entry], - runStore: makeMockRunStore([ - { - type: 'condition', - stepIndex: 0, - executionParams: { answer: 'A', reasoning: 'Only option' }, - executionResult: { answer: 'A' }, - }, - ]), - }), - ); - - const result = await executor - .buildPreviousStepsMessages() - .then(msgs => msgs[0]?.content ?? ''); - - expect(result).toContain('Prompt: (no prompt)'); + it('does not log when WorkflowExecutorError has no cause', async () => { + const logger = makeMockLogger(); + const executor = new TestableExecutor(makeContext({ logger }), new MissingToolCallError()); + await executor.execute(); + expect(logger.error).not.toHaveBeenCalled(); }); }); diff --git a/packages/workflow-executor/test/executors/condition-step-executor.test.ts b/packages/workflow-executor/test/executors/condition-step-executor.test.ts index 23eb6c836..696b200ab 100644 --- a/packages/workflow-executor/test/executors/condition-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/condition-step-executor.test.ts @@ -53,8 +53,8 @@ function makeContext( agentPort: {} as ExecutionContext['agentPort'], workflowPort: {} as ExecutionContext['workflowPort'], runStore: makeMockRunStore(), - history: [], - remoteTools: [], + previousSteps: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -175,7 +175,7 @@ describe('ConditionStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - history: [ + previousSteps: [ { stepDefinition: { type: StepType.Condition, @@ -252,30 +252,32 @@ describe('ConditionStepExecutor', () => { const result = await executor.execute(); + expect(result.stepOutcome.type).toBe('condition'); expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "choose-gateway-option": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('error propagation', () => { - it('returns error status when model invocation fails', async () => { + it('returns error outcome for infrastructure errors', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); const context = makeContext({ model: { bindTools } as unknown as ExecutionContext['model'], + runStore, }); const executor = new ConditionStepExecutor(context); const result = await executor.execute(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('API timeout'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('lets run store errors propagate', async () => { + it('returns error outcome when run store save fails with context', async () => { const mockModel = makeMockModel({ option: 'Approve', reasoning: 'OK', @@ -286,7 +288,10 @@ describe('ConditionStepExecutor', () => { }); const executor = new ConditionStepExecutor(makeContext({ model: mockModel.model, runStore })); - await expect(executor.execute()).rejects.toThrow('Storage full'); + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); }); }); }); diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts new file mode 100644 index 000000000..46910f72d --- /dev/null +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -0,0 +1,1497 @@ +import type { AgentPort } from '../../src/ports/agent-port'; +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { CollectionSchema, RecordData, RecordRef } from '../../src/types/record'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; +import type { LoadRelatedRecordStepExecutionData } from '../../src/types/step-execution-data'; + +import LoadRelatedRecordStepExecutor from '../../src/executors/load-related-record-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { + return { + type: StepType.LoadRelatedRecord, + prompt: 'Load the related order for this customer', + ...overrides, + }; +} + +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + +function makeRelatedRecordData(overrides: Partial = {}): RecordData { + return { + collectionName: 'orders', + recordId: [99], + values: { total: 150 }, + ...overrides, + }; +} + +function makeMockAgentPort(relatedData: RecordData[] = [makeRelatedRecordData()]): AgentPort { + return { + getRecord: jest.fn(), + updateRecord: jest.fn(), + getRelatedData: jest.fn().mockResolvedValue(relatedData), + executeAction: jest.fn(), + } as unknown as AgentPort; +} + +/** Default schema: 'Order' is BelongsTo (single record), 'Address' is HasMany. */ +function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { + return { + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { fieldName: 'order', displayName: 'Order', isRelationship: true, relationType: 'BelongsTo' }, + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + actions: [], + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort( + schemasByCollection: Record = { + customers: makeCollectionSchema(), + }, +): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest + .fn() + .mockImplementation((name: string) => + Promise.resolve( + schemasByCollection[name] ?? makeCollectionSchema({ collectionName: name }), + ), + ), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolCallArgs?: Record, toolName = 'select-relation') { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'load-1', + stepIndex: 0, + baseRecordRef: makeRecordRef(), + stepDefinition: makeStep(), + model: makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }).model, + agentPort: makeMockAgentPort(), + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + logger: { error: jest.fn() }, + ...overrides, + }; +} + +/** Builds a valid pending execution for Branch A tests. */ +function makePendingExecution( + overrides: Partial = {}, +): LoadRelatedRecordStepExecutionData { + return { + type: 'load-related-record', + stepIndex: 0, + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + selectedRecordId: [99], + suggestedFields: ['status', 'amount'], + }, + selectedRecordRef: makeRecordRef(), + ...overrides, + }; +} + +describe('LoadRelatedRecordStepExecutor', () => { + describe('automaticExecution: BelongsTo — load direct (Branch B)', () => { + it('fetches 1 related record and returns success', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + relation: 'order', + limit: 1, + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'load-related-record', + stepIndex: 0, + executionParams: { displayName: 'Order', name: 'order' }, + executionResult: expect.objectContaining({ + record: expect.objectContaining({ + collectionName: 'orders', + recordId: [99], + stepIndex: 0, + }), + }), + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('automaticExecution: HasMany — 2 AI calls (Branch B)', () => { + it('runs selectRelevantFields + selectBestRecord to pick the best candidate', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { fieldName: 'name', displayName: 'Name', isRelationship: false }, + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [ + { fieldName: 'city', displayName: 'City', isRelationship: false }, + { fieldName: 'zip', displayName: 'Zip', isRelationship: false }, + ], + }); + + // Call 1: select-relation → Address; Call 2: select-fields → ['City'] (displayName); + // Call 3: select-record-by-content → index 1 (Lyon) + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 1, reasoning: 'Lyon is relevant' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: hasManySchema, + addresses: addressSchema, + }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).toHaveBeenCalledTimes(3); + expect(bindTools.mock.calls[0][0][0].name).toBe('select-relation'); + expect(bindTools.mock.calls[1][0][0].name).toBe('select-fields'); + expect(bindTools.mock.calls[2][0][0].name).toBe('select-record-by-content'); + + // Fetches 50 candidates (HasMany) + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + relation: 'address', + limit: 50, + }); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'addresses', recordId: [2] }), + }), + }), + ); + }); + + it('skips field-selection AI call when related collection has no non-relation fields', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: {} }, + { collectionName: 'addresses', recordId: [2], values: {} }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [], + }); + + // Call 1: select-relation; Call 2: select-record-by-content (no select-fields) + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'First is best' }, + id: 'c2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).toHaveBeenCalledTimes(2); + expect(bindTools.mock.calls[0][0][0].name).toBe('select-relation'); + expect(bindTools.mock.calls[1][0][0].name).toBe('select-record-by-content'); + }); + + it('takes the single candidate directly without AI record-selection calls', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const agentPort = makeMockAgentPort([ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + ]); + + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load address' }, + id: 'c1', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const context = makeContext({ + model, + agentPort, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // Only select-relation was called — no field/record AI calls for single candidate + expect(bindTools).toHaveBeenCalledTimes(1); + }); + + it('returns error outcome when AI selects an out-of-range record index', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + + // Call 1: select-relation; Call 2: select-fields; Call 3: out-of-range index 999 + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['city'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 999, reasoning: 'Out of range' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI made an unexpected choice. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns empty fieldNames violating the min:1 constraint', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + + // Call 1: select-relation; Call 2: select-fields returns empty array (AI violation) + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load addresses' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: [] }, id: 'c2' }], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema, addresses: addressSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI made an unexpected choice. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('automaticExecution: HasOne — load direct (Branch B)', () => { + it('fetches 1 related record (same path as BelongsTo) and returns success', async () => { + const hasOneSchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'profile', + displayName: 'Profile', + isRelationship: true, + relationType: 'HasOne', + }, + ], + }); + const agentPort = makeMockAgentPort([ + { collectionName: 'profiles', recordId: [5], values: {} }, + ]); + const mockModel = makeMockModel({ relationName: 'Profile', reasoning: 'Load profile' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasOneSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // HasOne uses the same fetchFirstCandidate path as BelongsTo — limit: 1 + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + relation: 'profile', + limit: 1, + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'profiles', recordId: [5] }), + }), + }), + ); + }); + }); + + describe('without automaticExecution: awaiting-input (Branch C)', () => { + it('saves AI suggestion in pendingData and returns awaiting-input (single record — no field/record AI calls)', async () => { + const agentPort = makeMockAgentPort(); // returns 1 record: orders #99 + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + relation: 'order', + limit: 50, + }); + // Single record → only select-relation AI call + expect(mockModel.bindTools).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'load-related-record', + stepIndex: 0, + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + selectedRecordId: [99], + suggestedFields: [], + }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + + it('runs field-selection + record-selection AI calls when multiple related records exist', async () => { + const relatedData: RecordData[] = [ + { collectionName: 'orders', recordId: [1], values: { status: 'pending' } }, + { collectionName: 'orders', recordId: [2], values: { status: 'completed' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [{ fieldName: 'status', displayName: 'Status', isRelationship: false }], + }); + + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Order', reasoning: 'Load order' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['Status'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 1, reasoning: 'Completed is best' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(3); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + selectedRecordId: [2], // record at index 1 + suggestedFields: ['status'], + }, + }), + ); + }); + + it('skips field-selection AI call when related collection has no non-relation fields', async () => { + const relatedData: RecordData[] = [ + { collectionName: 'orders', recordId: [1], values: {} }, + { collectionName: 'orders', recordId: [2], values: {} }, + ]; + const agentPort = makeMockAgentPort(relatedData); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [], + }); + + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Order', reasoning: 'Load order' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'First' }, + id: 'c2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // select-relation + select-record-by-content (no select-fields) + expect(bindTools).toHaveBeenCalledTimes(2); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: expect.objectContaining({ + selectedRecordId: [1], + suggestedFields: [], + }), + }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('uses selectedRecordId from pendingData, no getRelatedData call', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution(); // selectedRecordId: [99] + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'load-related-record', + executionParams: { displayName: 'Order', name: 'order' }, + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), + }), + pendingData: expect.objectContaining({ + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + selectedRecordId: [99], + }), + }), + ); + }); + + it('uses selectedRecordId when the user overrides the AI suggestion', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution({ + pendingData: { + displayName: 'Order', + name: 'order', + relatedCollectionName: 'orders', + suggestedFields: ['status', 'amount'], + selectedRecordId: [42], + }, + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'orders', recordId: [42] }), + }), + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('skips the load when user rejects', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore, userConfirmed: false }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingData: expect.objectContaining({ displayName: 'Order', name: 'order' }), + }), + ); + }); + }); + + describe('no pending data in confirmation flow (Branch A)', () => { + it('returns error outcome when no execution record is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'load-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when execution exists but pendingData is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'load-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('NoRelationshipFieldsError', () => { + it('returns error when collection has no relationship fields', async () => { + const schema = makeCollectionSchema({ + fields: [{ fieldName: 'email', displayName: 'Email', isRelationship: false }], + }); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'This record type has no relations configured in Forest Admin.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('RelatedRecordNotFoundError', () => { + it('returns error when BelongsTo getRelatedData returns empty array (Branch B)', async () => { + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The related record could not be found. It may have been deleted.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when HasMany getRelatedData returns empty array (Branch B)', async () => { + const hasManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + }, + ], + }); + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: hasManySchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The related record could not be found. It may have been deleted.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when getRelatedData returns empty array (Branch C)', async () => { + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The related record could not be found. It may have been deleted.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('StepPersistenceError post-load', () => { + it('returns error outcome when saveStepExecution fails after load (Branch B)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + runId: 'run-1', + stepIndex: 0, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + }); + + it('returns error outcome when saveStepExecution fails after load (Branch A confirmed)', async () => { + const execution = makePendingExecution(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runId: 'run-1', stepIndex: 0, runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + }); + }); + + describe('resolveRelationName failure', () => { + it('returns error when AI returns a relation name not found in the schema', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ relationName: 'NonExistentRelation', reasoning: 'test' }); + const schema = makeCollectionSchema({ + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI selected a relation that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + }); + }); + + describe('AI malformed/missing tool call', () => { + it('returns error on malformed tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'select-relation', args: '{bad json', error: 'JSON parse error' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('load-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns no tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ tool_calls: [] }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('load-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('infra error propagation', () => { + it('returns error outcome for getRelatedData infrastructure errors (Branch B)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome for getRelatedData infrastructure errors (Branch C)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ model: mockModel.model, agentPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns user message and logs cause when agentPort.getRelatedData throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + logger, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "getRelatedData" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); + }); + + describe('multi-record AI selection (base record pool)', () => { + it('uses AI to select among multiple base records then loads relation', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + const relatedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { + fieldName: 'invoice', + displayName: 'Invoice', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + + // Call 1: select-record; Call 2: select-relation + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Invoice', reasoning: 'Load the invoice' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const agentPort = makeMockAgentPort([ + { collectionName: 'invoices', recordId: [55], values: {} }, + ]); + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort, agentPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(2); + + const selectRecordTool = bindTools.mock.calls[0][0][0]; + expect(selectRecordTool.name).toBe('select-record'); + + const selectRelationTool = bindTools.mock.calls[1][0][0]; + expect(selectRelationTool.name).toBe('select-relation'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: expect.objectContaining({ + displayName: 'Invoice', + name: 'invoice', + relatedCollectionName: 'invoices', + selectedRecordId: [55], + }), + selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders' }), + }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex in the outcome', async () => { + const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'load-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in select-relation messages', async () => { + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new LoadRelatedRecordStepExecutor({ + ...context, + stepId: 'load-2', + stepIndex: 1, + }); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + // previous steps message + system prompt + collection info + human message = 4 + expect(messages).toHaveLength(4); + expect(messages[0].content).toContain('Should we proceed?'); + expect(messages[0].content).toContain('"answer":"Yes"'); + expect(messages[1].content).toContain('loading a related record'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[mockModel.invoke.mock.calls.length - 1][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Load the relevant related record.'); + }); + }); + + describe('RunStore error propagation', () => { + it('returns error outcome when getStepExecutions fails (Branch A)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome when saveStepExecution fails saving awaiting-input (Branch C)', async () => { + const agentPort = makeMockAgentPort(); + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { + const execution = makePendingExecution(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore, userConfirmed: false }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + }); + + describe('displayName → fieldName resolution fallback', () => { + it('resolves relation when AI returns technical name instead of displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns technical name 'order' instead of display name 'Order' + const mockModel = makeMockModel({ relationName: 'order', reasoning: 'fallback' }); + const schema = makeCollectionSchema({ + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ + model: mockModel.model, + agentPort, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.getRelatedData).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + relation: 'order', + limit: 1, + }); + }); + }); + + describe('schema caching', () => { + it('fetches getCollectionSchema once per collection even when called twice (Branch B)', async () => { + const workflowPort = makeMockWorkflowPort(); + const context = makeContext({ + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('getAvailableRecordRefs filtering', () => { + it('excludes a pending load-related-record (no record field) from the record pool', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + // A completed load-related-record (has record) — should appear in pool + const completedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + // A pending load-related-record (no record — awaiting-input state) — should be excluded + const pendingExecution = { + type: 'load-related-record' as const, + stepIndex: 3, + selectedRecordRef: makeRecordRef(), + pendingData: { + displayName: 'Invoice', + name: 'invoice', + relatedCollectionName: 'invoices', + selectedRecordId: [55], + }, + }; + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + + // Call 1: select-record (picks the completed related record) + // Call 2: select-relation + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Order', reasoning: 'test' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: completedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + pendingExecution, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + // Pool = [base, completedRecord] = 2 items → select-record IS invoked + // Pool does NOT include pending execution (no record) → only 2 options, not 3 + expect(bindTools).toHaveBeenCalledTimes(2); + const selectRecordTool = bindTools.mock.calls[0][0][0]; + expect(selectRecordTool.name).toBe('select-record'); + expect(selectRecordTool.schema.shape.recordIdentifier.options).toHaveLength(2); + expect(selectRecordTool.schema.shape.recordIdentifier.options).not.toContain( + expect.stringContaining('stepIndex: 3'), + ); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts new file mode 100644 index 000000000..5525014dd --- /dev/null +++ b/packages/workflow-executor/test/executors/mcp-task-step-executor.test.ts @@ -0,0 +1,726 @@ +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { McpTaskStepDefinition } from '../../src/types/step-definition'; +import type { McpTaskStepExecutionData } from '../../src/types/step-execution-data'; + +import RemoteTool from '@forestadmin/ai-proxy/src/remote-tool'; + +import { StepStateError } from '../../src/errors'; +import McpTaskStepExecutor from '../../src/executors/mcp-task-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +class MockRemoteTool extends RemoteTool { + constructor(options: { name: string; sourceId?: string; invoke?: jest.Mock }) { + const invokeFn = options.invoke ?? jest.fn().mockResolvedValue('tool-result'); + super({ + tool: { + name: options.name, + description: `${options.name} description`, + schema: { parse: jest.fn(), _def: {} } as unknown as RemoteTool['base']['schema'], + invoke: invokeFn, + } as unknown as RemoteTool['base'], + sourceId: options.sourceId ?? 'mcp-server-1', + sourceType: 'mcp', + }); + } +} + +function makeStep(overrides: Partial = {}): McpTaskStepDefinition { + return { + type: StepType.McpTask, + prompt: 'Send a notification to the user', + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort(): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest.fn().mockResolvedValue({ + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [], + actions: [], + }), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolName: string, toolArgs: Record) { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [{ name: toolName, args: toolArgs, id: 'call_1' }], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'mcp-1', + stepIndex: 0, + baseRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + stepDefinition: makeStep(), + model: makeMockModel('send_notification', { message: 'Hello' }).model, + agentPort: { + getRecord: jest.fn(), + updateRecord: jest.fn(), + getRelatedData: jest.fn(), + executeAction: jest.fn(), + } as unknown as ExecutionContext['agentPort'], + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + logger: { error: jest.fn() }, + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('McpTaskStepExecutor', () => { + describe('automaticExecution: direct execution (Branch B)', () => { + it('invokes the tool and returns success', async () => { + const invokeFn = jest.fn().mockResolvedValue({ result: 'notification sent' }); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { + message: 'Hello', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(invokeFn).toHaveBeenCalledWith({ message: 'Hello' }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'mcp-task', + stepIndex: 0, + executionParams: { name: 'send_notification', input: { message: 'Hello' } }, + executionResult: { success: true, toolResult: { result: 'notification sent' } }, + }), + ); + // Model is invoked twice: once for tool selection, once for AI formatting + expect(modelInvoke).toHaveBeenCalledTimes(2); + }); + + it('persists formattedResponse when AI formatting succeeds', async () => { + const toolResult = { result: 'notification sent' }; + const invokeFn = jest.fn().mockResolvedValue(toolResult); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { + message: 'Hello', + }); + // Second model call (formatting) returns a summary + modelInvoke + .mockResolvedValueOnce({ + tool_calls: [{ name: 'send_notification', args: { message: 'Hello' }, id: 'call_1' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { name: 'summarize-result', args: { summary: 'Found 3 results.' }, id: 'call_2' }, + ], + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(modelInvoke).toHaveBeenCalledTimes(2); + // First save: raw result only + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 1, + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult }, + }), + ); + // Second save: raw result + formattedResponse + expect(runStore.saveStepExecution).toHaveBeenNthCalledWith( + 2, + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult, formattedResponse: 'Found 3 results.' }, + }), + ); + }); + + it('returns success and logs when AI formatting throws', async () => { + const invokeFn = jest.fn().mockResolvedValue({ result: 'ok' }); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { message: 'Hi' }); + // Second call (formatting) returns no tool calls → MissingToolCallError + modelInvoke + .mockResolvedValueOnce({ + tool_calls: [{ name: 'send_notification', args: { message: 'Hi' }, id: 'call_1' }], + }) + .mockResolvedValueOnce({ tool_calls: [] }); + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + logger, + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // Only the first save (raw result) — no second save since formatting failed + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult: { result: 'ok' } }, + }), + ); + expect(logger.error).toHaveBeenCalledWith( + 'Failed to format MCP tool result, using generic fallback', + expect.objectContaining({ toolName: 'send_notification' }), + ); + }); + + it('does not call AI formatting when toolResult is null', async () => { + const invokeFn = jest.fn().mockResolvedValue(null); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model, invoke: modelInvoke } = makeMockModel('send_notification', { message: 'Hi' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // Model called only once (tool selection) — no formatting call for null result + expect(modelInvoke).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledTimes(1); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { success: true, toolResult: null }, + }), + ); + }); + }); + + describe('without automaticExecution: awaiting-input (Branch C)', () => { + it('saves pendingData and returns awaiting-input', async () => { + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const runStore = makeMockRunStore(); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const context = makeContext({ model, runStore }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }), + ); + }); + + it('returns error when saveStepExecution fails (Branch C)', async () => { + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('DB unavailable')), + }); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const context = makeContext({ model, runStore, logger }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(logger.error).toHaveBeenCalledWith( + 'MCP task step state could not be persisted (run "run-1", step 0)', + expect.objectContaining({ cause: 'DB unavailable', stepId: 'mcp-1' }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('loads pendingData, invokes the tool, and persists the result', async () => { + const invokeFn = jest.fn().mockResolvedValue('email sent'); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(invokeFn).toHaveBeenCalledWith({ message: 'Hello' }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'mcp-task', + executionParams: { name: 'send_notification', input: { message: 'Hello' } }, + executionResult: { success: true, toolResult: 'email sent' }, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('saves skipped result and returns success without invoking the tool', async () => { + const invokeFn = jest.fn(); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: false }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(invokeFn).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }), + ); + }); + }); + + describe('mcpServerId filter', () => { + it('passes only tools from the specified server to the AI', async () => { + const toolA = new MockRemoteTool({ name: 'tool_a', sourceId: 'server-A' }); + const toolB = new MockRemoteTool({ name: 'tool_b', sourceId: 'server-B' }); + const invokeFn = jest.fn().mockResolvedValue('ok'); + const toolB2 = new MockRemoteTool({ + name: 'tool_b2', + sourceId: 'server-B', + invoke: invokeFn, + }); + + const { model, bindTools } = makeMockModel('tool_b', {}); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ mcpServerId: 'server-B', automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [toolA, toolB, toolB2]); + + await executor.execute(); + + // bindTools should only receive server-B tools + 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'); + }); + }); + + describe('NoMcpToolsError', () => { + it('returns error when remoteTools is empty', async () => { + const context = makeContext(); + const executor = new McpTaskStepExecutor(context, []); + + 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' }); + const context = makeContext({ + stepDefinition: makeStep({ mcpServerId: 'server-B' }), + }); + const executor = new McpTaskStepExecutor(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.'); + }); + }); + + describe('McpToolNotFoundError', () => { + it('returns error when tool from pendingData no longer exists (Branch A)', async () => { + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'deleted_tool', input: {} }, + }; + const tool = new MockRemoteTool({ name: 'other_tool', sourceId: 'mcp-server-1' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI selected a tool that doesn't exist. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('StepPersistenceError', () => { + it('returns error and logs cause when saveStepExecution fails after tool invocation (Branch B)', async () => { + const invokeFn = jest.fn().mockResolvedValue('ok'); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model } = makeMockModel('send_notification', { message: 'Hello' }); + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + logger, + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(logger.error).toHaveBeenCalledWith( + 'MCP tool "send_notification" executed but step state could not be persisted (run "run-1", step 0)', + expect.objectContaining({ cause: 'Disk full', stepId: 'mcp-1' }), + ); + }); + + it('returns error and logs cause when saveStepExecution fails after tool invocation (Branch A)', async () => { + const invokeFn = jest.fn().mockResolvedValue('ok'); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + pendingData: { name: 'send_notification', input: { message: 'Hello' } }, + }; + const logger = { error: jest.fn() }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore, userConfirmed: true, logger }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + expect(logger.error).toHaveBeenCalledWith( + 'MCP tool "send_notification" executed but step state could not be persisted (run "run-1", step 0)', + expect.objectContaining({ cause: 'Disk full', stepId: 'mcp-1' }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex', async () => { + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const { model } = makeMockModel('send_notification', {}); + const context = makeContext({ + model, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'mcp-task', + stepId: 'mcp-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('no pending data in confirmation flow (Branch A)', () => { + it('returns error when no execution record is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, []); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + }); + + it('returns error when execution exists but pendingData is absent', async () => { + const execution: McpTaskStepExecutionData = { + type: 'mcp-task', + stepIndex: 0, + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ runStore, userConfirmed: true }); + const executor = new McpTaskStepExecutor(context, []); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + }); + }); + + describe('tool.base.invoke error', () => { + it('returns error when tool invocation throws a WorkflowExecutorError', async () => { + const invokeFn = jest.fn().mockRejectedValue(new StepStateError('Tool failed')); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model } = makeMockModel('send_notification', {}); + const mockRunStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore: mockRunStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(mockRunStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error and logs when tool invocation throws an infrastructure error', async () => { + const invokeFn = jest.fn().mockRejectedValue(new Error('Connection refused')); + const tool = new MockRemoteTool({ + name: 'send_notification', + sourceId: 'mcp-server-1', + invoke: invokeFn, + }); + const { model } = makeMockModel('send_notification', {}); + const logger = { error: jest.fn() }; + const context = makeContext({ + model, + stepDefinition: makeStep({ automaticExecution: true }), + logger, + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The tool failed to execute. Please try again or contact your administrator.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'MCP tool "send_notification" invocation failed: Connection refused', + expect.objectContaining({ cause: 'Connection refused' }), + ); + }); + }); + + describe('selectTool AI errors', () => { + it('returns error when AI returns a malformed tool call (MalformedToolCallError)', async () => { + const model = { + bindTools: jest.fn().mockReturnValue({ + invoke: jest.fn().mockResolvedValue({ + tool_calls: [{ name: 'send_notification', args: null, id: 'call_1' }], + }), + }), + } as unknown as ExecutionContext['model']; + const tool = new MockRemoteTool({ name: 'send_notification' }); + const context = makeContext({ model }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); + }); + + it('returns error when AI returns no tool call (MissingToolCallError)', async () => { + const model = { + bindTools: jest.fn().mockReturnValue({ + invoke: jest.fn().mockResolvedValue({ tool_calls: [] }), + }), + } as unknown as ExecutionContext['model']; + const tool = new MockRemoteTool({ name: 'send_notification' }); + const context = makeContext({ model }); + const executor = new McpTaskStepExecutor(context, [tool]); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const { model, invoke: modelInvoke } = makeMockModel('send_notification', {}); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const context = makeContext({ + model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new McpTaskStepExecutor(context, [tool]); + + await executor.execute(); + + const messages = modelInvoke.mock.calls[0][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Execute the relevant tool.'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in selectTool messages', async () => { + const { model, invoke: modelInvoke } = makeMockModel('send_notification', {}); + const tool = new MockRemoteTool({ name: 'send_notification', sourceId: 'mcp-server-1' }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we send a notification?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new McpTaskStepExecutor({ ...context, stepId: 'mcp-2', stepIndex: 1 }, [ + tool, + ]); + + await executor.execute(); + + const messages = modelInvoke.mock.calls[0][0]; + // previous steps message + system prompt + human message = 3 + expect(messages).toHaveLength(3); + expect(messages[0].content).toContain('Should we send a notification?'); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts index eb9c3bc5d..cee40bc8c 100644 --- a/packages/workflow-executor/test/executors/read-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/read-record-step-executor.test.ts @@ -3,13 +3,13 @@ import type { RunStore } from '../../src/ports/run-store'; import type { WorkflowPort } from '../../src/ports/workflow-port'; import type { ExecutionContext } from '../../src/types/execution'; import type { CollectionSchema, RecordRef } from '../../src/types/record'; -import type { AiTaskStepDefinition } from '../../src/types/step-definition'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; import { NoRecordsError, RecordNotFoundError } from '../../src/errors'; import ReadRecordStepExecutor from '../../src/executors/read-record-step-executor'; import { StepType } from '../../src/types/step-definition'; -function makeStep(overrides: Partial = {}): AiTaskStepDefinition { +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { return { type: StepType.ReadRecord, prompt: 'Read the customer email', @@ -34,8 +34,8 @@ function makeMockAgentPort( return { getRecord: jest .fn() - .mockImplementation((collectionName: string) => - Promise.resolve(recordsByCollection[collectionName] ?? { values: {} }), + .mockImplementation(({ collection }: { collection: string }) => + Promise.resolve(recordsByCollection[collection] ?? { values: {} }), ), updateRecord: jest.fn(), getRelatedData: jest.fn(), @@ -73,6 +73,7 @@ function makeMockWorkflowPort( ): WorkflowPort { return { getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), updateStepExecution: jest.fn().mockResolvedValue(undefined), getCollectionSchema: jest .fn() @@ -99,8 +100,8 @@ function makeMockModel( } function makeContext( - overrides: Partial> = {}, -): ExecutionContext { + overrides: Partial> = {}, +): ExecutionContext { return { runId: 'run-1', stepId: 'read-1', @@ -111,8 +112,8 @@ function makeContext( agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), - history: [], - remoteTools: [], + previousSteps: [], + logger: { error: jest.fn() }, ...overrides, }; } @@ -133,9 +134,9 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ type: 'read-record', stepIndex: 0, - executionParams: { fieldNames: ['email'] }, + executionParams: { fields: [{ name: 'email', displayName: 'Email' }] }, executionResult: { - fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], + fields: [{ value: 'john@example.com', name: 'email', displayName: 'Email' }], }, }), ); @@ -155,11 +156,16 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionParams: { fieldNames: ['email', 'name'] }, + executionParams: { + fields: [ + { name: 'email', displayName: 'Email' }, + { name: 'name', displayName: 'Full Name' }, + ], + }, executionResult: { fields: [ - { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, - { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, + { value: 'john@example.com', name: 'email', displayName: 'Email' }, + { value: 'John Doe', name: 'name', displayName: 'Full Name' }, ], }, }), @@ -180,9 +186,9 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionParams: { fieldNames: ['name'] }, + executionParams: { fields: [{ name: 'name', displayName: 'Full Name' }] }, executionResult: { - fields: [{ value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }], + fields: [{ value: 'John Doe', name: 'name', displayName: 'Full Name' }], }, }), ); @@ -199,7 +205,11 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(); - expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['name', 'email']); + expect(agentPort.getRecord).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + fields: ['name', 'email'], + }); }); it('passes only resolved field names when some fields are unresolved', async () => { @@ -211,7 +221,11 @@ describe('ReadRecordStepExecutor', () => { await executor.execute(); - expect(agentPort.getRecord).toHaveBeenCalledWith('customers', [42], ['email']); + expect(agentPort.getRecord).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + fields: ['email'], + }); }); it('returns error when no fields can be resolved', async () => { @@ -225,7 +239,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'None of the requested fields could be resolved: nonexistent, unknown', + "The AI selected fields that don't exist on this record. Try rephrasing the step's prompt.", ); expect(agentPort.getRecord).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); @@ -247,10 +261,10 @@ describe('ReadRecordStepExecutor', () => { expect.objectContaining({ executionResult: { fields: [ - { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, + { value: 'john@example.com', name: 'email', displayName: 'Email' }, { error: 'Field not found: nonexistent', - fieldName: 'nonexistent', + name: 'nonexistent', displayName: 'nonexistent', }, ], @@ -310,7 +324,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'No readable fields on record from collection "customers"', + 'This record type has no readable fields configured in Forest Admin.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -356,11 +370,17 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -392,7 +412,7 @@ describe('ReadRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { - fields: [{ value: 'john@example.com', fieldName: 'email', displayName: 'Email' }], + fields: [{ value: 'john@example.com', name: 'email', displayName: 'Email' }], }, selectedRecordRef: expect.objectContaining({ recordId: [42], @@ -429,18 +449,28 @@ describe('ReadRecordStepExecutor', () => { }) .mockResolvedValueOnce({ tool_calls: [ - { name: 'read-selected-record-fields', args: { fieldNames: ['total'] }, id: 'call_2' }, + { + name: 'read-selected-record-fields', + args: { fieldNames: ['total'] }, + id: 'call_2', + }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 2, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -459,7 +489,7 @@ describe('ReadRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { - fields: [{ value: 150, fieldName: 'total', displayName: 'Total' }], + fields: [{ value: 150, name: 'total', displayName: 'Total' }], }, selectedRecordRef: expect.objectContaining({ recordId: [99], @@ -496,18 +526,28 @@ describe('ReadRecordStepExecutor', () => { }) .mockResolvedValueOnce({ tool_calls: [ - { name: 'read-selected-record-fields', args: { fieldNames: ['email'] }, id: 'call_2' }, + { + name: 'read-selected-record-fields', + args: { fieldNames: ['email'] }, + id: 'call_2', + }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 5, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 5, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -553,11 +593,17 @@ describe('ReadRecordStepExecutor', () => { const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore({ - getStepExecutions: jest - .fn() - .mockResolvedValue([ - { type: 'load-related-record', stepIndex: 1, record: relatedRecord }, - ]), + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 1, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), }); const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema(), @@ -570,7 +616,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI selected record "NonExistent #999" which does not match any available record', + "The AI made an unexpected choice. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -590,23 +636,46 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('Record not found: collection "customers", id "42"'); + expect(result.stepOutcome.error).toBe( + 'The record no longer exists. It may have been deleted.', + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('lets infrastructure errors propagate', async () => { + it('returns error outcome for infrastructure errors', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); const mockModel = makeMockModel({ fieldNames: ['email'] }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection refused'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns user message and logs cause when agentPort.getRecord throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.getRecord as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ fieldNames: ['email'] }); + const context = makeContext({ model: mockModel.model, agentPort, logger }); + const executor = new ReadRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "getRecord" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); }); }); describe('model error', () => { - it('lets non-WorkflowExecutorError propagate from AI invocation', async () => { + it('returns error outcome for non-WorkflowExecutorError from AI invocation', async () => { const invoke = jest.fn().mockRejectedValue(new Error('API timeout')); const bindTools = jest.fn().mockReturnValue({ invoke }); const context = makeContext({ @@ -614,7 +683,8 @@ describe('ReadRecordStepExecutor', () => { }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('API timeout'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); @@ -638,7 +708,7 @@ describe('ReadRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'AI returned a malformed tool call for "read-selected-record-fields": JSON parse error', + "The AI returned an unexpected response. Try rephrasing the step's prompt.", ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -656,13 +726,15 @@ describe('ReadRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe('AI did not return a tool call'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); describe('RunStore error propagation', () => { - it('lets saveStepExecution errors propagate', async () => { + it('returns error outcome when saveStepExecution fails', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ saveStepExecution: jest.fn().mockRejectedValue(new Error('Storage full')), @@ -670,10 +742,11 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Storage full'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); - it('lets getStepExecutions errors propagate', async () => { + it('returns error outcome when getStepExecutions fails', async () => { const mockModel = makeMockModel({ fieldNames: ['email'] }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockRejectedValue(new Error('Connection lost')), @@ -681,7 +754,8 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore }); const executor = new ReadRecordStepExecutor(context); - await expect(executor.execute()).rejects.toThrow('Connection lost'); + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); }); }); @@ -700,7 +774,7 @@ describe('ReadRecordStepExecutor', () => { const context = makeContext({ model: mockModel.model, runStore, - history: [ + previousSteps: [ { stepDefinition: { type: StepType.Condition, @@ -766,11 +840,16 @@ describe('ReadRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { type: 'read-record', stepIndex: 3, - executionParams: { fieldNames: ['email', 'name'] }, + executionParams: { + fields: [ + { name: 'email', displayName: 'Email' }, + { name: 'name', displayName: 'Full Name' }, + ], + }, executionResult: { fields: [ - { value: 'john@example.com', fieldName: 'email', displayName: 'Email' }, - { value: 'John Doe', fieldName: 'name', displayName: 'Full Name' }, + { value: 'john@example.com', name: 'email', displayName: 'Email' }, + { value: 'John Doe', name: 'name', displayName: 'Full Name' }, ], }, selectedRecordRef: { diff --git a/packages/workflow-executor/test/executors/safe-agent-port.test.ts b/packages/workflow-executor/test/executors/safe-agent-port.test.ts new file mode 100644 index 000000000..33ab53d07 --- /dev/null +++ b/packages/workflow-executor/test/executors/safe-agent-port.test.ts @@ -0,0 +1,173 @@ +import type { AgentPort } from '../../src/ports/agent-port'; + +import { AgentPortError, StepStateError, WorkflowExecutorError } from '../../src/errors'; +import SafeAgentPort from '../../src/executors/safe-agent-port'; + +function makeMockPort(overrides: Partial = {}): AgentPort { + return { + getRecord: jest + .fn() + .mockResolvedValue({ collectionName: 'customers', recordId: [1], values: {} }), + updateRecord: jest + .fn() + .mockResolvedValue({ collectionName: 'customers', recordId: [1], values: {} }), + getRelatedData: jest.fn().mockResolvedValue([]), + executeAction: jest.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as AgentPort; +} + +describe('SafeAgentPort', () => { + describe('returns result when port call succeeds', () => { + it('getRecord returns the port result', async () => { + const expected = { collectionName: 'customers', recordId: [1], values: { email: 'a@b.com' } }; + const port = makeMockPort({ getRecord: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.getRecord({ collection: 'customers', id: [1] }); + + expect(result).toBe(expected); + }); + + it('updateRecord returns the port result', async () => { + const expected = { collectionName: 'customers', recordId: [1], values: { status: 'active' } }; + const port = makeMockPort({ updateRecord: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.updateRecord({ + collection: 'customers', + id: [1], + values: { status: 'active' }, + }); + + expect(result).toBe(expected); + }); + + it('getRelatedData returns the port result', async () => { + const expected = [{ collectionName: 'orders', recordId: [10], values: {} }]; + const port = makeMockPort({ getRelatedData: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.getRelatedData({ + collection: 'customers', + id: [1], + relation: 'orders', + limit: 10, + }); + + expect(result).toBe(expected); + }); + + it('executeAction returns the port result', async () => { + const expected = { success: true }; + const port = makeMockPort({ executeAction: jest.fn().mockResolvedValue(expected) }); + const safe = new SafeAgentPort(port); + + const result = await safe.executeAction({ collection: 'customers', action: 'send-email' }); + + expect(result).toBe(expected); + }); + }); + + describe('wraps infra Error in AgentPortError', () => { + it('wraps getRecord infra error with correct operation name', async () => { + const port = makeMockPort({ + getRecord: jest.fn().mockRejectedValue(new Error('DB connection lost')), + }); + const safe = new SafeAgentPort(port); + + await expect(safe.getRecord({ collection: 'customers', id: [1] })).rejects.toThrow( + AgentPortError, + ); + }); + + it('includes cause message in AgentPortError.message for getRecord', async () => { + const port = makeMockPort({ + getRecord: jest.fn().mockRejectedValue(new Error('DB connection lost')), + }); + const safe = new SafeAgentPort(port); + + await expect(safe.getRecord({ collection: 'customers', id: [1] })).rejects.toThrow( + 'Agent port "getRecord" failed: DB connection lost', + ); + }); + + it('wraps updateRecord infra error with correct operation name', async () => { + const port = makeMockPort({ + updateRecord: jest.fn().mockRejectedValue(new Error('Timeout')), + }); + const safe = new SafeAgentPort(port); + + await expect( + safe.updateRecord({ collection: 'customers', id: [1], values: {} }), + ).rejects.toThrow('Agent port "updateRecord" failed: Timeout'); + }); + + it('wraps getRelatedData infra error with correct operation name', async () => { + const port = makeMockPort({ + getRelatedData: jest.fn().mockRejectedValue(new Error('Network error')), + }); + const safe = new SafeAgentPort(port); + + await expect( + safe.getRelatedData({ collection: 'customers', id: [1], relation: 'orders', limit: 10 }), + ).rejects.toThrow('Agent port "getRelatedData" failed: Network error'); + }); + + it('wraps executeAction infra error with correct operation name', async () => { + const port = makeMockPort({ + executeAction: jest.fn().mockRejectedValue(new Error('Action failed')), + }); + const safe = new SafeAgentPort(port); + + await expect( + safe.executeAction({ collection: 'customers', action: 'send-email' }), + ).rejects.toThrow('Agent port "executeAction" failed: Action failed'); + }); + + it('sets cause on AgentPortError', async () => { + const infraError = new Error('DB connection lost'); + const port = makeMockPort({ getRecord: jest.fn().mockRejectedValue(infraError) }); + const safe = new SafeAgentPort(port); + + let thrown: unknown; + + try { + await safe.getRecord({ collection: 'customers', id: [1] }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(AgentPortError); + expect((thrown as AgentPortError).cause).toBe(infraError); + }); + }); + + describe('does not re-wrap WorkflowExecutorError', () => { + it('rethrows WorkflowExecutorError as-is from getRecord', async () => { + const domainError = new StepStateError('invalid state'); + const port = makeMockPort({ getRecord: jest.fn().mockRejectedValue(domainError) }); + const safe = new SafeAgentPort(port); + + await expect(safe.getRecord({ collection: 'customers', id: [1] })).rejects.toBe(domainError); + }); + + it('rethrows WorkflowExecutorError subclass without wrapping in AgentPortError', async () => { + const domainError = new StepStateError('invalid state'); + const port = makeMockPort({ executeAction: jest.fn().mockRejectedValue(domainError) }); + const safe = new SafeAgentPort(port); + + let thrown: unknown; + + try { + await safe.executeAction({ collection: 'customers', action: 'send-email' }); + } catch (e) { + thrown = e; + } + + expect(thrown).toBeInstanceOf(WorkflowExecutorError); + expect(thrown).not.toBeInstanceOf(AgentPortError); + expect(thrown).toBe(domainError); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts new file mode 100644 index 000000000..dc4f65db2 --- /dev/null +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -0,0 +1,141 @@ +import type { StepExecutionData } from '../../src/types/step-execution-data'; + +import StepExecutionFormatters from '../../src/executors/summary/step-execution-formatters'; + +describe('StepExecutionFormatters', () => { + describe('format', () => { + describe('load-related-record', () => { + it('returns the full Loaded: line for a completed execution', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { + relation: { name: 'address', displayName: 'Address' }, + record: { collectionName: 'addresses', recordId: [1], stepIndex: 1 }, + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' Loaded: customers #42 → [Address] → addresses #1 (step 1)', + ); + }); + + it('returns null for a skipped execution', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { skipped: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('returns null when executionResult is absent (pending phase)', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + pendingData: { + displayName: 'Address', + name: 'address', + relatedCollectionName: 'addresses', + selectedRecordId: [1], + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('formats composite record IDs joined by ", "', () => { + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42, 'abc'], stepIndex: 0 }, + executionResult: { + relation: { name: 'orders', displayName: 'Orders' }, + record: { collectionName: 'orders', recordId: [1, 'xyz'], stepIndex: 1 }, + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' Loaded: customers #42, abc → [Orders] → orders #1, xyz (step 1)', + ); + }); + }); + + describe('mcp-task', () => { + it('returns the Result: line when formattedResponse is present', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + executionParams: { name: 'search_records', input: { query: 'foo' } }, + executionResult: { + success: true, + toolResult: { items: [] }, + formattedResponse: 'No records found.', + }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe(' Result: No records found.'); + }); + + it('returns a generic Executed: line when formattedResponse is absent', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + executionParams: { name: 'search_records', input: { query: 'foo' } }, + executionResult: { success: true, toolResult: { items: [] } }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' Executed: search_records (result not summarized)', + ); + }); + + it('returns null when executionResult is absent (pending phase)', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + pendingData: { name: 'search_records', input: {} }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('returns null for a skipped execution', () => { + const execution: StepExecutionData = { + type: 'mcp-task', + stepIndex: 2, + executionResult: { skipped: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + }); + + describe('types without a custom formatter', () => { + it('returns null for condition type', () => { + const execution: StepExecutionData = { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes' }, + executionResult: { answer: 'Yes' }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + + it('returns null for record-task type', () => { + const execution: StepExecutionData = { + type: 'record-task', + stepIndex: 0, + executionResult: { success: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBeNull(); + }); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/step-summary-builder.test.ts b/packages/workflow-executor/test/executors/step-summary-builder.test.ts new file mode 100644 index 000000000..a6a5c743f --- /dev/null +++ b/packages/workflow-executor/test/executors/step-summary-builder.test.ts @@ -0,0 +1,284 @@ +import type { StepDefinition } from '../../src/types/step-definition'; +import type { StepExecutionData } from '../../src/types/step-execution-data'; +import type { StepOutcome } from '../../src/types/step-outcome'; + +import StepSummaryBuilder from '../../src/executors/summary/step-summary-builder'; +import { StepType } from '../../src/types/step-definition'; + +function makeConditionStep(prompt?: string): StepDefinition { + return { type: StepType.Condition, options: ['A', 'B'], prompt }; +} + +function makeConditionOutcome( + stepId: string, + stepIndex: number, + extra: Record = {}, +): StepOutcome { + return { type: 'condition', stepId, stepIndex, status: 'success', ...extra } as StepOutcome; +} + +describe('StepSummaryBuilder', () => { + describe('build', () => { + it('renders header, prompt, Input, and Output for a condition step with execution data', () => { + const step = makeConditionStep('Approve?'); + const outcome = makeConditionOutcome('cond-1', 0); + const execution: StepExecutionData = { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Order is valid' }, + executionResult: { answer: 'Yes' }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Step "cond-1" (index 0):'); + expect(result).toContain('Prompt: Approve?'); + expect(result).toContain('Input: {"answer":"Yes","reasoning":"Order is valid"}'); + expect(result).toContain('Output: {"answer":"Yes"}'); + }); + + it('renders Output: when executionResult is present but executionParams is absent', () => { + const step: StepDefinition = { type: StepType.ReadRecord, prompt: 'Do something' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'task-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'record-task', + stepIndex: 0, + executionResult: { success: true }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Output: {"success":true}'); + expect(result).not.toContain('Input:'); + }); + + it('falls back to History when no execution data is provided', () => { + const step = makeConditionStep('Pick one'); + const outcome = makeConditionOutcome('cond-1', 0); + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('Step "cond-1" (index 0):'); + expect(result).toContain('Prompt: Pick one'); + expect(result).toContain('History: {"status":"success"}'); + expect(result).not.toContain('"stepId"'); + expect(result).not.toContain('"stepIndex"'); + expect(result).not.toContain('"type"'); + }); + + it('includes selectedOption in History for condition steps', () => { + const step = makeConditionStep('Approved?'); + const outcome = makeConditionOutcome('cond-approval', 0, { selectedOption: 'Yes' }); + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('"selectedOption":"Yes"'); + }); + + it('includes error in History for failed steps', () => { + const step = makeConditionStep('Do something'); + const outcome: StepOutcome = { + type: 'condition', + stepId: 'failing-step', + stepIndex: 0, + status: 'error', + error: 'AI could not match an option', + }; + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('"status":"error"'); + expect(result).toContain('"error":"AI could not match an option"'); + }); + + it('omits History type field and includes status for record-task steps', () => { + const step: StepDefinition = { type: StepType.ReadRecord, prompt: 'Run task' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'read-record-1', + stepIndex: 0, + status: 'awaiting-input', + }; + + const result = StepSummaryBuilder.build(step, outcome, undefined); + + expect(result).toContain('Step "read-record-1" (index 0):'); + expect(result).toContain('History: {"status":"awaiting-input"}'); + }); + + it('omits Input and Output lines when executionParams and executionResult are both absent', () => { + const step: StepDefinition = { type: StepType.ReadRecord, prompt: 'Do something' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'read-record-1', + stepIndex: 0, + status: 'success', + }; + const execution: StepExecutionData = { type: 'record-task', stepIndex: 0 }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Step "read-record-1" (index 0):'); + expect(result).toContain('Prompt: Do something'); + expect(result).not.toContain('Input:'); + expect(result).not.toContain('Output:'); + }); + + it('uses Pending when update-record step has pendingData but no executionParams', () => { + const step: StepDefinition = { type: StepType.UpdateRecord, prompt: 'Set status to active' }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Status"'); + expect(result).toContain('"value":"active"'); + expect(result).not.toContain('Input:'); + }); + + it('uses Pending for trigger-action step with pendingData', () => { + const step: StepDefinition = { + type: StepType.TriggerAction, + prompt: 'Archive the customer', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { displayName: 'Archive Customer', name: 'archive' }, + selectedRecordRef: { collectionName: 'customers', recordId: [1], stepIndex: 0 }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Archive Customer"'); + expect(result).toContain('"name":"archive"'); + expect(result).not.toContain('Input:'); + }); + + it('renders load-related-record completed as Loaded: (no Input: or Output:)', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'load-1', + stepIndex: 1, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { + relation: { name: 'address', displayName: 'Address' }, + record: { collectionName: 'addresses', recordId: [1], stepIndex: 1 }, + }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + const lines = result.split('\n'); + expect(lines).toHaveLength(3); + expect(lines[0]).toBe('Step "load-1" (index 1):'); + expect(lines[1]).toBe(' Prompt: Load the address'); + expect(lines[2]).toBe(' Loaded: customers #42 → [Address] → addresses #1 (step 1)'); + expect(result).not.toContain('Input:'); + expect(result).not.toContain('Output:'); + }); + + it('renders load-related-record skipped as generic Output: fallback', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'load-1', + stepIndex: 1, + status: 'success', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + executionResult: { skipped: true }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Output: {"skipped":true}'); + expect(result).not.toContain('Loaded:'); + }); + + it('renders load-related-record pending state with Pending: line', () => { + const step: StepDefinition = { + type: StepType.LoadRelatedRecord, + prompt: 'Load the address', + }; + const outcome: StepOutcome = { + type: 'record-task', + stepId: 'load-1', + stepIndex: 1, + status: 'awaiting-input', + }; + const execution: StepExecutionData = { + type: 'load-related-record', + stepIndex: 1, + selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, + pendingData: { + displayName: 'Address', + name: 'address', + relatedCollectionName: 'addresses', + selectedRecordId: [1], + }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Pending:'); + expect(result).toContain('"displayName":"Address"'); + expect(result).not.toContain('Input:'); + expect(result).not.toContain('Output:'); + expect(result).not.toContain('Loaded:'); + }); + + it('shows "(no prompt)" when step has no prompt', () => { + const step: StepDefinition = { type: StepType.Condition, options: ['A', 'B'] }; + const outcome = makeConditionOutcome('cond-1', 0); + const execution: StepExecutionData = { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'A', reasoning: 'Only option' }, + executionResult: { answer: 'A' }, + }; + + const result = StepSummaryBuilder.build(step, outcome, execution); + + expect(result).toContain('Prompt: (no prompt)'); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts new file mode 100644 index 000000000..7bb0a77df --- /dev/null +++ b/packages/workflow-executor/test/executors/trigger-record-action-step-executor.test.ts @@ -0,0 +1,920 @@ +import type { AgentPort } from '../../src/ports/agent-port'; +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { CollectionSchema, RecordRef } from '../../src/types/record'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; +import type { TriggerRecordActionStepExecutionData } from '../../src/types/step-execution-data'; + +import { StepStateError } from '../../src/errors'; +import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { + return { + type: StepType.TriggerAction, + prompt: 'Send a welcome email to the customer', + ...overrides, + }; +} + +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + +function makeMockAgentPort(): AgentPort { + return { + getRecord: jest.fn(), + updateRecord: jest.fn(), + getRelatedData: jest.fn(), + executeAction: jest.fn().mockResolvedValue(undefined), + } as unknown as AgentPort; +} + +function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { + return { + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { fieldName: 'status', displayName: 'Status', isRelationship: false }, + ], + actions: [ + { name: 'send-welcome-email', displayName: 'Send Welcome Email' }, + { name: 'archive', displayName: 'Archive Customer' }, + ], + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort( + schemasByCollection: Record = { + customers: makeCollectionSchema(), + }, +): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest + .fn() + .mockImplementation((name: string) => + Promise.resolve( + schemasByCollection[name] ?? makeCollectionSchema({ collectionName: name }), + ), + ), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolCallArgs?: Record, toolName = 'select-action') { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'trigger-1', + stepIndex: 0, + baseRecordRef: makeRecordRef(), + stepDefinition: makeStep(), + model: makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }).model, + agentPort: makeMockAgentPort(), + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + logger: { error: jest.fn() }, + ...overrides, + }; +} + +describe('TriggerRecordActionStepExecutor', () => { + describe('automaticExecution: trigger direct (Branch B)', () => { + it('triggers the action and returns success', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'send-welcome-email', + id: [42], + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + stepIndex: 0, + executionParams: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + executionResult: { success: true, actionResult: { message: 'Email sent' } }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('without automaticExecution: awaiting-input (Branch C)', () => { + it('saves pendingAction and returns awaiting-input', async () => { + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('triggers the action when user confirms and preserves pendingAction', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ message: 'Email sent' }); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'send-welcome-email', + id: [42], + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'trigger-action', + executionParams: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + executionResult: { success: true, actionResult: { message: 'Email sent' } }, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('skips the action when user rejects', async () => { + const agentPort = makeMockAgentPort(); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = false; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + }), + ); + }); + }); + + describe('no pending action in confirmation flow (Branch A)', () => { + it('returns error outcome when no pending action is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when execution exists but stepIndex does not match', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 5, + pendingData: { displayName: 'Send Welcome Email' }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when execution exists but pendingData is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'trigger-action', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('NoActionsError', () => { + it('returns error when collection has no actions', async () => { + const schema = makeCollectionSchema({ actions: [] }); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('No actions are available on this record.'); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('resolveActionName failure', () => { + it('returns error when AI returns an action name not found in the schema', async () => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + actionName: 'NonExistentAction', + reasoning: 'hallucinated', + }); + const schema = makeCollectionSchema({ + actions: [{ name: 'archive', displayName: 'Archive Customer' }], + }); + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI selected an action that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('agentPort.executeAction WorkflowExecutorError (Branch B)', () => { + it('returns error when executeAction throws WorkflowExecutorError', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new StepStateError('Action not permitted'), + ); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('agentPort.executeAction WorkflowExecutorError (Branch A)', () => { + it('returns error when executeAction throws WorkflowExecutorError during confirmation', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new StepStateError('Action not permitted'), + ); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('agentPort.executeAction infra error', () => { + it('returns error outcome for infrastructure errors (Branch B)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome for infrastructure errors (Branch A)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns user message and logs cause when agentPort.executeAction throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.executeAction as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'User requested welcome email', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + logger, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "executeAction" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); + }); + + describe('displayName → name resolution', () => { + it('calls executeAction with the technical name when AI returns a displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns displayName 'Archive Customer', technical name is 'archive' + const mockModel = makeMockModel({ + actionName: 'Archive Customer', + reasoning: 'User wants to archive', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'archive', + id: [42], + }); + }); + + it('resolves action when AI returns technical name instead of displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns technical name 'archive' instead of display name 'Archive Customer' + const mockModel = makeMockModel({ + actionName: 'archive', + reasoning: 'fallback to technical name', + }); + const schema = makeCollectionSchema({ + actions: [{ name: 'archive', displayName: 'Archive Customer' }], + }); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ + model: mockModel.model, + agentPort, + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith({ + collection: 'customers', + action: 'archive', + id: [42], + }); + }); + }); + + describe('multi-record AI selection', () => { + it('uses AI to select among multiple records then selects action', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + const relatedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + actions: [{ name: 'cancel-order', displayName: 'Cancel Order' }], + }); + + // First call: select-record, second call: select-action + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-action', + args: { actionName: 'Cancel Order', reasoning: 'Cancel the order' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const agentPort = makeMockAgentPort(); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort, agentPort }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(2); + + const selectTool = bindTools.mock.calls[0][0][0]; + expect(selectTool.name).toBe('select-record'); + + const actionTool = bindTools.mock.calls[1][0][0]; + expect(actionTool.name).toBe('select-action'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: { displayName: 'Cancel Order', name: 'cancel-order' }, + selectedRecordRef: expect.objectContaining({ + recordId: [99], + collectionName: 'orders', + }), + }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex in the outcome', async () => { + const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'trigger-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('schema caching', () => { + it('fetches getCollectionSchema once per collection even when called twice (Branch B)', async () => { + const workflowPort = makeMockWorkflowPort(); + const context = makeContext({ + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('AI malformed/missing tool call', () => { + it('returns error on malformed tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'select-action', args: '{bad json', error: 'JSON parse error' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns no tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ tool_calls: [] }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('trigger-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('RunStore error propagation', () => { + it('returns error outcome when getStepExecutions fails (Branch A)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const userConfirmed = false; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome when saveStepExecution fails saving awaiting-input (Branch C)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome after successful executeAction when saveStepExecution fails (Branch B)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + }); + + it('returns error outcome after successful executeAction when saveStepExecution fails (Branch A confirmed)', async () => { + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Send Welcome Email', + name: 'send-welcome-email', + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new TriggerRecordActionStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new TriggerRecordActionStepExecutor(context); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[mockModel.invoke.mock.calls.length - 1][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Trigger the relevant action.'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in select-action messages', async () => { + const mockModel = makeMockModel({ + actionName: 'Send Welcome Email', + reasoning: 'test', + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new TriggerRecordActionStepExecutor({ + ...context, + stepId: 'trigger-2', + stepIndex: 1, + }); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + // previous steps message + system prompt + collection info + human message = 4 + expect(messages).toHaveLength(4); + expect(messages[0].content).toContain('Should we proceed?'); + expect(messages[0].content).toContain('"answer":"Yes"'); + expect(messages[1].content).toContain('triggering an action'); + }); + }); +}); diff --git a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts new file mode 100644 index 000000000..00ed053d0 --- /dev/null +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -0,0 +1,875 @@ +import type { AgentPort } from '../../src/ports/agent-port'; +import type { RunStore } from '../../src/ports/run-store'; +import type { WorkflowPort } from '../../src/ports/workflow-port'; +import type { ExecutionContext } from '../../src/types/execution'; +import type { CollectionSchema, RecordRef } from '../../src/types/record'; +import type { RecordTaskStepDefinition } from '../../src/types/step-definition'; +import type { UpdateRecordStepExecutionData } from '../../src/types/step-execution-data'; + +import { StepStateError } from '../../src/errors'; +import UpdateRecordStepExecutor from '../../src/executors/update-record-step-executor'; +import { StepType } from '../../src/types/step-definition'; + +function makeStep(overrides: Partial = {}): RecordTaskStepDefinition { + return { + type: StepType.UpdateRecord, + prompt: 'Set the customer status to active', + ...overrides, + }; +} + +function makeRecordRef(overrides: Partial = {}): RecordRef { + return { + collectionName: 'customers', + recordId: [42], + stepIndex: 0, + ...overrides, + }; +} + +function makeMockAgentPort( + updatedValues: Record = { status: 'active', name: 'John Doe' }, +): AgentPort { + return { + getRecord: jest.fn().mockResolvedValue({ values: updatedValues }), + updateRecord: jest.fn().mockResolvedValue({ + collectionName: 'customers', + recordId: [42], + values: updatedValues, + }), + getRelatedData: jest.fn(), + executeAction: jest.fn(), + } as unknown as AgentPort; +} + +function makeCollectionSchema(overrides: Partial = {}): CollectionSchema { + return { + collectionName: 'customers', + collectionDisplayName: 'Customers', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'email', displayName: 'Email', isRelationship: false }, + { fieldName: 'status', displayName: 'Status', isRelationship: false }, + { fieldName: 'name', displayName: 'Full Name', isRelationship: false }, + { fieldName: 'orders', displayName: 'Orders', isRelationship: true }, + ], + actions: [], + ...overrides, + }; +} + +function makeMockRunStore(overrides: Partial = {}): RunStore { + return { + getStepExecutions: jest.fn().mockResolvedValue([]), + saveStepExecution: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +function makeMockWorkflowPort( + schemasByCollection: Record = { + customers: makeCollectionSchema(), + }, +): WorkflowPort { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn().mockResolvedValue(null), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest + .fn() + .mockImplementation((name: string) => + Promise.resolve( + schemasByCollection[name] ?? makeCollectionSchema({ collectionName: name }), + ), + ), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function makeMockModel(toolCallArgs?: Record, toolName = 'update-record-field') { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + return { model, bindTools, invoke }; +} + +function makeContext( + overrides: Partial> = {}, +): ExecutionContext { + return { + runId: 'run-1', + stepId: 'update-1', + stepIndex: 0, + baseRecordRef: makeRecordRef(), + stepDefinition: makeStep(), + model: makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'User requested status change', + }).model, + agentPort: makeMockAgentPort(), + workflowPort: makeMockWorkflowPort(), + runStore: makeMockRunStore(), + previousSteps: [], + logger: { error: jest.fn() }, + ...overrides, + }; +} + +describe('UpdateRecordStepExecutor', () => { + describe('automaticExecution: update direct (Branch B)', () => { + it('updates the record and returns success', async () => { + const updatedValues = { status: 'active', name: 'John Doe' }; + const agentPort = makeMockAgentPort(updatedValues); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'User requested status change', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + values: { status: 'active' }, + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'update-record', + stepIndex: 0, + executionParams: { displayName: 'Status', name: 'status', value: 'active' }, + executionResult: { updatedValues }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('without automaticExecution: awaiting-input (Branch C)', () => { + it('saves execution and returns awaiting-input', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'User requested status change', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: expect.objectContaining({ + collectionName: 'customers', + recordId: [42], + }), + }), + ); + }); + }); + + describe('confirmation accepted (Branch A)', () => { + it('updates the record when user confirms', async () => { + const updatedValues = { status: 'active', name: 'John Doe' }; + const agentPort = makeMockAgentPort(updatedValues); + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + values: { status: 'active' }, + }); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + type: 'update-record', + executionParams: { displayName: 'Status', name: 'status', value: 'active' }, + executionResult: { updatedValues }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + }), + ); + }); + }); + + describe('confirmation rejected (Branch A)', () => { + it('skips the update when user rejects', async () => { + const agentPort = makeMockAgentPort(); + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = false; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { skipped: true }, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + }), + ); + }); + }); + + describe('no pending update in phase 2 (Branch A)', () => { + it('returns error outcome when no pending update is found', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when execution exists but stepIndex does not match', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'update-record', + stepIndex: 5, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error outcome when execution exists but pendingData is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'update-record', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + await expect(executor.execute()).resolves.toMatchObject({ + stepOutcome: { + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred while processing this step.', + }, + }); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('multi-record AI selection', () => { + it('uses AI to select among multiple records then selects field', async () => { + const baseRecordRef = makeRecordRef({ stepIndex: 1 }); + const relatedRecord = makeRecordRef({ + stepIndex: 2, + recordId: [99], + collectionName: 'orders', + }); + + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { fieldName: 'total', displayName: 'Total', isRelationship: false }, + { fieldName: 'status', displayName: 'Order Status', isRelationship: false }, + ], + }); + + // First call: select-record, second call: update-record-field + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record', + args: { recordIdentifier: 'Step 2 - Orders #99' }, + id: 'call_1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'update-record-field', + args: { fieldName: 'Order Status', value: 'shipped', reasoning: 'Mark as shipped' }, + id: 'call_2', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: relatedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).toHaveBeenCalledTimes(2); + + const selectTool = bindTools.mock.calls[0][0][0]; + expect(selectTool.name).toBe('select-record'); + + const updateTool = bindTools.mock.calls[1][0][0]; + expect(updateTool.name).toBe('update-record-field'); + + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: { + displayName: 'Order Status', + name: 'status', + value: 'shipped', + }, + selectedRecordRef: expect.objectContaining({ + recordId: [99], + collectionName: 'orders', + }), + }), + ); + }); + }); + + describe('NoWritableFieldsError', () => { + it('returns error when all fields are relationships', async () => { + const schema = makeCollectionSchema({ + fields: [{ fieldName: 'orders', displayName: 'Orders', isRelationship: true }], + }); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const workflowPort = makeMockWorkflowPort({ customers: schema }); + const context = makeContext({ model: mockModel.model, runStore, workflowPort }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'This record type has no editable fields configured in Forest Admin.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('resolveFieldName failure', () => { + it('returns error when field is not found during automaticExecution (Branch B)', async () => { + // AI returns a display name that doesn't match any field in the schema + const mockModel = makeMockModel({ + fieldName: 'NonExistentField', + value: 'test', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI selected a field that doesn't exist on this record. Try rephrasing the step's prompt.", + ); + }); + }); + + describe('relationship fields excluded from update tool', () => { + it('excludes relationship fields from the tool schema', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ model: mockModel.model }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + // Second bindTools call is for update-record-field (first may be select-record) + const lastCall = mockModel.bindTools.mock.calls[mockModel.bindTools.mock.calls.length - 1]; + const tool = lastCall[0][0]; + expect(tool.name).toBe('update-record-field'); + + // Non-relationship display names should be accepted + expect(tool.schema.parse({ fieldName: 'Email', value: 'x', reasoning: 'r' })).toBeTruthy(); + expect(tool.schema.parse({ fieldName: 'Status', value: 'x', reasoning: 'r' })).toBeTruthy(); + expect( + tool.schema.parse({ fieldName: 'Full Name', value: 'x', reasoning: 'r' }), + ).toBeTruthy(); + + // Relationship display name should be rejected + expect(() => + tool.schema.parse({ fieldName: 'Orders', value: 'x', reasoning: 'r' }), + ).toThrow(); + }); + }); + + describe('AI malformed/missing tool call', () => { + it('returns error on malformed tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ + tool_calls: [], + invalid_tool_calls: [ + { name: 'update-record-field', args: '{bad json', error: 'JSON parse error' }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI returned an unexpected response. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('returns error when AI returns no tool call', async () => { + const invoke = jest.fn().mockResolvedValue({ tool_calls: [] }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: { bindTools } as unknown as ExecutionContext['model'], + runStore, + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + "The AI couldn't decide what to do. Try rephrasing the step's prompt.", + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + + describe('agentPort.updateRecord WorkflowExecutorError (Branch B)', () => { + it('returns error when updateRecord throws WorkflowExecutorError', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new StepStateError('Record locked')); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + }); + }); + + describe('agentPort.updateRecord WorkflowExecutorError (Branch A)', () => { + it('returns error when updateRecord throws WorkflowExecutorError during confirmation', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new StepStateError('Record locked')); + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.type).toBe('record-task'); + expect(result.stepOutcome.stepId).toBe('update-1'); + expect(result.stepOutcome.stepIndex).toBe(0); + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + }); + }); + + describe('agentPort.updateRecord infra error', () => { + it('returns error outcome for infrastructure errors (Branch B)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome for infrastructure errors (Branch A)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('Connection refused')); + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const userConfirmed = true; + const context = makeContext({ agentPort, runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns user message and logs cause when agentPort.updateRecord throws an infra error', async () => { + const logger = { error: jest.fn() }; + const agentPort = makeMockAgentPort(); + (agentPort.updateRecord as jest.Mock).mockRejectedValue(new Error('DB connection lost')); + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + logger, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An error occurred while accessing your data. Please try again.', + ); + expect(logger.error).toHaveBeenCalledWith( + 'Agent port "updateRecord" failed: DB connection lost', + expect.objectContaining({ cause: 'DB connection lost' }), + ); + }); + }); + + describe('stepOutcome shape', () => { + it('emits correct type, stepId and stepIndex in the outcome', async () => { + const context = makeContext({ stepDefinition: makeStep({ automaticExecution: true }) }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome).toMatchObject({ + type: 'record-task', + stepId: 'update-1', + stepIndex: 0, + status: 'success', + }); + }); + }); + + describe('findField fieldName fallback', () => { + it('resolves update when AI returns raw fieldName instead of displayName', async () => { + const agentPort = makeMockAgentPort(); + // AI returns 'status' (fieldName) instead of 'Status' (displayName) + const mockModel = makeMockModel({ fieldName: 'status', value: 'active', reasoning: 'test' }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith({ + collection: 'customers', + id: [42], + values: { status: 'active' }, + }); + }); + }); + + describe('schema caching', () => { + it('fetches getCollectionSchema once per collection even when called twice (Branch B)', async () => { + const workflowPort = makeMockWorkflowPort(); + const context = makeContext({ + workflowPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + // resolveFieldName is called in handleFirstCall, so getCollectionSchema is only fetched once + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + }); + }); + + describe('RunStore error propagation', () => { + it('returns error outcome when getStepExecutions fails (Branch A)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockRejectedValue(new Error('DB timeout')), + }); + const userConfirmed = true; + const context = makeContext({ runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: 'Status', name: 'status', value: 'active' }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const userConfirmed = false; + const context = makeContext({ runStore, userConfirmed }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome when saveStepExecution fails saving awaiting-input (Branch C)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ runStore }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + expect(result.stepOutcome.status).toBe('error'); + }); + + it('returns error outcome after successful updateRecord when saveStepExecution fails (Branch B)', async () => { + const runStore = makeMockRunStore({ + saveStepExecution: jest.fn().mockRejectedValue(new Error('Disk full')), + }); + const context = makeContext({ + runStore, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The step result could not be saved. Please retry.'); + }); + }); + + describe('default prompt', () => { + it('uses default prompt when step.prompt is undefined', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ prompt: undefined }), + }); + const executor = new UpdateRecordStepExecutor(context); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + const humanMessage = messages[messages.length - 1]; + expect(humanMessage.content).toBe('**Request**: Update the relevant field.'); + }); + }); + + describe('previous steps context', () => { + it('includes previous steps summary in update-field messages', async () => { + const mockModel = makeMockModel({ + fieldName: 'Status', + value: 'active', + reasoning: 'test', + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'condition', + stepIndex: 0, + executionParams: { answer: 'Yes', reasoning: 'Approved' }, + }, + ]), + }); + const context = makeContext({ + model: mockModel.model, + runStore, + previousSteps: [ + { + stepDefinition: { + type: StepType.Condition, + options: ['Yes', 'No'], + prompt: 'Should we proceed?', + }, + stepOutcome: { + type: 'condition', + stepId: 'prev-step', + stepIndex: 0, + status: 'success', + }, + }, + ], + }); + const executor = new UpdateRecordStepExecutor({ + ...context, + stepId: 'update-2', + stepIndex: 1, + }); + + await executor.execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + // previous steps summary + system prompt + collection info + human message = 4 + expect(messages).toHaveLength(4); + expect(messages[0].content).toContain('Should we proceed?'); + expect(messages[0].content).toContain('"answer":"Yes"'); + expect(messages[1].content).toContain('updating a field on a record'); + }); + }); +}); diff --git a/packages/workflow-executor/test/http/executor-http-server.test.ts b/packages/workflow-executor/test/http/executor-http-server.test.ts index f4d415b4c..e660b926a 100644 --- a/packages/workflow-executor/test/http/executor-http-server.test.ts +++ b/packages/workflow-executor/test/http/executor-http-server.test.ts @@ -3,6 +3,7 @@ import type Runner from '../../src/runner'; import request from 'supertest'; +import { RunNotFoundError } from '../../src/errors'; import ExecutorHttpServer from '../../src/http/executor-http-server'; function createMockRunStore(overrides: Partial = {}): RunStore { @@ -58,7 +59,7 @@ describe('ExecutorHttpServer', () => { const response = await request(server.callback).get('/runs/run-1'); expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'db error' }); + expect(response.body).toEqual({ error: 'Internal server error' }); }); }); @@ -79,9 +80,26 @@ describe('ExecutorHttpServer', () => { expect(runner.triggerPoll).toHaveBeenCalledWith('run-1'); }); - it('should propagate errors from runner', async () => { + it('returns 404 when triggerPoll rejects with RunNotFoundError', async () => { const runner = createMockRunner({ - triggerPoll: jest.fn().mockRejectedValue(new Error('poll failed')), + triggerPoll: jest.fn().mockRejectedValue(new RunNotFoundError('run-1')), + }); + + const server = new ExecutorHttpServer({ + port: 0, + runStore: createMockRunStore(), + runner, + }); + + const response = await request(server.callback).post('/runs/run-1/trigger'); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ error: 'Run not found or unavailable' }); + }); + + it('returns 500 when triggerPoll rejects with an unexpected error', async () => { + const runner = createMockRunner({ + triggerPoll: jest.fn().mockRejectedValue(new Error('unexpected')), }); const server = new ExecutorHttpServer({ @@ -93,7 +111,7 @@ describe('ExecutorHttpServer', () => { const response = await request(server.callback).post('/runs/run-1/trigger'); expect(response.status).toBe(500); - expect(response.body).toEqual({ error: 'poll failed' }); + expect(response.body).toEqual({ error: 'Internal server error' }); }); }); diff --git a/packages/workflow-executor/test/index.test.ts b/packages/workflow-executor/test/index.test.ts index 05affa035..1267b1cbb 100644 --- a/packages/workflow-executor/test/index.test.ts +++ b/packages/workflow-executor/test/index.test.ts @@ -1,9 +1,9 @@ import { StepType } from '../src/index'; describe('StepType', () => { - it('should expose exactly 5 step types', () => { + it('should expose exactly 6 step types', () => { const values = Object.values(StepType); - expect(values).toHaveLength(5); + expect(values).toHaveLength(6); }); it.each([ @@ -12,6 +12,7 @@ describe('StepType', () => { ['UpdateRecord', 'update-record'], ['TriggerAction', 'trigger-action'], ['LoadRelatedRecord', 'load-related-record'], + ['McpTask', 'mcp-task'], ] as const)('should have %s = "%s"', (key, value) => { expect(StepType[key]).toBe(value); }); diff --git a/packages/workflow-executor/test/runner.test.ts b/packages/workflow-executor/test/runner.test.ts index 0ea16bd27..52ba1c0c3 100644 --- a/packages/workflow-executor/test/runner.test.ts +++ b/packages/workflow-executor/test/runner.test.ts @@ -1,96 +1,794 @@ +import type { StepContextConfig } from '../src/executors/step-executor-factory'; import type { AgentPort } from '../src/ports/agent-port'; +import type { Logger } from '../src/ports/logger-port'; import type { RunStore } from '../src/ports/run-store'; import type { WorkflowPort } from '../src/ports/workflow-port'; +import type { PendingStepExecution } from '../src/types/execution'; +import type { StepDefinition } from '../src/types/step-definition'; +import type { AiClient, BaseChatModel } from '@forestadmin/ai-proxy'; +import { RunNotFoundError } from '../src/errors'; +import BaseStepExecutor from '../src/executors/base-step-executor'; +import ConditionStepExecutor from '../src/executors/condition-step-executor'; +import LoadRelatedRecordStepExecutor from '../src/executors/load-related-record-step-executor'; +import McpTaskStepExecutor from '../src/executors/mcp-task-step-executor'; +import ReadRecordStepExecutor from '../src/executors/read-record-step-executor'; +import StepExecutorFactory from '../src/executors/step-executor-factory'; +import TriggerRecordActionStepExecutor from '../src/executors/trigger-record-action-step-executor'; +import UpdateRecordStepExecutor from '../src/executors/update-record-step-executor'; import ExecutorHttpServer from '../src/http/executor-http-server'; import Runner from '../src/runner'; +import { StepType } from '../src/types/step-definition'; jest.mock('../src/http/executor-http-server'); const MockedExecutorHttpServer = ExecutorHttpServer as jest.MockedClass; -function createRunnerConfig(overrides: { httpPort?: number } = {}) { +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const POLLING_INTERVAL_MS = 1000; + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +function createMockWorkflowPort(): jest.Mocked { + return { + getPendingStepExecutions: jest.fn().mockResolvedValue([]), + getPendingStepExecutionsForRun: jest.fn(), + updateStepExecution: jest.fn().mockResolvedValue(undefined), + getCollectionSchema: jest.fn(), + getMcpServerConfigs: jest.fn().mockResolvedValue([]), + }; +} + +function createMockAiClient() { + return { + getModel: jest.fn().mockReturnValue({} as BaseChatModel), + loadRemoteTools: jest.fn().mockResolvedValue([]), + closeConnections: jest.fn().mockResolvedValue(undefined), + }; +} + +function createMockLogger(): jest.Mocked { + return { error: jest.fn() }; +} + +function createRunnerConfig( + overrides: Partial<{ + workflowPort: WorkflowPort; + aiClient: AiClient; + logger: Logger; + httpPort: number; + }> = {}, +) { return { agentPort: {} as AgentPort, - workflowPort: {} as WorkflowPort, + workflowPort: createMockWorkflowPort(), runStore: {} as RunStore, - pollingIntervalMs: 2000, + pollingIntervalMs: POLLING_INTERVAL_MS, + aiClient: createMockAiClient() as unknown as AiClient, + logger: createMockLogger(), ...overrides, }; } -describe('Runner', () => { - beforeEach(() => { - jest.clearAllMocks(); - MockedExecutorHttpServer.prototype.start = jest.fn().mockResolvedValue(undefined); - MockedExecutorHttpServer.prototype.stop = jest.fn().mockResolvedValue(undefined); +function makeStepDefinition(stepType: StepType): StepDefinition { + if (stepType === StepType.Condition) { + return { type: StepType.Condition, options: ['opt1', 'opt2'] }; + } + + if (stepType === StepType.McpTask) { + return { type: StepType.McpTask }; + } + + return { type: stepType as Exclude }; +} + +function makePendingStep( + overrides: Partial & { stepType?: StepType } = {}, +): PendingStepExecution { + const { stepType = StepType.ReadRecord, ...rest } = overrides; + + return { + runId: 'run-1', + stepId: 'step-1', + stepIndex: 0, + baseRecordRef: { collectionName: 'customers', recordId: ['1'], stepIndex: 0 }, + stepDefinition: makeStepDefinition(stepType), + previousSteps: [], + ...rest, + }; +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- + +let executeSpy: jest.SpyInstance; +let runner: Runner; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + + MockedExecutorHttpServer.prototype.start = jest.fn().mockResolvedValue(undefined); + MockedExecutorHttpServer.prototype.stop = jest.fn().mockResolvedValue(undefined); + + executeSpy = jest.spyOn(BaseStepExecutor.prototype, 'execute').mockResolvedValue({ + stepOutcome: { type: 'record-task', stepId: 'step-1', stepIndex: 0, status: 'success' }, }); +}); + +afterEach(async () => { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (runner) { + await runner.stop(); + (runner as Runner | undefined) = undefined; + } + + jest.clearAllTimers(); +}); + +// --------------------------------------------------------------------------- +// HTTP server (existing tests, kept passing) +// --------------------------------------------------------------------------- - describe('start', () => { - it('should start the HTTP server when httpPort is configured', async () => { - const config = createRunnerConfig({ httpPort: 3100 }); - const runner = new Runner(config); +describe('start', () => { + it('should start the HTTP server when httpPort is configured', async () => { + const config = createRunnerConfig({ httpPort: 3100 }); + runner = new Runner(config); - await runner.start(); + await runner.start(); - expect(MockedExecutorHttpServer).toHaveBeenCalledWith({ - port: 3100, - runStore: config.runStore, - runner, - }); - expect(MockedExecutorHttpServer.prototype.start).toHaveBeenCalled(); + expect(MockedExecutorHttpServer).toHaveBeenCalledWith({ + port: 3100, + runStore: config.runStore, + runner, }); + expect(MockedExecutorHttpServer.prototype.start).toHaveBeenCalled(); + }); + + it('should not start the HTTP server when httpPort is not configured', async () => { + runner = new Runner(createRunnerConfig()); + + await runner.start(); + + expect(MockedExecutorHttpServer).not.toHaveBeenCalled(); + }); + + it('should not create a second HTTP server if already started', async () => { + runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + + await runner.start(); + await runner.start(); + + expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(1); + }); +}); + +describe('stop', () => { + it('should stop the HTTP server when running', async () => { + runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + + await runner.start(); + await runner.stop(); + + expect(MockedExecutorHttpServer.prototype.stop).toHaveBeenCalled(); + }); + + it('should handle stop when no HTTP server is running', async () => { + runner = new Runner(createRunnerConfig()); + + await expect(runner.stop()).resolves.toBeUndefined(); + }); + + it('should allow restarting after stop', async () => { + runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + + await runner.start(); + await runner.stop(); + await runner.start(); + + expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(2); + }); +}); + +// --------------------------------------------------------------------------- +// Polling loop +// --------------------------------------------------------------------------- + +describe('polling loop', () => { + it('schedules a poll after pollingIntervalMs', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + }); + + it('reschedules automatically after each cycle', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(2); + }); + + it('stop() prevents scheduling a new cycle', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + + await runner.stop(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS * 3); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + }); + + it('stop() clears the pending timer', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.start(); + + await runner.stop(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + }); + + it('calling start() twice does not schedule two timers', async () => { + const workflowPort = createMockWorkflowPort(); + runner = new Runner(createRunnerConfig({ workflowPort })); + + await runner.start(); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// Deduplication +// --------------------------------------------------------------------------- + +describe('deduplication', () => { + it('skips a step whose key is already in inFlightSteps', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'inflight-step' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + + // Block the first execution so the key stays in-flight + const unblockRef = { fn: (): void => {} }; + executeSpy.mockReturnValueOnce( + new Promise(resolve => { + unblockRef.fn = () => + resolve({ + stepOutcome: { + type: 'record-task', + stepId: 'inflight-step', + stepIndex: 0, + status: 'success', + }, + }); + }), + ); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + const poll1 = runner.triggerPoll('run-1'); + await Promise.resolve(); // let getPendingStepExecutionsForRun resolve and step key get added + + // Second poll: step is in-flight → should be skipped + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + + unblockRef.fn(); + await poll1; + }); + + it('removes the step key after successful execution', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-dedup' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); - it('should not start the HTTP server when httpPort is not configured', async () => { - const runner = new Runner(createRunnerConfig()); + runner = new Runner(createRunnerConfig({ workflowPort })); - await runner.start(); + await runner.triggerPoll('run-1'); + await runner.triggerPoll('run-1'); - expect(MockedExecutorHttpServer).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledTimes(2); + }); + + it('removes the step key even when executor construction fails', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-throws' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('construction error'); }); - it('should not create a second HTTP server if already started', async () => { - const runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + + await runner.triggerPoll('run-1'); + await runner.triggerPoll('run-1'); + + // Both polls completed: the step key was cleared after the first (failed) execution + expect(workflowPort.updateStepExecution).toHaveBeenCalledTimes(2); + // First poll produced an error outcome from the construction failure + expect(workflowPort.updateStepExecution).toHaveBeenNthCalledWith( + 1, + 'run-1', + expect.objectContaining({ status: 'error', error: 'An unexpected error occurred.' }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// triggerPoll +// --------------------------------------------------------------------------- + +describe('triggerPoll', () => { + it('calls getPendingStepExecutionsForRun with the given runId and executes the step', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-A', stepId: 'step-a' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + + runner = new Runner(createRunnerConfig({ workflowPort })); + await runner.triggerPoll('run-A'); + + expect(workflowPort.getPendingStepExecutionsForRun).toHaveBeenCalledWith('run-A'); + expect(workflowPort.getPendingStepExecutions).not.toHaveBeenCalled(); + expect(executeSpy).toHaveBeenCalledTimes(1); + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-A', expect.anything()); + }); + + it('skips in-flight steps', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-inflight' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + + const unblockRef = { fn: (): void => {} }; + executeSpy.mockReturnValueOnce( + new Promise(resolve => { + unblockRef.fn = () => + resolve({ + stepOutcome: { + type: 'record-task', + stepId: 'step-inflight', + stepIndex: 0, + status: 'success', + }, + }); + }), + ); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + const poll1 = runner.triggerPoll('run-1'); + await Promise.resolve(); + + await runner.triggerPoll('run-1'); + + expect(executeSpy).toHaveBeenCalledTimes(1); + + unblockRef.fn(); + await poll1; + }); + + it('resolves after the step has settled', async () => { + const workflowPort = createMockWorkflowPort(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-a' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); + expect(executeSpy).toHaveBeenCalledTimes(1); + }); + + it('rejects with RunNotFoundError when getPendingStepExecutionsForRun returns null', async () => { + const workflowPort = createMockWorkflowPort(); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(null); - await runner.start(); - await runner.start(); + runner = new Runner(createRunnerConfig({ workflowPort })); - expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(1); + await expect(runner.triggerPoll('run-1')).rejects.toThrow(RunNotFoundError); + }); + + it('propagates errors from getPendingStepExecutionsForRun as-is', async () => { + const workflowPort = createMockWorkflowPort(); + workflowPort.getPendingStepExecutionsForRun.mockRejectedValue(new Error('Network error')); + + runner = new Runner(createRunnerConfig({ workflowPort })); + + await expect(runner.triggerPoll('run-1')).rejects.toThrow('Network error'); + }); +}); + +// --------------------------------------------------------------------------- +// MCP lazy loading +// --------------------------------------------------------------------------- + +describe('MCP lazy loading (via once thunk)', () => { + it('does not call fetchRemoteTools when there are no McpTask steps', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepType: StepType.ReadRecord }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.getMcpServerConfigs).not.toHaveBeenCalled(); + expect(aiClient.loadRemoteTools).not.toHaveBeenCalled(); + }); + + it('calls fetchRemoteTools once for an McpTask step', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-1', + stepType: StepType.McpTask, }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + // Provide a non-empty config so fetchRemoteTools actually calls loadRemoteTools + workflowPort.getMcpServerConfigs.mockResolvedValue([{ configs: {} }] as never); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.getMcpServerConfigs).toHaveBeenCalledTimes(1); + expect(aiClient.loadRemoteTools).toHaveBeenCalledTimes(1); + }); +}); + +// --------------------------------------------------------------------------- +// getExecutor — factory +// --------------------------------------------------------------------------- + +describe('StepExecutorFactory.create — factory', () => { + const makeContextConfig = (): StepContextConfig => ({ + aiClient: { + getModel: jest.fn().mockReturnValue({} as BaseChatModel), + } as unknown as AiClient, + agentPort: {} as AgentPort, + workflowPort: {} as WorkflowPort, + runStore: {} as RunStore, + logger: { error: jest.fn() }, }); - describe('stop', () => { - it('should stop the HTTP server when running', async () => { - const runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + it('dispatches Condition steps to ConditionStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.Condition }); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + expect(executor).toBeInstanceOf(ConditionStepExecutor); + }); - await runner.start(); - await runner.stop(); + it('dispatches ReadRecord steps to ReadRecordStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.ReadRecord }); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + expect(executor).toBeInstanceOf(ReadRecordStepExecutor); + }); - expect(MockedExecutorHttpServer.prototype.stop).toHaveBeenCalled(); + it('dispatches UpdateRecord steps to UpdateRecordStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.UpdateRecord }); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + expect(executor).toBeInstanceOf(UpdateRecordStepExecutor); + }); + + it('dispatches TriggerAction steps to TriggerRecordActionStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.TriggerAction }); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + expect(executor).toBeInstanceOf(TriggerRecordActionStepExecutor); + }); + + it('dispatches LoadRelatedRecord steps to LoadRelatedRecordStepExecutor', async () => { + const step = makePendingStep({ stepType: StepType.LoadRelatedRecord }); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + expect(executor).toBeInstanceOf(LoadRelatedRecordStepExecutor); + }); + + it('dispatches McpTask steps to McpTaskStepExecutor and calls loadTools', async () => { + const step = makePendingStep({ stepType: StepType.McpTask }); + const loadTools = jest.fn().mockResolvedValue([]); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); + expect(executor).toBeInstanceOf(McpTaskStepExecutor); + expect(loadTools).toHaveBeenCalledTimes(1); + }); + + it('returns an executor with an error outcome for an unknown step type', async () => { + const step = { + ...makePendingStep(), + stepDefinition: { type: 'unknown-type' as StepType }, + } as unknown as PendingStepExecution; + const executor = await StepExecutorFactory.create(step, makeContextConfig(), jest.fn()); + const { stepOutcome } = await executor.execute(); + expect(stepOutcome.status).toBe('error'); + expect(stepOutcome.error).toBe('An unexpected error occurred.'); + }); + + it('returns an executor with an error outcome when loadTools rejects for a McpTask step', async () => { + const step = makePendingStep({ stepType: StepType.McpTask }); + const loadTools = jest.fn().mockRejectedValueOnce(new Error('MCP server down')); + const executor = await StepExecutorFactory.create(step, makeContextConfig(), loadTools); + const { stepOutcome } = await executor.execute(); + expect(stepOutcome.status).toBe('error'); + expect(stepOutcome.type).toBe('mcp-task'); + expect(stepOutcome.error).toBe('An unexpected error occurred.'); + }); + + it('logs cause message when construction error has an Error cause', async () => { + const rootCause = new Error('root cause'); + const error = new Error('wrapper'); + (error as Error & { cause: Error }).cause = rootCause; + const logger = { error: jest.fn() }; + const contextConfig: StepContextConfig = { + ...makeContextConfig(), + aiClient: { + getModel: jest.fn().mockImplementationOnce(() => { + throw error; + }), + } as unknown as AiClient, + logger, + }; + + await StepExecutorFactory.create(makePendingStep(), contextConfig, jest.fn()); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ cause: 'root cause' }), + ); + }); + + it('logs cause as undefined when construction error cause is not an Error instance', async () => { + const error = new Error('wrapper'); + (error as Error & { cause: string }).cause = 'plain string'; + const logger = { error: jest.fn() }; + const contextConfig: StepContextConfig = { + ...makeContextConfig(), + aiClient: { + getModel: jest.fn().mockImplementationOnce(() => { + throw error; + }), + } as unknown as AiClient, + logger, + }; + + await StepExecutorFactory.create(makePendingStep(), contextConfig, jest.fn()); + + expect(logger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ cause: undefined }), + ); + }); +}); + +// --------------------------------------------------------------------------- +// Error handling +// --------------------------------------------------------------------------- + +describe('error handling', () => { + it('reports a fallback error outcome when buildContext throws (getModel throws)', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-err' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('AI not configured'); }); - it('should handle stop when no HTTP server is running', async () => { - const runner = new Runner(createRunnerConfig()); + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiClient: aiClient as unknown as AiClient, + logger: mockLogger, + }), + ); + await runner.triggerPoll('run-1'); - await expect(runner.stop()).resolves.toBeUndefined(); + expect(mockLogger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-err', + stepIndex: 0, + error: 'AI not configured', + }), + ); + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith('run-1', { + type: 'record-task', + stepId: 'step-err', + stepIndex: 0, + status: 'error', + error: 'An unexpected error occurred.', }); + }); - it('should allow restarting after stop', async () => { - const runner = new Runner(createRunnerConfig({ httpPort: 3100 })); + it('reports type mcp-task in fallback error outcome for McpTask steps', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ + runId: 'run-1', + stepId: 'step-mcp-err', + stepType: StepType.McpTask, + }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('AI not configured'); + }); - await runner.start(); - await runner.stop(); - await runner.start(); + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); - expect(MockedExecutorHttpServer).toHaveBeenCalledTimes(2); + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ type: 'mcp-task', status: 'error' }), + ); + }); + + it('logs unexpected errors with runId, stepId, and stack when getModel throws', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const aiClient = createMockAiClient(); + const error = new Error('something blew up'); + const step = makePendingStep({ runId: 'run-2', stepId: 'step-log' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + aiClient.getModel.mockImplementationOnce(() => { + throw error; + }); + + runner = new Runner( + createRunnerConfig({ + workflowPort, + aiClient: aiClient as unknown as AiClient, + logger: mockLogger, + }), + ); + await runner.triggerPoll('run-2'); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Step execution failed unexpectedly', + expect.objectContaining({ + runId: 'run-2', + stepId: 'step-log', + stepIndex: 0, + error: 'something blew up', + stack: expect.any(String), + }), + ); + }); + + it('does not re-throw if updateStepExecution fails after a construction error', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-fallback' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + aiClient.getModel.mockImplementationOnce(() => { + throw new Error('construction error'); }); + workflowPort.updateStepExecution.mockRejectedValueOnce(new Error('update failed')); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + + await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); }); - describe('triggerPoll', () => { - it('should resolve without error', async () => { - const runner = new Runner(createRunnerConfig()); + it('logs FATAL and does not call updateStepExecution if executor.execute() rejects', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-fatal' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); - await expect(runner.triggerPoll('run-1')).resolves.toBeUndefined(); + // Simulate a broken executor that violates the never-throw contract + jest.spyOn(StepExecutorFactory, 'create').mockResolvedValueOnce({ + execute: jest.fn().mockRejectedValueOnce(new Error('contract violated')), }); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.triggerPoll('run-1'); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'FATAL: executor contract violated — step outcome not reported', + expect.objectContaining({ + runId: 'run-1', + stepId: 'step-fatal', + error: 'contract violated', + }), + ); + expect(workflowPort.updateStepExecution).not.toHaveBeenCalled(); + }); + + it('reports an outcome when getModel throws a non-Error throwable', async () => { + const workflowPort = createMockWorkflowPort(); + const aiClient = createMockAiClient(); + const step = makePendingStep({ runId: 'run-1', stepId: 'step-string-throw' }); + workflowPort.getPendingStepExecutionsForRun.mockResolvedValue(step); + aiClient.getModel.mockImplementationOnce(() => { + // eslint-disable-next-line @typescript-eslint/no-throw-literal + throw 'plain string error'; + }); + + runner = new Runner( + createRunnerConfig({ workflowPort, aiClient: aiClient as unknown as AiClient }), + ); + await runner.triggerPoll('run-1'); + + expect(workflowPort.updateStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ status: 'error', error: 'An unexpected error occurred.' }), + ); + }); + + it('catches getPendingStepExecutions failure, logs it, and reschedules', async () => { + const workflowPort = createMockWorkflowPort(); + const mockLogger = createMockLogger(); + workflowPort.getPendingStepExecutions + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValue([]); + + runner = new Runner(createRunnerConfig({ workflowPort, logger: mockLogger })); + await runner.start(); + + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(mockLogger.error).toHaveBeenCalledWith( + 'Poll cycle failed', + expect.objectContaining({ error: 'network error' }), + ); + + // After the error, the cycle should have been rescheduled + jest.advanceTimersByTime(POLLING_INTERVAL_MS); + await flushPromises(); + + expect(workflowPort.getPendingStepExecutions).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/workflow-executor/test/types/step-outcome.test.ts b/packages/workflow-executor/test/types/step-outcome.test.ts new file mode 100644 index 000000000..e044e79f8 --- /dev/null +++ b/packages/workflow-executor/test/types/step-outcome.test.ts @@ -0,0 +1,32 @@ +import { StepType } from '../../src/types/step-definition'; +import { stepTypeToOutcomeType } from '../../src/types/step-outcome'; + +describe('stepTypeToOutcomeType', () => { + it('maps Condition to condition', () => { + expect(stepTypeToOutcomeType(StepType.Condition)).toBe('condition'); + }); + + it('maps McpTask to mcp-task', () => { + expect(stepTypeToOutcomeType(StepType.McpTask)).toBe('mcp-task'); + }); + + it('maps ReadRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.ReadRecord)).toBe('record-task'); + }); + + it('maps UpdateRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.UpdateRecord)).toBe('record-task'); + }); + + it('maps TriggerAction to record-task', () => { + expect(stepTypeToOutcomeType(StepType.TriggerAction)).toBe('record-task'); + }); + + it('maps LoadRelatedRecord to record-task', () => { + expect(stepTypeToOutcomeType(StepType.LoadRelatedRecord)).toBe('record-task'); + }); + + it('falls through to record-task for an unknown future step type', () => { + expect(stepTypeToOutcomeType('future-step-type' as StepType)).toBe('record-task'); + }); +}); diff --git a/yarn.lock b/yarn.lock index c7c7add7e..371ee67d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2314,23 +2314,6 @@ uuid "^10.0.0" zod "^3.25.76 || ^4" -"@langchain/core@1.1.33": - version "1.1.33" - resolved "https://registry.yarnpkg.com/@langchain/core/-/core-1.1.33.tgz#414536e9d0a6f90576502e532336104360ed4392" - integrity sha512-At1ooBmPlHMkhTkG6NqeOVjNscuJwneBB8F88rFRvBvIfhTACVLzEwMiZFWNTM8DzUXUOcxxqS7xKRyr6JBbOQ== - dependencies: - "@cfworker/json-schema" "^4.0.2" - "@standard-schema/spec" "^1.1.0" - ansi-styles "^5.0.0" - camelcase "6" - decamelize "1.2.0" - js-tiktoken "^1.0.12" - langsmith ">=0.5.0 <1.0.0" - mustache "^4.2.0" - p-queue "^6.6.2" - uuid "^11.1.0" - zod "^3.25.76 || ^4" - "@langchain/langgraph-checkpoint@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz#ece2ede439d0d0b0b532c4be7817fd5029afe4f8" @@ -4186,11 +4169,6 @@ dependencies: tslib "^2.6.2" -"@standard-schema/spec@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@standard-schema/spec/-/spec-1.1.0.tgz#a79b55dbaf8604812f52d140b2c9ab41bc150bb8" - integrity sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w== - "@tokenizer/token@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@tokenizer/token/-/token-0.3.0.tgz#fe98a93fe789247e998c75e74e9c7c63217aa276" @@ -11319,18 +11297,6 @@ koa@^3.0.1: semver "^7.6.3" uuid "^10.0.0" -"langsmith@>=0.5.0 <1.0.0": - version "0.5.10" - resolved "https://registry.yarnpkg.com/langsmith/-/langsmith-0.5.10.tgz#f0df23538e6a7c2928787030cedfb4be9d5b3db6" - integrity sha512-unBdaaD/CqAOLIYjd9kT33FgHUMvHSsyBIPbQa+p/rE/Sv/l4pAC5ISEE79zphxi+vV4qxHqEgqahVXj2Xvz7A== - dependencies: - "@types/uuid" "^10.0.0" - chalk "^5.6.2" - console-table-printer "^2.12.1" - p-queue "^6.6.2" - semver "^7.6.3" - uuid "^10.0.0" - lerna@^8.2.3: version "8.2.3" resolved "https://registry.yarnpkg.com/lerna/-/lerna-8.2.3.tgz#0a9c07eda4cfac84a480b3e66915189ccfb5bd2c" @@ -17322,11 +17288,6 @@ uuid@^10.0.0: resolved "https://registry.yarnpkg.com/uuid/-/uuid-10.0.0.tgz#5a95aa454e6e002725c79055fd42aaba30ca6294" integrity sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ== -uuid@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" - integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== - uuid@^13.0.0: version "13.0.0" resolved "https://registry.yarnpkg.com/uuid/-/uuid-13.0.0.tgz#263dc341b19b4d755eb8fe36b78d95a6b65707e8"