diff --git a/packages/workflow-executor/src/adapters/server-types.ts b/packages/workflow-executor/src/adapters/server-types.ts index 3fbd351fbd..6d6ba3285f 100644 --- a/packages/workflow-executor/src/adapters/server-types.ts +++ b/packages/workflow-executor/src/adapters/server-types.ts @@ -76,7 +76,9 @@ interface ServerWorkflowTaskUpdateData extends ServerWorkflowTaskBase { interface ServerWorkflowTaskTriggerAction extends ServerWorkflowTaskBase { taskType: ServerTaskTypeEnum.TriggerAction; + // Manual is valid for a form-bearing action (PRD-511): pause for the user with no AI prefill. executionType: + | ServerStepExecutionTypeEnum.Manual | ServerStepExecutionTypeEnum.FullyAutomated | ServerStepExecutionTypeEnum.AutomatedWithConfirmation; } diff --git a/packages/workflow-executor/src/executors/agent-with-log.ts b/packages/workflow-executor/src/executors/agent-with-log.ts index fa9ef13cb6..142ff2a252 100644 --- a/packages/workflow-executor/src/executors/agent-with-log.ts +++ b/packages/workflow-executor/src/executors/agent-with-log.ts @@ -1,8 +1,10 @@ import type ActivityLog from './activity-log'; import type { + ActionForm, AgentPort, ExecuteActionQuery, GetActionFormInfoQuery, + GetActionFormQuery, GetRecordQuery, GetRelatedDataQuery, GetSingleRelatedDataQuery, @@ -117,6 +119,12 @@ export default class AgentWithLog { return this.agentPort.getActionFormInfo(query, this.user); } + // Unaudited passthrough: reading the form structure (and applying values to reveal dependent + // fields via change hooks) is read-only — the actual execution is what gets logged (PRD-509/511). + getActionForm(query: GetActionFormQuery): Promise { + return this.agentPort.getActionForm(query, this.user); + } + // Unaudited passthrough: resolves a polymorphic relation's target type (metadata probe). The // actual related-record load is audited separately, so this records NO activity-log entry. resolvePolymorphicType( 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 b7c9a8ff43..6710184c01 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 @@ -1,5 +1,10 @@ +import type { ActionForm, ActionFormField } from '../ports/agent-port'; import type { StepExecutionResult } from '../types/execution-context'; -import type { ActionRef, TriggerRecordActionStepExecutionData } from '../types/step-execution-data'; +import type { + ActionRef, + AiFilledFormValue, + TriggerRecordActionStepExecutionData, +} from '../types/step-execution-data'; import type { ActionSchema, CollectionSchema, RecordRef } from '../types/validated/collection'; import type { TriggerActionStepDefinition } from '../types/validated/step-definition'; @@ -23,6 +28,16 @@ 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.`; +const FILL_FORM_SYSTEM_PROMPT = `You are filling out an action form using the user's request and the data available in the workflow context. + +Important rules: +- The request is an explicit instruction. When it states a value for a field (e.g. "set the price to 35"), use that exact value — following an explicit instruction is NOT guessing. +- Otherwise, fill a field only when the workflow context gives you the value with confidence. +- A field's "current" value is just its existing default; override it when the request asks you to. +- If neither the request nor the workflow context gives you a field's value, LEAVE IT OUT — never guess or assume. +- For Enum fields, use exactly one of the allowed values, otherwise leave the field out. +- Do not invent identifiers, dates, amounts, or file contents that are absent from both the request and the context.`; + interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; } @@ -56,9 +71,16 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< async exec => { const { selectedRecordRef, pendingData, userConfirmation } = exec; - // The frontend executes the action itself and posts the result back. - // A confirmed step without actionResult is a broken frontend contract. - if (!pendingData || !userConfirmation || !('actionResult' in userConfirmation)) { + // The frontend executes the action natively and posts the result back. A confirmed step + // must carry an actionResult — UNLESS the submission only created an approval request + // (pending-approval), in which case no result exists yet (PRD-511/520). + const isPendingApproval = userConfirmation?.submissionOutcome === 'pending-approval'; + + if ( + !pendingData || + !userConfirmation || + (!('actionResult' in userConfirmation) && !isPendingApproval) + ) { throw new StepStateError( `Frontend confirmed action but did not provide actionResult ` + `(run "${this.context.runId}", step ${this.context.stepIndex})`, @@ -71,7 +93,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< name: pendingData.name, }; - return this.saveFrontendResult(target, userConfirmation.actionResult, exec); + return this.saveFrontendResult(target, exec); }, ); } @@ -104,31 +126,192 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< name: action.name, }; - // Branch B -- fully automated: executor runs the action itself, so it cannot - // handle forms (no UI to fill them). Reject form-bearing actions here. When the - // frontend is in the loop (Branch C), it handles the form natively so no check. - if (step.executionType === StepExecutionMode.FullyAutomated) { - const { hasForm } = await this.context.agent.getActionFormInfo({ - collection: selectedRecordRef.collectionName, - action: target.name, - id: selectedRecordRef.recordId, - }); - if (hasForm) throw new UnsupportedActionFormError(target.displayName); + const form = await this.context.agent.getActionForm({ + collection: selectedRecordRef.collectionName, + action: target.name, + id: selectedRecordRef.recordId, + }); + const hasForm = form.fields.length > 0; + + // Formless action — unchanged behavior: Full AI runs it directly, otherwise pause for the user. + if (!hasForm) { + return step.executionType === StepExecutionMode.FullyAutomated + ? this.executeOnExecutor(target) + : this.pauseForConfirmation(target); + } + + // Manual (PRD-511): pause with the native form, NO AI pre-fill. + if (step.executionType === StepExecutionMode.Manual) { + return this.pauseForConfirmation(target, { fields: form.fields, aiFilledValues: [] }); + } - return this.executeOnExecutor(target); + // 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); } - // Branch C -- Awaiting confirmation (frontend executes the action, including forms) + // AI-assisted (PRD-511): AI pre-fills what it can from the workflow context, then pause for + // the user to review/edit/submit natively. + const { aiFilledValues, form: filledForm } = await this.fillFormWithAi( + selectedRecordRef, + target.name, + form, + ); + + return this.pauseForConfirmation(target, { fields: filledForm.fields, aiFilledValues }); + } + + // Pause the step awaiting user confirmation. For form-bearing actions, `form` carries the native + // form fields + the ordered AI prefill the front replays sequentially (PRD-511). + private async pauseForConfirmation( + target: ActionTarget, + form?: { fields: ActionFormField[]; aiFilledValues: AiFilledFormValue[] }, + ): Promise { await this.context.runStore.saveStepExecution(this.context.runId, { type: 'trigger-action', stepIndex: this.context.stepIndex, - pendingData: { displayName: target.displayName, name: target.name }, + pendingData: { displayName: target.displayName, name: target.name, ...(form && { form }) }, selectedRecordRef: target.selectedRecordRef, }); return this.buildOutcomeResult({ status: 'awaiting-input' }); } + // Shared AI form-fill loop (PRD-511, reused by Full AI in PRD-512). Iteratively asks the AI to + // fill the fields it has context for (leave-empty-if-unsure), re-applying after each pass so + // change hooks reveal dependent fields. Bounded by max iterations + no-progress detection so an + // oscillating dynamic form can't loop forever. Returns the values in fill order + the final form. + private async fillFormWithAi( + recordRef: RecordRef, + action: string, + initialForm: ActionForm, + ): Promise<{ aiFilledValues: AiFilledFormValue[]; form: ActionForm }> { + const MAX_ITERATIONS = 3; + const accumulator: Record = {}; + const ordered: AiFilledFormValue[] = []; + let form = initialForm; + + for (let i = 0; i < MAX_ITERATIONS; i += 1) { + // eslint-disable-next-line no-await-in-loop + const aiValues = await this.askAiToFillForm(form); + let progressed = false; + + for (const [field, value] of Object.entries(aiValues)) { + const isEmpty = value === undefined || value === null || value === ''; + const exists = form.fields.some(f => f.name === field); + const isNew = accumulator[field] !== value; + + // Keep only non-empty values for fields that still exist and weren't already set. + if (!isEmpty && exists && isNew) { + accumulator[field] = value; + ordered.push({ field, value }); + progressed = true; + } + } + + // No-progress guard: the AI added nothing new this pass → it has no more context to offer. + if (!progressed) break; + + // Re-apply so change hooks reveal/adjust dependent fields for the next pass. + // eslint-disable-next-line no-await-in-loop + form = await this.context.agent.getActionForm({ + collection: recordRef.collectionName, + action, + id: recordRef.recordId, + values: accumulator, + }); + + if (form.canExecute) break; + } + + // Drop any value whose field no longer exists after the hooks (state drift) — fail-safe. + const finalFieldNames = new Set(form.fields.map(f => f.name)); + const aiFilledValues = ordered.filter(v => finalFieldNames.has(v.field)); + + // Debug trace for support: the net field values actually retained (after drop-stale) + whether + // the form is now complete enough to submit. Off by default (Debug level). Client-side log only. + this.context.logger('Debug', 'AI form-fill: final values', { + ...this.logCtx, + aiFilledValues, + canExecute: form.canExecute, + }); + + return { aiFilledValues, form }; + } + + // One AI fill pass: present the current form fields and ask the AI for values it's confident + // about. The strict leave-empty rule (never guess) lives in the prompt + tool description. + private async askAiToFillForm(form: ActionForm): Promise> { + const { stepDefinition: step } = this.context; + const fieldLines = form.fields + .map(field => { + const parts = [`- ${field.name} (${field.type}${field.isRequired ? ', required' : ''})`]; + if (field.enumValues?.length) parts.push(`allowed: ${field.enumValues.join(', ')}`); + + if (field.value !== undefined && field.value !== null) { + parts.push(`current: ${JSON.stringify(field.value)}`); + } + + return parts.join(' — '); + }) + .join('\n'); + + const tool = new DynamicStructuredTool({ + name: 'fill_action_form', + description: + 'Provide values for the action form fields you have enough context to fill. ' + + 'Return a `values` object keyed by field name. Leave a field OUT entirely if you are ' + + 'not sure — never guess or assume. For Enum fields use exactly one of the allowed values.', + schema: z.object({ + values: z + .record(z.string(), z.unknown()) + .optional() + .describe('Field name → value, only for fields you are confident about.'), + }), + func: undefined, + }); + + const contextMessage = this.buildContextMessage(); + const previousStepsMessages = await this.buildPreviousStepsMessages(); + const messages = [ + contextMessage, + ...previousStepsMessages, + new SystemMessage(FILL_FORM_SYSTEM_PROMPT), + new SystemMessage(`Action form fields:\n${fieldLines}`), + new HumanMessage(`**Request**: ${step.prompt ?? 'Fill the action form.'}`), + ]; + + // Debug trace for support: the inputs the AI fill works from. Off by default (Debug level); a + // client turns it on with LOG_LEVEL=Debug to diagnose an under-/mis-filled form. Logged before + // the call so it's available even if the AI invocation fails. Client-side log only. + // Only the non-redundant parts: the request (instruction), the fields as structured rows, and + // the workflow context (record + previous steps) — the static fill rules aren't logged. + this.context.logger('Debug', 'AI form-fill: context', { + ...this.logCtx, + request: step.prompt ?? null, + fields: form.fields.map(field => ({ + name: field.name, + type: field.type, + required: field.isRequired, + current: field.value, + ...(field.enumValues?.length ? { allowed: field.enumValues } : {}), + })), + workflowContext: [contextMessage, ...previousStepsMessages].map(message => message.content), + }); + + const { values } = await this.invokeWithTool<{ values?: Record }>( + messages, + tool, + ); + + this.context.logger('Debug', 'AI form-fill: values returned by the AI', { + ...this.logCtx, + values: values ?? {}, + }); + + return values ?? {}; + } + /** Branch B — executor runs the action via the audited agent, then persists the result. */ private async executeOnExecutor(target: ActionTarget): Promise { const { selectedRecordRef, displayName, name } = target; @@ -162,20 +345,33 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< return this.buildOutcomeResult({ status: 'success' }); } - /** Branch A — the frontend executed the action; executor only persists the result it sent. */ + /** + * Branch A — the frontend executed the action natively; the executor persists what it reported. + * Records the submission outcome (executed vs pending-approval), the submitted values, and the + * AI prefill (from the stored pending payload) for the audit trail / downstream-AI context. + */ private async saveFrontendResult( target: ActionTarget, - actionResult: unknown, existingExecution: TriggerRecordActionStepExecutionData, ): Promise { const { selectedRecordRef, displayName, name } = target; + const confirmation = existingExecution.userConfirmation; + const submissionOutcome = confirmation?.submissionOutcome ?? 'executed'; + const aiFilledValues = existingExecution.pendingData?.form?.aiFilledValues; await this.context.runStore.saveStepExecution(this.context.runId, { ...existingExecution, type: 'trigger-action', stepIndex: this.context.stepIndex, executionParams: { displayName, name }, - executionResult: { success: true, actionResult }, + executionResult: { + success: true, + // No action result exists yet when the submission only created an approval request. + ...(submissionOutcome === 'executed' && { actionResult: confirmation?.actionResult }), + submissionOutcome, + ...(confirmation?.submittedValues && { submittedValues: confirmation.submittedValues }), + ...(aiFilledValues?.length && { aiFilledValues }), + }, selectedRecordRef, }); diff --git a/packages/workflow-executor/src/http/pending-data-validators.ts b/packages/workflow-executor/src/http/pending-data-validators.ts index 13dc67dc3c..07c11c9a3a 100644 --- a/packages/workflow-executor/src/http/pending-data-validators.ts +++ b/packages/workflow-executor/src/http/pending-data-validators.ts @@ -20,6 +20,12 @@ const triggerActionPatchSchema = z // presence check lives in the step-executor so a descriptive StepStateError can // name the runId/stepIndex — not achievable from inside a zod schema. actionResult: z.unknown().optional(), + // PRD-511/520: the front executes the action natively, so it self-reports the final form + // values it submitted (lets the executor diff against the AI prefill for the audit trail). + submittedValues: z.record(z.string(), z.unknown()).optional(), + // Whether the native submit actually executed the action, or only created an approval request + // (non-blocking): downstream AI steps must be told an awaiting-approval action did NOT run. + submissionOutcome: z.enum(['executed', 'pending-approval']).optional(), }) .strict(); diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 751322ac8f..2fc2e2f310 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -1,3 +1,4 @@ +import type { ActionFormField } from '../ports/agent-port'; import type { RecordId, RecordRef } from './validated/collection'; import type { LoadRelatedRecordConfirmation, @@ -80,6 +81,26 @@ export interface ActionRef { displayName: string; } +// One AI-prefilled form value (PRD-511). Kept as an ORDERED list (not a map) so the front can +// replay it sequentially — setting a field fires its change hook, which may reveal dependent fields. +export interface AiFilledFormValue { + field: string; + value: unknown; +} + +// Pending payload for a form-bearing Trigger Action paused for review (PRD-511). `form` is absent +// for formless actions and for Manual mode (no AI prefill at all). +export interface TriggerActionPendingData extends ActionRef { + form?: { + fields: ActionFormField[]; + aiFilledValues: AiFilledFormValue[]; + }; +} + +// Submission outcome reported by the native front (PRD-511/520): `executed` = the action ran and a +// result exists; `pending-approval` = the submit only created an approval request (no result yet). +export type TriggerActionSubmissionOutcome = 'executed' | 'pending-approval'; + // Intentionally separate from ActionRef/FieldRef: expected to gain relation-specific // fields (e.g. relationType) in a future iteration. export interface RelationRef { @@ -92,8 +113,19 @@ export interface TriggerRecordActionStepExecutionData WithUserConfirmation { type: 'trigger-action'; executionParams?: ActionRef; - executionResult?: { success: true; actionResult: unknown } | { skipped: true }; - pendingData?: ActionRef; + executionResult?: + | { + success: true; + // Absent when submissionOutcome is 'pending-approval' (no result exists yet). + actionResult?: unknown; + // Defaults to 'executed' semantics when absent (formless / legacy flows). PRD-511/520. + submissionOutcome?: TriggerActionSubmissionOutcome; + // Final values the front submitted + the ordered AI prefill — PRD-513 audit (human-edit diff). + submittedValues?: Record; + aiFilledValues?: AiFilledFormValue[]; + } + | { skipped: true }; + pendingData?: TriggerActionPendingData; selectedRecordRef: RecordRef; } diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index a841e6d1aa..f07406f7bb 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -74,10 +74,12 @@ export type UpdateRecordStepDefinition = z.infer { }); }); + it('preserves executionType=manual on a trigger-action task — no silent coercion (PRD-511)', () => { + const task = makeTask({ + taskType: ServerTaskTypeEnum.TriggerAction, + executionType: ServerStepExecutionTypeEnum.Manual, + }); + + // Before PRD-511 the trigger schema's `.catch(AutomatedWithConfirmation)` would have coerced + // `manual` into AI-assisted — opting the builder back into AI prefill against their choice. + expect(toStepDefinition(task)).toMatchObject({ + type: StepType.TriggerAction, + executionType: StepExecutionMode.Manual, + }); + }); + // Casts through `as` because the orchestrator types forbid this combination — the runtime // normalization is a defensive safety net for wire data the server should not emit. it('should silently fall back to default when executionType is unsupported for the step type', () => { 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 f07aec5d09..77a7e7c1cd 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 @@ -43,6 +43,10 @@ function makeMockAgentPort(): AgentPort { getRelatedData: jest.fn(), executeAction: jest.fn().mockResolvedValue(undefined), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), + // Default: a formless action (no fields) — matches the pre-PRD-511 behavior of most tests. + getActionForm: jest + .fn() + .mockResolvedValue({ fields: [], canExecute: true, requiredFields: [], skippedFields: [] }), } as unknown as AgentPort; } @@ -449,6 +453,7 @@ describe('TriggerRecordActionStepExecutor', () => { executionResult: { success: true, actionResult: { success: 'ok', html: '

Email queued

' }, + submissionOutcome: 'executed', }, pendingData: { displayName: 'Send Welcome Email', @@ -486,7 +491,7 @@ describe('TriggerRecordActionStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionResult: { success: true, actionResult: null }, + executionResult: { success: true, actionResult: null, submissionOutcome: 'executed' }, }), ); }); @@ -637,9 +642,14 @@ describe('TriggerRecordActionStepExecutor', () => { }); describe('UnsupportedActionFormError (form detection)', () => { - it('throws when the action has a form and executionType is FullyAutomated', async () => { + it('throws when the action has a form and executionType is FullyAutomated (PRD-512 not yet)', async () => { const agentPort = makeMockAgentPort(); - (agentPort.getActionFormInfo as jest.Mock).mockResolvedValue({ hasForm: true }); + (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', @@ -661,7 +671,7 @@ describe('TriggerRecordActionStepExecutor', () => { ); // Form detection uses the resolved technical name, not the AI display name — // passing "Send Welcome Email" would 404 against the agent. - expect(agentPort.getActionFormInfo).toHaveBeenCalledWith( + expect(agentPort.getActionForm).toHaveBeenCalledWith( { collection: 'customers', action: 'send-welcome-email', id: [42] }, expect.objectContaining({ id: 1 }), ); @@ -1339,6 +1349,128 @@ describe('TriggerRecordActionStepExecutor', () => { ); }); + it('Manual mode on a form action pauses WITHOUT any AI pre-fill (PRD-511)', 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(); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ + executionType: StepExecutionMode.Manual, + preRecordedArgs: { + selectedRecordStepId: 'workflow-start', + actionName: 'send-welcome-email', + }, + }), + }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // Manual = no AI involvement at all. + expect(mockModel.bindTools).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: expect.objectContaining({ + form: { + fields: [{ name: 'reason', type: 'String', isRequired: true }], + aiFilledValues: [], + }, + }), + }), + ); + }); + + it('AI-assisted mode pre-fills the form (ordered) and pauses for review (PRD-511)', async () => { + const agentPort = makeMockAgentPort(); + (agentPort.getActionForm as jest.Mock).mockResolvedValue({ + fields: [ + { name: 'amount', type: 'Number', isRequired: true }, + { name: 'reason', type: 'String', isRequired: false }, + ], + canExecute: true, + requiredFields: [], + skippedFields: [], + }); + // AI fills amount but leaves `reason` out (no context) — must stay empty. + const mockModel = makeMockModel({ values: { amount: 50 } }, 'fill_action_form'); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + preRecordedArgs: { + selectedRecordStepId: 'workflow-start', + actionName: 'send-welcome-email', + }, + }), + }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(mockModel.bindTools).toHaveBeenCalled(); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + pendingData: expect.objectContaining({ + form: expect.objectContaining({ aiFilledValues: [{ field: 'amount', value: 50 }] }), + }), + }), + ); + }); + + it('persists a pending-approval submission without an actionResult (PRD-511/520)', async () => { + const agentPort = makeMockAgentPort(); + const execution: TriggerRecordActionStepExecutionData = { + type: 'trigger-action', + stepIndex: 0, + pendingData: { + displayName: 'Process Refund', + name: 'process-refund', + form: { fields: [], aiFilledValues: [{ field: 'amount', value: 50 }] }, + }, + userConfirmation: { + userConfirmed: true, + submissionOutcome: 'pending-approval', + submittedValues: { amount: 50 }, + }, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore }); + + const result = await new TriggerRecordActionStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.executeAction).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: { + success: true, + submissionOutcome: 'pending-approval', + submittedValues: { amount: 50 }, + aiFilledValues: [{ field: 'amount', value: 50 }], + }, + }), + ); + }); + it('falls back to AI when no preRecordedArgs', async () => { const mockModel = makeMockModel({ actionName: 'Send Welcome Email', reasoning: 'r' }); const context = makeContext({ diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index f4c38b460e..74b12b05a6 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -168,6 +168,9 @@ function createMockAgentPort(): jest.Mocked { resolvePolymorphicType: jest.fn().mockResolvedValue(null), executeAction: jest.fn().mockResolvedValue(undefined), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), + getActionForm: jest + .fn() + .mockResolvedValue({ fields: [], canExecute: true, requiredFields: [], skippedFields: [] }), probe: jest.fn().mockResolvedValue(undefined), } as jest.Mocked; }