From e22e60f281820db1e22d42bf3480489daf022042 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 14:18:44 +0200 Subject: [PATCH 1/3] feat(workflow-executor): ai-assisted action forms, ai pre-fills then human submits (PRD-511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a Trigger Action step's action has a form, AI-assisted mode (AutomatedWithConfirmation) pre-fills the form from the workflow context, then pauses for the user to review/edit/submit natively. The executor does NOT execute in this mode — the front does. - shared AI fill loop (fillFormWithAi): bounded by max iterations + no-progress detection, re-applies values each pass so dynamic-form change hooks reveal dependent fields; strict "leave empty if unsure" prompt; returns values in fill order so the front replays sequentially. Reused by Full AI (PRD-512). - mode branching: formless unchanged; form+Manual pauses with NO prefill; form+FullyAutomated still throws UnsupportedActionFormError (PRD-512); form+AutomatedWithConfirmation fills then pauses. - schema: TriggerAction executionType accepts Manual + drops the .catch that silently coerced manual into AI-assisted (anti-coercion, regression-tested). - pending payload carries the form + ordered AI prefill; confirmation payload gains submittedValues + submissionOutcome (executed or pending-approval); a pending-approval submission is persisted distinctly (no actionResult). fixes PRD-511 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/adapters/server-types.ts | 2 + .../src/executors/agent-with-log.ts | 8 + .../trigger-record-action-step-executor.ts | 202 ++++++++++++++++-- .../src/http/pending-data-validators.ts | 6 + .../src/types/step-execution-data.ts | 36 +++- .../src/types/validated/step-definition.ts | 8 +- .../adapters/step-definition-mapper.test.ts | 14 ++ ...rigger-record-action-step-executor.test.ts | 140 +++++++++++- .../integration/workflow-execution.test.ts | 3 + 9 files changed, 389 insertions(+), 30 deletions(-) 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..bdb2852baf 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,14 @@ 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 data available in the workflow context. + +Important rules: +- Only fill a field when the workflow context gives you the value with confidence. +- If you do not have the necessary context for a field, 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.`; + interface ActionTarget extends ActionRef { selectedRecordRef: RecordRef; } @@ -56,9 +69,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 +91,7 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< name: pendingData.name, }; - return this.saveFrontendResult(target, userConfirmation.actionResult, exec); + return this.saveFrontendResult(target, exec); }, ); } @@ -104,31 +124,158 @@ 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)); + + return { aiFilledValues: ordered.filter(v => finalFieldNames.has(v.field)), 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 messages = [ + this.buildContextMessage(), + ...(await this.buildPreviousStepsMessages()), + new SystemMessage(FILL_FORM_SYSTEM_PROMPT), + new SystemMessage(`Action form fields:\n${fieldLines}`), + new HumanMessage(`**Request**: ${step.prompt ?? 'Fill the action form.'}`), + ]; + + const { values } = await this.invokeWithTool<{ values?: Record }>( + messages, + tool, + ); + + 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 +309,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; } From ce9b9a8bb2394eea4223952943e0a706296956e0 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 16:31:47 +0200 Subject: [PATCH 2/3] fix(workflow-executor): make the AI form-fill treat the request as authoritative (PRD-511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fill system prompt framed the workflow context (record data) as the only source and forbade "inventing amounts", which conflicted with an explicit instruction like "set the price to 35" — that value comes from the request, not the record. The AI oscillated between the requested value and echoing the field's current default. Clarified that an explicit value stated in the request must be applied (not guessing), and that "current" is just a default to override. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../executors/trigger-record-action-step-executor.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 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 bdb2852baf..4dd9d74ea4 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 @@ -28,13 +28,15 @@ 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 data available in the workflow context. +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: -- Only fill a field when the workflow context gives you the value with confidence. -- If you do not have the necessary context for a field, LEAVE IT OUT — never guess or assume. +- 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.`; +- 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; From 8a4cc02943e05b52db17434a67476852fb78cf56 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Sun, 21 Jun 2026 17:04:43 +0200 Subject: [PATCH 3/3] feat(workflow-executor): debug traces for the AI form-fill (PRD-511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Debug-level traces around the AI form-fill so support can diagnose a client's under-/mis-filled form: the context handed to the AI (request, fields, full message list), the raw values the AI returned, and the net values retained after the drop-stale pass (+ canExecute). Off by default — turned on per client with LOG_LEVEL=Debug. Client-side logs only. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../trigger-record-action-step-executor.ts | 40 +++++++++++++++++-- 1 file changed, 37 insertions(+), 3 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 4dd9d74ea4..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 @@ -226,8 +226,17 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< // 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: ordered.filter(v => finalFieldNames.has(v.field)), form }; + return { aiFilledValues, form }; } // One AI fill pass: present the current form fields and ask the AI for values it's confident @@ -262,19 +271,44 @@ export default class TriggerRecordActionStepExecutor extends RecordStepExecutor< func: undefined, }); + const contextMessage = this.buildContextMessage(); + const previousStepsMessages = await this.buildPreviousStepsMessages(); const messages = [ - this.buildContextMessage(), - ...(await this.buildPreviousStepsMessages()), + 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 ?? {}; }