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 @@ -91,6 +91,26 @@
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;
}
Expand Down Expand Up @@ -131,10 +151,17 @@
if (pending) {
return this.handleConfirmationFlow<UpdateRecordStepExecutionData>(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;

Check warning on line 157 in packages/workflow-executor/src/executors/update-record-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion

const target: UpdateTarget = {
selectedRecordRef,
...pendingData!,

Check warning on line 161 in packages/workflow-executor/src/executors/update-record-step-executor.ts

View workflow job for this annotation

GitHub Actions / Linting & Testing (workflow-executor)

Forbidden non-null assertion
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);
Expand All @@ -145,6 +172,19 @@
return this.handleFirstCall();
}

private async coerceOverride(
selectedRecordRef: RecordRef,
pendingData: FieldWithValue | undefined,
value: unknown,
): Promise<unknown> {
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<StepExecutionResult> {
const { stepDefinition: step } = this.context;
const { preRecordedArgs } = step;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading