From c12364ab6283ddb44af4127e354d15cd3922962c Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 14:29:52 +0200 Subject: [PATCH] feat(workflow-executor): full-ai action forms, fill and submit with fallback (PRD-512) Full AI (FullyAutomated) on a form-bearing action now fills and submits the form automatically instead of throwing UnsupportedActionFormError. Reuses the shared fillFormWithAi loop from PRD-511, then: - all required fields filled -> executeOnExecutor submits the AI values (idempotency write-ahead) and records submissionOutcome 'executed' + submittedValues + the AI prefill (audit, PRD-513) - a required field left empty -> fall back to the exact AI-assisted review state, carrying what was filled - validation rejection / approval-required 403 -> same fallback (a human finishes natively); the fallback pause cleanly overwrites the 'executing' marker - plain permission 403 / infra error -> real step error (no fallback) Removes the UnsupportedActionFormError path for FullyAutomated + form. fixes PRD-512 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trigger-record-action-step-executor.ts | 60 +++++-- ...rigger-record-action-step-executor.test.ts | 169 +++++++++++++----- 2 files changed, 169 insertions(+), 60 deletions(-) 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 index 6710184c01..4e2f114939 100644 --- a/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts +++ b/packages/workflow-executor/src/executors/trigger-record-action-step-executor.ts @@ -12,10 +12,11 @@ import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin import { z } from 'zod'; import { + ActionFormValidationError, ActionNotFoundError, + ActionRequiresApprovalError, NoActionsError, StepStateError, - UnsupportedActionFormError, } from '../errors'; import RecordStepExecutor from './record-step-executor'; import { StepExecutionMode } from '../types/validated/step-definition'; @@ -145,20 +146,42 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< return this.pauseForConfirmation(target, { fields: form.fields, aiFilledValues: [] }); } - // Full AI on a form is implemented in PRD-512 (fill + submit). Until then, unsupported. - if (step.executionType === StepExecutionMode.FullyAutomated) { - throw new UnsupportedActionFormError(target.displayName); - } - - // AI-assisted (PRD-511): AI pre-fills what it can from the workflow context, then pause for - // the user to review/edit/submit natively. + // AI-assisted + Full AI share the same fill loop; only the exit differs. const { aiFilledValues, form: filledForm } = await this.fillFormWithAi( selectedRecordRef, target.name, form, ); + const reviewState = { fields: filledForm.fields, aiFilledValues }; + + // AI-assisted (PRD-511): pause for the user to review/edit/submit natively. + if (step.executionType !== StepExecutionMode.FullyAutomated) { + return this.pauseForConfirmation(target, reviewState); + } + + // Full AI (PRD-512): submit if the AI filled every required field; otherwise fall back to the + // exact AI-assisted review state, carrying what was filled. + if (!filledForm.canExecute) { + return this.pauseForConfirmation(target, reviewState); + } + + const values = Object.fromEntries(aiFilledValues.map(v => [v.field, v.value])); + + try { + return await this.executeOnExecutor(target, { values, aiFilledValues }); + } catch (error) { + // Validation rejection or an approval-gated action → not a hard failure: pause as + // AI-assisted so a human can finish/submit natively. Plain permission 403, infra errors, + // etc. propagate as a real step error (a reviewing human couldn't fix those). + if ( + error instanceof ActionFormValidationError || + error instanceof ActionRequiresApprovalError + ) { + return this.pauseForConfirmation(target, reviewState); + } - return this.pauseForConfirmation(target, { fields: filledForm.fields, aiFilledValues }); + throw error; + } } // Pause the step awaiting user confirmation. For form-bearing actions, `form` carries the native @@ -313,7 +336,12 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< } /** Branch B — executor runs the action via the audited agent, then persists the result. */ - private async executeOnExecutor(target: ActionTarget): Promise { + private async executeOnExecutor( + target: ActionTarget, + // Form submission (Full AI, PRD-512): the AI-filled values to submit + the ordered prefill for + // the audit trail. Omitted for a formless action (executor just triggers it). + form?: { values: Record; aiFilledValues: AiFilledFormValue[] }, + ): Promise { const { selectedRecordRef, displayName, name } = target; const actionResult = await this.context.agent.executeAction( @@ -321,6 +349,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< collection: selectedRecordRef.collectionName, action: name, id: selectedRecordRef.recordId, + ...(form && { values: form.values }), }, { beforeCall: () => @@ -337,7 +366,16 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< type: 'trigger-action', stepIndex: this.context.stepIndex, executionParams: { displayName, name }, - executionResult: { success: true, actionResult }, + executionResult: { + success: true, + actionResult, + // Form-bearing Full AI: record what the executor submitted (PRD-512/513). + ...(form && { + submissionOutcome: 'executed', + submittedValues: form.values, + ...(form.aiFilledValues.length && { aiFilledValues: form.aiFilledValues }), + }), + }, selectedRecordRef, idempotencyPhase: 'done', }); 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 index 77a7e7c1cd..31fa539379 100644 --- 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 @@ -8,7 +8,13 @@ import type { CollectionSchema, RecordRef } from '../../src/types/validated/coll import type { Step } from '../../src/types/validated/execution'; import type { TriggerActionStepDefinition } from '../../src/types/validated/step-definition'; -import { AgentPortError, RunStorePortError, StepStateError } from '../../src/errors'; +import { + ActionFormValidationError, + ActionRequiresApprovalError, + AgentPortError, + RunStorePortError, + StepStateError, +} from '../../src/errors'; import ActivityLog from '../../src/executors/activity-log'; import AgentWithLog from '../../src/executors/agent-with-log'; import TriggerRecordActionStepExecutor from '../../src/executors/trigger-record-action-step-executor'; @@ -641,73 +647,138 @@ describe('TriggerRecordActionStepExecutor', () => { }); }); - describe('UnsupportedActionFormError (form detection)', () => { - it('throws when the action has a form and executionType is FullyAutomated (PRD-512 not yet)', async () => { - const agentPort = makeMockAgentPort(); - (agentPort.getActionForm as jest.Mock).mockResolvedValue({ - fields: [{ name: 'reason', type: 'String', isRequired: true }], - canExecute: false, - requiredFields: ['reason'], - skippedFields: [], - }); - const mockModel = makeMockModel({ - actionName: 'Send Welcome Email', - reasoning: 'r', - }); - const runStore = makeMockRunStore(); - const context = makeContext({ - model: mockModel.model, + describe('Full AI on a form (PRD-512)', () => { + // initial form (required field empty) then the re-fetch after the AI fills it (canExecute). + function mockFillThenComplete(agentPort: AgentPort) { + (agentPort.getActionForm as jest.Mock) + .mockResolvedValueOnce({ + fields: [{ name: 'amount', type: 'Number', isRequired: true }], + canExecute: false, + requiredFields: ['amount'], + skippedFields: [], + }) + .mockResolvedValueOnce({ + fields: [{ name: 'amount', type: 'Number', isRequired: true, value: 50 }], + canExecute: true, + requiredFields: [], + skippedFields: [], + }); + } + + function fullAiContext(agentPort: AgentPort, runStore: ReturnType) { + return makeContext({ + model: makeMockModel({ values: { amount: 50 } }, 'fill_action_form').model, agentPort, runStore, - stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { + selectedRecordStepId: 'workflow-start', + actionName: 'send-welcome-email', + }, + }), }); - const executor = new TriggerRecordActionStepExecutor(context); + } - const result = await executor.execute(); + it('fills every required field then submits the action with the AI values', async () => { + const agentPort = makeMockAgentPort(); + mockFillThenComplete(agentPort); + (agentPort.executeAction as jest.Mock).mockResolvedValue({ success: 'ok' }); + const runStore = makeMockRunStore(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - 'This action requires user input via a form, which is not yet supported in workflows.', + const result = await new TriggerRecordActionStepExecutor( + fullAiContext(agentPort, runStore), + ).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).toHaveBeenCalledWith( + expect.objectContaining({ action: 'send-welcome-email', values: { amount: 50 } }), + expect.anything(), ); - // Form detection uses the resolved technical name, not the AI display name — - // passing "Send Welcome Email" would 404 against the agent. - expect(agentPort.getActionForm).toHaveBeenCalledWith( - { collection: 'customers', action: 'send-welcome-email', id: [42] }, - expect.objectContaining({ id: 1 }), + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: expect.objectContaining({ + success: true, + submissionOutcome: 'executed', + submittedValues: { amount: 50 }, + }), + }), ); - expect(agentPort.executeAction).not.toHaveBeenCalled(); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('supports form-bearing actions when executionType is not FullyAutomated (frontend handles the form)', async () => { + it('falls back to the AI-assisted review state when a required field stays empty', async () => { const agentPort = makeMockAgentPort(); - // hasForm would return true if called — but it should not be called in this branch. - (agentPort.getActionFormInfo as jest.Mock).mockResolvedValue({ hasForm: true }); - const mockModel = makeMockModel({ - actionName: 'Send Welcome Email', - reasoning: 'r', + (agentPort.getActionForm as jest.Mock).mockResolvedValue({ + fields: [{ name: 'amount', type: 'Number', isRequired: true }], + canExecute: false, + requiredFields: ['amount'], + skippedFields: [], }); - const runStore = makeMockRunStore(); + // AI returns no values → loop makes no progress → required field unfilled. const context = makeContext({ - model: mockModel.model, + model: makeMockModel({ values: {} }, 'fill_action_form').model, agentPort, - runStore, + runStore: makeMockRunStore(), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { + selectedRecordStepId: 'workflow-start', + actionName: 'send-welcome-email', + }, + }), }); - const executor = new TriggerRecordActionStepExecutor(context); - const result = await executor.execute(); + const result = await new TriggerRecordActionStepExecutor(context).execute(); expect(result.stepOutcome.status).toBe('awaiting-input'); - // Form check is skipped when not automatic — the frontend will handle the form. - expect(agentPort.getActionFormInfo).not.toHaveBeenCalled(); expect(agentPort.executeAction).not.toHaveBeenCalled(); - expect(runStore.saveStepExecution).toHaveBeenCalledWith( - 'run-1', - expect.objectContaining({ - type: 'trigger-action', - pendingData: { displayName: 'Send Welcome Email', name: 'send-welcome-email' }, - }), + }); + + it('falls back to AI-assisted when the action requires an approval', async () => { + const agentPort = makeMockAgentPort(); + mockFillThenComplete(agentPort); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new ActionRequiresApprovalError('send-welcome-email', [7]), ); + const runStore = makeMockRunStore(); + const result = await new TriggerRecordActionStepExecutor( + fullAiContext(agentPort, runStore), + ).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // The execute attempt wrote an `executing` write-ahead marker; the fallback pause must + // overwrite it with a clean awaiting-input record — otherwise a re-dispatch would think the + // step is stuck (StepStateError) instead of resumable. + const lastSave = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(lastSave).toHaveProperty('pendingData'); + expect(lastSave).not.toHaveProperty('idempotencyPhase'); + }); + + it('falls back to AI-assisted when the submission is rejected by validation', async () => { + const agentPort = makeMockAgentPort(); + mockFillThenComplete(agentPort); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new ActionFormValidationError('send-welcome-email'), + ); + const result = await new TriggerRecordActionStepExecutor( + fullAiContext(agentPort, makeMockRunStore()), + ).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + }); + + it('surfaces a plain permission/infra error as a step error (no fallback)', async () => { + const agentPort = makeMockAgentPort(); + mockFillThenComplete(agentPort); + (agentPort.executeAction as jest.Mock).mockRejectedValue( + new AgentPortError('executeAction', new Error('403 Forbidden')), + ); + const result = await new TriggerRecordActionStepExecutor( + fullAiContext(agentPort, makeMockRunStore()), + ).execute(); + + expect(result.stepOutcome.status).toBe('error'); }); });