diff --git a/packages/workflow-executor/src/executors/update-record-step-executor.ts b/packages/workflow-executor/src/executors/update-record-step-executor.ts index 56d3fb7c07..4fc70ecdcd 100644 --- a/packages/workflow-executor/src/executors/update-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/update-record-step-executor.ts @@ -91,6 +91,26 @@ function buildZodSchemaForField(field: FieldSchema): z.ZodTypeAny { return buildZodSchemaForPrimitive(type as string, enumValues); } +// Coerce a user-overridden value to the field's native type before updating the record. +// The HTTP schema accepts `unknown`, so the override may be a boolean or an array; this turns +// it into the type the datasource expects, and throws a StepStateError on mismatch. +function coerceFieldValue(fieldSchema: FieldSchema | undefined, value: unknown): unknown { + // No coercible primitive schema (field not found or relationship) → leave it as-is. + if (!fieldSchema || fieldSchema.type == null || value === null) return value; + + const parsed = buildZodSchemaForField(fieldSchema).safeParse(value); + + if (!parsed.success) { + throw new StepStateError( + `Invalid value for field "${fieldSchema.displayName}": ${parsed.error.issues + .map(issue => issue.message) + .join(', ')}`, + ); + } + + return parsed.data; +} + interface UpdateTarget extends FieldWithValue { selectedRecordRef: RecordRef; } @@ -131,10 +151,17 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor(pending, async exec => { const { selectedRecordRef, pendingData, userConfirmation } = exec; + // A user override of `null` (clearing the field) must win over the AI suggestion, so + // distinguish "no override" (undefined) from "override to null". + const rawValue = + userConfirmation?.value !== undefined ? userConfirmation.value : pendingData!.value; + const target: UpdateTarget = { selectedRecordRef, ...pendingData!, - value: userConfirmation?.value ?? pendingData!.value, + // The value comes from an `unknown` HTTP value (may be a boolean or array), so coerce + // it to the field's native type before updating. Idempotent on already-typed values. + value: await this.coerceOverride(selectedRecordRef, pendingData, rawValue), }; return this.resolveAndUpdate(target, exec); @@ -145,6 +172,19 @@ export default class UpdateRecordStepExecutor extends RecordStepExecutor { + const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); + const fieldSchema = + this.findField(schema, pendingData?.name ?? '') ?? + this.findField(schema, pendingData?.displayName ?? ''); + + return coerceFieldValue(fieldSchema, value); + } + private async handleFirstCall(): Promise { const { stepDefinition: step } = this.context; const { preRecordedArgs } = step; diff --git a/packages/workflow-executor/src/http/pending-data-validators.ts b/packages/workflow-executor/src/http/pending-data-validators.ts index a00d401e83..d739e1b40d 100644 --- a/packages/workflow-executor/src/http/pending-data-validators.ts +++ b/packages/workflow-executor/src/http/pending-data-validators.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; const updateRecordPatchSchema = z .object({ userConfirmed: z.boolean(), - value: z.union([z.string(), z.number()]).optional(), // user may override the AI-proposed value + value: z.unknown().optional(), }) .strict(); diff --git a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts index 58ae08a386..993f68bb63 100644 --- a/packages/workflow-executor/test/executors/update-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/update-record-step-executor.test.ts @@ -1602,4 +1602,249 @@ describe('UpdateRecordStepExecutor', () => { }); }); }); + + describe('confirmation value coercion (Branch A)', () => { + // Build a confirmation execution targeting `field` with a given pendingData/userConfirmation value. + function makeCoercionContext( + field: CollectionSchema['fields'][number], + pendingValue: unknown, + userConfirmation: { userConfirmed: boolean; value?: unknown }, + agentPort = makeMockAgentPort({ [field.fieldName]: pendingValue }), + ) { + const schema = makeCollectionSchema({ fields: [field] }); + const execution: UpdateRecordStepExecutionData = { + type: 'update-record', + stepIndex: 0, + pendingData: { displayName: field.displayName, name: field.fieldName, value: pendingValue }, + userConfirmation, + selectedRecordRef: makeRecordRef(), + }; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: schema }), + }); + + return { executor: new UpdateRecordStepExecutor(context), agentPort }; + } + + const booleanField = { + fieldName: 'isActive', + displayName: 'Is Active', + isRelationship: false, + type: 'Boolean' as const, + }; + const numberArrayField = { + fieldName: 'scores', + displayName: 'Scores', + isRelationship: false, + type: ['Number'] as ['Number'], + }; + const stringArrayField = { + fieldName: 'tags', + displayName: 'Tags', + isRelationship: false, + type: ['String'] as ['String'], + }; + const enumArrayField = { + fieldName: 'colors', + displayName: 'Colors', + isRelationship: false, + type: ['Enum'] as ['Enum'], + enumValues: ['red', 'green', 'blue'], + }; + + it('coerces a native boolean user override', async () => { + const { executor, agentPort } = makeCoercionContext(booleanField, null, { + userConfirmed: true, + value: true, + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { isActive: true } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('coerces "true"/"false" string overrides to booleans', async () => { + const t = makeCoercionContext(booleanField, null, { userConfirmed: true, value: 'true' }); + await t.executor.execute(); + expect(t.agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { isActive: true } }, + expect.objectContaining({ id: 1 }), + ); + + const f = makeCoercionContext(booleanField, null, { userConfirmed: true, value: 'false' }); + await f.executor.execute(); + expect(f.agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { isActive: false } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('coerces a [Number] array of numeric strings to numbers', async () => { + const { executor, agentPort } = makeCoercionContext(numberArrayField, null, { + userConfirmed: true, + value: ['1', '2'], + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { scores: [1, 2] } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('keeps a native [Number] array unchanged', async () => { + const { executor, agentPort } = makeCoercionContext(numberArrayField, null, { + userConfirmed: true, + value: [1, 2], + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { scores: [1, 2] } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('keeps a [String] array unchanged', async () => { + const { executor, agentPort } = makeCoercionContext(stringArrayField, null, { + userConfirmed: true, + value: ['a', 'b'], + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { tags: ['a', 'b'] } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('accepts a valid [Enum] array', async () => { + const { executor, agentPort } = makeCoercionContext(enumArrayField, null, { + userConfirmed: true, + value: ['red', 'blue'], + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { colors: ['red', 'blue'] } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('coerces a numeric string to a number (non-regression)', async () => { + const numberField = { + fieldName: 'age', + displayName: 'Age', + isRelationship: false, + type: 'Number' as const, + }; + const { executor, agentPort } = makeCoercionContext(numberField, null, { + userConfirmed: true, + value: '42', + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { age: 42 } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('leaves an ISO datetime string unchanged (non-regression)', async () => { + const dateField = { + fieldName: 'createdAt', + displayName: 'Created At', + isRelationship: false, + type: 'Date' as const, + }; + const { executor, agentPort } = makeCoercionContext(dateField, null, { + userConfirmed: true, + value: '2026-05-28T00:00:00.000Z', + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { createdAt: '2026-05-28T00:00:00.000Z' } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('fails with a StepStateError on coercion mismatch without updating', async () => { + const { executor, agentPort } = makeCoercionContext(numberArrayField, null, { + userConfirmed: true, + value: ['abc'], + }); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + expect(agentPort.updateRecord).not.toHaveBeenCalled(); + }); + + it('coerces the AI/preRecordedArg value when the user confirms without overriding', async () => { + // pendingData.value is a raw "true" string (e.g. from preRecordedArgs); no user override. + const { executor, agentPort } = makeCoercionContext(booleanField, 'true', { + userConfirmed: true, + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { isActive: true } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('passes a null override through without coercion (field clear)', async () => { + const { executor, agentPort } = makeCoercionContext(booleanField, true, { + userConfirmed: true, + value: null, + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { isActive: null } }, + expect.objectContaining({ id: 1 }), + ); + }); + + it('passes the value through unchanged when the field type is null (relationship)', async () => { + const nullTypeField = { + fieldName: 'orders', + displayName: 'Orders', + isRelationship: true, + type: null, + }; + const opaque = { some: 'object' }; + const { executor, agentPort } = makeCoercionContext(nullTypeField, null, { + userConfirmed: true, + value: opaque, + }); + + await executor.execute(); + + expect(agentPort.updateRecord).toHaveBeenCalledWith( + { collection: 'customers', id: [42], values: { orders: opaque } }, + expect.objectContaining({ id: 1 }), + ); + }); + }); }); diff --git a/packages/workflow-executor/test/http/pending-data-validators.test.ts b/packages/workflow-executor/test/http/pending-data-validators.test.ts index a461eab196..72738d04c7 100644 --- a/packages/workflow-executor/test/http/pending-data-validators.test.ts +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -58,16 +58,34 @@ describe('patchBodySchemas', () => { }); }); - it('rejects boolean value', () => { - expect(() => schema.parse({ userConfirmed: true, value: true })).toThrow(); + // value is now z.unknown(): the HTTP schema no longer judges the business type. + // The update-record executor coerces/validates it field-aware (buildZodSchemaForField). + it('accepts boolean value (coerced field-aware downstream)', () => { + expect(schema.parse({ userConfirmed: true, value: true })).toEqual({ + userConfirmed: true, + value: true, + }); + }); + + it('accepts array value (coerced field-aware downstream)', () => { + expect(schema.parse({ userConfirmed: true, value: [1, 2] })).toEqual({ + userConfirmed: true, + value: [1, 2], + }); }); - it('rejects null value', () => { - expect(() => schema.parse({ userConfirmed: true, value: null })).toThrow(); + it('accepts null value', () => { + expect(schema.parse({ userConfirmed: true, value: null })).toEqual({ + userConfirmed: true, + value: null, + }); }); - it('rejects object value', () => { - expect(() => schema.parse({ userConfirmed: true, value: { foo: 'bar' } })).toThrow(); + it('accepts object value (coerced field-aware downstream)', () => { + expect(schema.parse({ userConfirmed: true, value: { foo: 'bar' } })).toEqual({ + userConfirmed: true, + value: { foo: 'bar' }, + }); }); it('rejects missing userConfirmed', () => {