Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -313,14 +336,20 @@ 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<StepExecutionResult> {
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<string, unknown>; aiFilledValues: AiFilledFormValue[] },
): Promise<StepExecutionResult> {
const { selectedRecordRef, displayName, name } = target;

const actionResult = await this.context.agent.executeAction(
{
collection: selectedRecordRef.collectionName,
action: name,
id: selectedRecordRef.recordId,
...(form && { values: form.values }),
},
{
beforeCall: () =>
Expand All @@ -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',
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<typeof makeMockRunStore>) {
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');
});
});

Expand Down
Loading