diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 9d952d407e..e897f3012e 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -56,7 +56,8 @@ export interface ServerWorkflowTaskBase extends ServerWorkflowStepBase { export interface ServerWorkflowTaskGuideline extends ServerWorkflowTaskBase { taskType: ServerTaskTypeEnum.Guideline; - executionType: ServerStepExecutionTypeEnum.Manual; + // AI modes only for a user-input guidance; simple-completion is always Manual (parser-enforced). + executionType: ServerStepExecutionTypeEnum; completionType: 'simple' | 'user-input'; inputType?: 'free-text'; automaticCompletion: false; diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 64d7860149..5c795959f5 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -16,6 +16,7 @@ import type { import { SystemMessage } from '@forestadmin/ai-proxy'; import { + AiAssistUnavailableError, AiInvokeTimeoutError, InvalidAiRequestError, MalformedToolCallError, @@ -365,6 +366,25 @@ export default abstract class BaseStepExecutor(messages, [tool])).args; } + // Tags any AI-call failure as AiAssistUnavailableError so callers can degrade to Manual; + // an already-tagged error passes through (no double-wrapping when calls nest). + protected async withAiAssist(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + if (error instanceof AiAssistUnavailableError) throw error; + throw new AiAssistUnavailableError(error); + } + } + + protected logAiDegrade(reason: unknown): void { + this.context.logger( + 'Warn', + `${this.context.stepDefinition.type}: AI unavailable, degrading to manual`, + { ...this.logCtx, error: extractErrorMessage(reason) }, + ); + } + // Overridden by executors that carry type-specific log identifiers (e.g. McpStepExecutor). protected getExtraLogContext(): Record { return {}; diff --git a/packages/workflow-executor/src/executors/guidance-step-executor.ts b/packages/workflow-executor/src/executors/guidance-step-executor.ts index e63028c122..e8f1af8375 100644 --- a/packages/workflow-executor/src/executors/guidance-step-executor.ts +++ b/packages/workflow-executor/src/executors/guidance-step-executor.ts @@ -1,19 +1,81 @@ import type { StepExecutionResult } from '../types/execution-context'; +import type { GuidanceStepExecutionData } from '../types/step-execution-data'; import type { GuidanceStepDefinition } from '../types/validated/step-definition'; import type { RecordStepStatus } from '../types/validated/step-outcome'; -import { StepStateError } from '../errors'; +import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; +import { z } from 'zod'; + +import { AiAssistUnavailableError, StepStateError } from '../errors'; import BaseStepExecutor from './base-step-executor'; import patchBodySchemas from '../http/pending-data-validators'; +import { StepExecutionMode } from '../types/validated/step-definition'; + +const GUIDANCE_RESPONSE_SYSTEM_PROMPT = `You are completing a free-text response step in a business workflow on behalf of the operator. +Write the response the operator would type, using the step instructions and the workflow context (trigger record, previous steps). +- Answer the instructions directly; do not narrate what you are doing or address the operator. +- Keep a reasonable length: a few sentences unless the instructions clearly require more. +- Use only facts available in the context — never invent names, dates, amounts or identifiers. +- Plain text only (no markdown headings or code blocks).`; export default class GuidanceStepExecutor extends BaseStepExecutor { protected async doExecute(): Promise { - const { incomingPendingData } = this.context; + const { incomingPendingData, stepDefinition } = this.context; + + // Submit path (front POST) — identical in all modes, never calls the AI. + if (incomingPendingData) return this.saveSubmission(incomingPendingData); - if (!incomingPendingData) { + if (stepDefinition.executionType === StepExecutionMode.Manual) { return this.buildOutcomeResult({ status: 'awaiting-input' }); } + // Never re-run the AI on re-dispatch of the same (runId, stepIndex): replay the stored result. + const existing = await this.findGuidanceExecution(); + if (existing?.executionResult) return this.buildOutcomeResult({ status: 'success' }); + if (existing?.pendingData) return this.buildOutcomeResult({ status: 'awaiting-input' }); + + let draft: string; + + try { + draft = await this.askAiForResponse(); + } catch (error) { + if (!(error instanceof AiAssistUnavailableError)) throw error; + this.logAiDegrade(error.reason); + + return this.degradeToManual(); + } + + // An empty draft is not a submittable answer (even in Full AI) → degrade to manual input. + if (!draft.trim()) { + this.context.logger( + 'Warn', + `${this.context.stepDefinition.type}: AI returned an empty response, degrading to manual`, + this.logCtx, + ); + + return this.degradeToManual(); + } + + if (stepDefinition.executionType === StepExecutionMode.FullyAutomated) { + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'guidance', + stepIndex: this.context.stepIndex, + executionResult: { userInput: draft, generatedByAi: true }, + }); + + return this.buildOutcomeResult({ status: 'success' }); + } + + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'guidance', + stepIndex: this.context.stepIndex, + pendingData: { userInput: draft, aiGenerated: true }, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + private async saveSubmission(incomingPendingData: unknown): Promise { const parsed = patchBodySchemas.guidance.safeParse(incomingPendingData); if (!parsed.success) { @@ -33,6 +95,72 @@ export default class GuidanceStepExecutor extends BaseStepExecutor { + const executions = await this.context.runStore.getStepExecutions(this.context.runId); + + return executions.find( + (e): e is GuidanceStepExecutionData => + e.type === 'guidance' && e.stepIndex === this.context.stepIndex, + ); + } + + // Persists an empty pendingData so the front opens an empty field (no badge) and the AI is not + // retried on re-dispatch. + private async degradeToManual(): Promise { + await this.context.runStore.saveStepExecution(this.context.runId, { + type: 'guidance', + stepIndex: this.context.stepIndex, + pendingData: {}, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + + private async askAiForResponse(): Promise { + const { stepDefinition: step } = this.context; + + const tool = new DynamicStructuredTool({ + name: 'submit_guidance_response', + description: 'Submit the free-text response for this workflow step.', + schema: z.object({ + response: z.string().describe('The response text. Plain text, reasonable length.'), + }), + func: undefined, + }); + + const contextMessage = this.buildContextMessage(); + const previousStepsMessages = await this.buildPreviousStepsMessages(); + const messages = [ + contextMessage, + ...previousStepsMessages, + new SystemMessage(GUIDANCE_RESPONSE_SYSTEM_PROMPT), + new HumanMessage( + `**Step instructions**: ${ + step.prompt ?? step.title ?? 'Provide the response for this step.' + }`, + ), + ]; + + this.context.logger('Debug', 'AI guidance-response: context', { + ...this.logCtx, + request: step.prompt ?? null, + workflowContext: [contextMessage, ...previousStepsMessages].map(message => message.content), + }); + + // Wrap only the AI call: buildPreviousStepsMessages above reads the run store, and a store + // failure must surface, not be mistagged as an AI failure and degraded. + const { response } = await this.withAiAssist(() => + this.invokeWithTool<{ response?: string }>(messages, tool), + ); + + this.context.logger('Debug', 'AI guidance-response: text returned by the AI', { + ...this.logCtx, + response: response ?? '', + }); + + return response ?? ''; + } + protected buildOutcomeResult(outcome: { status: RecordStepStatus; error?: string; 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 index 2f8483bcdd..9771506d39 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -23,7 +23,6 @@ import { RelatedRecordNotFoundError, RelationNotFoundError, StepStateError, - extractErrorMessage, } from '../errors'; import RecordStepExecutor from './record-step-executor'; import { StepExecutionMode } from '../types/validated/step-definition'; @@ -174,14 +173,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const target = await this.resolveTarget(false); @@ -581,17 +572,6 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor(call: () => Promise): Promise { - try { - return await call(); - } catch (error) { - if (error instanceof AiAssistUnavailableError) throw error; - throw new AiAssistUnavailableError(error); - } - } - private async fetchRelatedData( target: Pick, relatedSchema: CollectionSchema, diff --git a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts index 82c38943cf..5aba505d83 100644 --- a/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts +++ b/packages/workflow-executor/src/executors/summary/step-execution-formatters.ts @@ -81,9 +81,13 @@ export default class StepExecutionFormatters { } private static formatGuidance(execution: GuidanceStepExecutionData): string | null { - if (!execution.executionResult?.userInput) return null; + const { userInput, generatedByAi } = execution.executionResult ?? {}; + if (!userInput) return null; - return ` The user provided the following input: "${execution.executionResult.userInput}"`; + // Don't attribute a Full AI response to the operator — a later AI step reads this summary. + return generatedByAi + ? ` The AI generated the following response: "${userInput}"` + : ` The user provided the following input: "${userInput}"`; } private static formatLoadRelatedRecord( diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index f9b7bbfcc8..80da7c91cc 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -197,8 +197,10 @@ export interface LoadRelatedRecordStepExecutionData export interface GuidanceStepExecutionData extends BaseStepExecutionData { type: 'guidance'; - pendingData?: { userInput?: string }; - executionResult?: { userInput?: string }; + // aiGenerated → front shows the "AI" badge until first edit. Empty {} marks an AI degrade: field + // opens empty, no badge, AI not retried on re-dispatch. + pendingData?: { userInput?: string; aiGenerated?: boolean }; + executionResult?: { userInput?: string; generatedByAi?: boolean }; } export type ConfirmableStepExecutionData = diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index a2c6cda898..431f1ae898 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -137,7 +137,9 @@ export type McpStepDefinition = z.infer; export const GuidanceStepDefinitionSchema = z.object({ ...sharedFields, type: z.literal(StepType.Guidance), - executionType: z.literal(Manual).default(Manual).catch(Manual), + // No `.catch`; default Manual (not AWC) — the orchestrator owns the legacy default, so the + // executor fallback only fires on a missing field, where "never call AI" is the safe direction. + executionType: z.enum([Manual, AutomatedWithConfirmation, FullyAutomated]).default(Manual), }); export type GuidanceStepDefinition = z.infer; diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts index 802c19aed6..e64cc8073d 100644 --- a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -138,6 +138,27 @@ describe('toStepDefinition', () => { }); }); + it.each([ + ServerStepExecutionTypeEnum.Manual, + ServerStepExecutionTypeEnum.AutomatedWithConfirmation, + ServerStepExecutionTypeEnum.FullyAutomated, + ])('maps a guideline task with executionType=%s through verbatim', executionType => { + const task = makeTask({ + taskType: ServerTaskTypeEnum.Guideline, + prompt: 'guide', + executionType, + }); + + expect(toStepDefinition(task)).toMatchObject({ type: StepType.Guidance, executionType }); + }); + + it('defaults a guideline task without executionType to manual', () => { + const task = makeTask({ taskType: ServerTaskTypeEnum.Guideline, prompt: 'guide' }); + delete (task as { executionType?: unknown }).executionType; + + expect(toStepDefinition(task)).toMatchObject({ executionType: StepExecutionMode.Manual }); + }); + it('should preserve executionType=fully-automated', () => { const task = makeTask({ taskType: ServerTaskTypeEnum.GetData, diff --git a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts index 308be4058e..1f212103ec 100644 --- a/packages/workflow-executor/test/executors/guidance-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/guidance-step-executor.test.ts @@ -25,6 +25,23 @@ function makeMockRunStore(overrides: Partial = {}): RunStore { }; } +function makeMockModel( + toolCallArgs?: Record, + toolName = 'submit_guidance_response', +) { + 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 makeStep(overrides: Partial = {}): GuidanceStepDefinition { + return { type: StepType.Guidance, executionType: StepExecutionMode.Manual, ...overrides }; +} + function makeContext( overrides: Partial> & { agentPort?: AgentPort; @@ -47,7 +64,7 @@ function makeContext( recordId: [1], stepIndex: 0, } as RecordRef, - stepDefinition: { type: StepType.Guidance, executionType: StepExecutionMode.Manual }, + stepDefinition: makeStep(), model: {} as ExecutionContext['model'], runStore: makeMockRunStore(), user: { @@ -93,70 +110,334 @@ function makeContext( } describe('GuidanceStepExecutor', () => { - it('saves executionResult and returns success when incomingPendingData has valid userInput', async () => { - const runStore = makeMockRunStore(); + describe('submit path (front POST)', () => { + it('saves executionResult and returns success when incomingPendingData has valid userInput', async () => { + const runStore = makeMockRunStore(); - const executor = new GuidanceStepExecutor( - makeContext({ runStore, incomingPendingData: { userInput: 'Please proceed with option A' } }), - ); - const result = await executor.execute(); + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + incomingPendingData: { userInput: 'Please proceed with option A' }, + }), + ); + const result = await executor.execute(); - const outcome = result.stepOutcome as GuidanceStepOutcome; - expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('success'); - expect(outcome.stepId).toBe('guidance-1'); - expect(outcome.stepIndex).toBe(0); + const outcome = result.stepOutcome as GuidanceStepOutcome; + expect(outcome.type).toBe('guidance'); + expect(outcome.status).toBe('success'); + expect(outcome.stepId).toBe('guidance-1'); + expect(outcome.stepIndex).toBe(0); - expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { - type: 'guidance', - stepIndex: 0, - executionResult: { userInput: 'Please proceed with option A' }, + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: 'Please proceed with option A' }, + }); + }); + + it('saves empty string and returns success when userInput is empty', async () => { + const runStore = makeMockRunStore(); + + const executor = new GuidanceStepExecutor( + makeContext({ runStore, incomingPendingData: { userInput: '' } }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: '' }, + }); + }); + + it('saves empty string and returns success when userInput is absent', async () => { + const runStore = makeMockRunStore(); + + const executor = new GuidanceStepExecutor(makeContext({ runStore, incomingPendingData: {} })); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: '' }, + }); + }); + + it('processes the submission without calling the AI even in an AI mode', async () => { + const runStore = makeMockRunStore(); + const { model, bindTools } = makeMockModel({ response: 'unused' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + incomingPendingData: { userInput: 'human answer' }, + }), + ); + await executor.execute(); + + expect(bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: 'human answer' }, + }); }); }); - it('returns awaiting-input when incomingPendingData is absent', async () => { - const runStore = makeMockRunStore(); + describe('executionType=Manual', () => { + it('returns awaiting-input and never calls the AI', async () => { + const runStore = makeMockRunStore(); + const { model, bindTools } = makeMockModel({ response: 'unused' }); - const executor = new GuidanceStepExecutor(makeContext({ runStore })); - const result = await executor.execute(); + const executor = new GuidanceStepExecutor(makeContext({ runStore, model })); + const result = await executor.execute(); - const outcome = result.stepOutcome as GuidanceStepOutcome; - expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('awaiting-input'); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); }); - it('saves empty string and returns success when userInput is empty', async () => { - const runStore = makeMockRunStore(); + describe('executionType=AutomatedWithConfirmation (AI-assisted)', () => { + it('saves the AI draft as pendingData with aiGenerated and returns awaiting-input', async () => { + const runStore = makeMockRunStore(); + const { model } = makeMockModel({ response: 'Drafted answer from the AI' }); - const executor = new GuidanceStepExecutor( - makeContext({ runStore, incomingPendingData: { userInput: '' } }), - ); - const result = await executor.execute(); + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + prompt: 'Summarize the situation', + }), + }), + ); + const result = await executor.execute(); - const outcome = result.stepOutcome as GuidanceStepOutcome; - expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('success'); - expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { - type: 'guidance', - stepIndex: 0, - executionResult: { userInput: '' }, + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + pendingData: { userInput: 'Drafted answer from the AI', aiGenerated: true }, + }); + }); + + it('sends the step prompt and workflow context to the AI', async () => { + const { model, bindTools, invoke } = makeMockModel({ response: 'answer' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + model, + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + prompt: 'Summarize the customer situation', + }), + }), + ); + await executor.execute(); + + expect(bindTools).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'submit_guidance_response' })], + { tool_choice: 'any' }, + ); + const messages = invoke.mock.calls[0][0] as { content: string }[]; + const allContent = messages.map(m => m.content).join('\n'); + expect(allContent).toContain('Summarize the customer situation'); + }); + + it('does not regenerate the draft when a pending execution already exists (re-dispatch)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'guidance', + stepIndex: 0, + pendingData: { userInput: 'earlier draft', aiGenerated: true }, + }, + ]), + }); + const { model, bindTools } = makeMockModel({ response: 'new draft' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('degrades to manual (empty pendingData, awaiting-input) when the AI call fails', async () => { + const runStore = makeMockRunStore(); + const invoke = jest.fn().mockRejectedValue(new Error('AI provider down')); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + pendingData: {}, + }); + }); + + it('opens an empty field (degrade, no badge) when the AI returns a whitespace-only draft', async () => { + const runStore = makeMockRunStore(); + const { model } = makeMockModel({ response: ' ' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + pendingData: {}, + }); + }); + + it('does not retry the AI on re-dispatch after a degrade (empty pendingData persisted)', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest + .fn() + .mockResolvedValue([{ type: 'guidance', stepIndex: 0, pendingData: {} }]), + }); + const { model, bindTools } = makeMockModel({ response: 'new draft' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); }); - it('saves empty string and returns success when userInput is absent', async () => { - const runStore = makeMockRunStore(); + describe('executionType=FullyAutomated (Full AI)', () => { + it('saves the AI response as executionResult with generatedByAi and returns success without pausing', async () => { + const runStore = makeMockRunStore(); + const { model } = makeMockModel({ response: 'Auto-written summary' }); - const executor = new GuidanceStepExecutor(makeContext({ runStore, incomingPendingData: {} })); - const result = await executor.execute(); + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + prompt: 'Write a summary', + }), + }), + ); + const result = await executor.execute(); - const outcome = result.stepOutcome as GuidanceStepOutcome; - expect(outcome.type).toBe('guidance'); - expect(outcome.status).toBe('success'); - expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { - type: 'guidance', - stepIndex: 0, - executionResult: { userInput: '' }, + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('success'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: 'Auto-written summary', generatedByAi: true }, + }); + }); + + it('replays success without re-calling the AI when executionResult already exists', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: 'done', generatedByAi: true }, + }, + ]), + }); + const { model, bindTools } = makeMockModel({ response: 'new' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('success'); + expect(bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('degrades to manual awaiting-input on AI failure (run does not fail, step not skipped)', async () => { + const runStore = makeMockRunStore(); + const invoke = jest.fn().mockRejectedValue(new Error('AI provider down')); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + pendingData: {}, + }); + }); + + it('degrades to manual awaiting-input on an empty AI response', async () => { + const runStore = makeMockRunStore(); + const { model } = makeMockModel({ response: '' }); + + const executor = new GuidanceStepExecutor( + makeContext({ + runStore, + model, + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }), + ); + const result = await executor.execute(); + + expect((result.stepOutcome as GuidanceStepOutcome).status).toBe('awaiting-input'); + expect(runStore.saveStepExecution).toHaveBeenCalledWith('run-1', { + type: 'guidance', + stepIndex: 0, + pendingData: {}, + }); }); }); }); diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts index e21c833597..d525767551 100644 --- a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -159,6 +159,18 @@ describe('StepExecutionFormatters', () => { ); }); + it('attributes a Full AI response to the AI, not the operator', () => { + const execution: StepExecutionData = { + type: 'guidance', + stepIndex: 0, + executionResult: { userInput: 'Summary written by the AI.', generatedByAi: true }, + }; + + expect(StepExecutionFormatters.format(execution)).toBe( + ' The AI generated the following response: "Summary written by the AI."', + ); + }); + it('returns null when executionResult is absent', () => { const execution: StepExecutionData = { type: 'guidance', diff --git a/packages/workflow-executor/test/types/step-definition.test.ts b/packages/workflow-executor/test/types/step-definition.test.ts index 04fdd1c6aa..5118d59ca5 100644 --- a/packages/workflow-executor/test/types/step-definition.test.ts +++ b/packages/workflow-executor/test/types/step-definition.test.ts @@ -1,4 +1,5 @@ import { + GuidanceStepDefinitionSchema, LoadRelatedRecordStepDefinitionSchema, StepExecutionMode, StepType, @@ -41,3 +42,31 @@ describe('LoadRelatedRecordStepDefinitionSchema executionType', () => { expect(result.success).toBe(false); }); }); + +describe('GuidanceStepDefinitionSchema executionType', () => { + const base = { type: StepType.Guidance as const }; + + it('parses each valid execution mode to its own value', () => { + expect( + GuidanceStepDefinitionSchema.parse({ ...base, executionType: 'manual' }).executionType, + ).toBe(StepExecutionMode.Manual); + expect( + GuidanceStepDefinitionSchema.parse({ ...base, executionType: 'automated-with-confirmation' }) + .executionType, + ).toBe(StepExecutionMode.AutomatedWithConfirmation); + expect( + GuidanceStepDefinitionSchema.parse({ ...base, executionType: 'fully-automated' }) + .executionType, + ).toBe(StepExecutionMode.FullyAutomated); + }); + + it('defaults a missing executionType to Manual', () => { + expect(GuidanceStepDefinitionSchema.parse(base).executionType).toBe(StepExecutionMode.Manual); + }); + + it('rejects an invalid executionType instead of coercing it', () => { + expect( + GuidanceStepDefinitionSchema.safeParse({ ...base, executionType: 'not-a-mode' }).success, + ).toBe(false); + }); +});