From c3496f077f7cb37492169cdbc221b442e8eff215 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Tue, 19 May 2026 18:42:31 +0200 Subject: [PATCH 1/3] test(workflow-executor): cover LLM field name variation fallback in findField Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/record-step-executor.ts | 9 +++--- .../update-record-step-executor.test.ts | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/workflow-executor/src/executors/record-step-executor.ts b/packages/workflow-executor/src/executors/record-step-executor.ts index 92e96f7126..1af05b5281 100644 --- a/packages/workflow-executor/src/executors/record-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-step-executor.ts @@ -77,11 +77,10 @@ export default abstract class RecordStepExecutor< } protected findField(schema: CollectionSchema, name: string): FieldSchema | undefined { - // The tool definition sent to the LLM is built from z.literal(displayName) — the JSON - // Schema does constrain fieldName to exact values. However, invokeWithTool returns - // toolCall.args as-is without re-running Zod validation on the response, so the LLM can - // silently ignore the constraint and return a formatting variation (e.g. "first_name" - // instead of "firstname"). The normalized fallback catches these cosmetic mismatches. + // LLMs occasionally return formatting variants of field names (e.g. "first_name" for + // "firstname", "full-name" for "Full Name") even though the tool schema declares them + // as literals. Fall back to a normalized comparison so a cosmetic variation doesn't + // fail an otherwise correct step. const normalizeFieldName = (s: string) => s.toLowerCase().replace(/[\s_-]/g, ''); const normalized = normalizeFieldName(name); 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 9c1866e092..f405df7fa1 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 @@ -499,6 +499,37 @@ describe('UpdateRecordStepExecutor', () => { }); }); + describe('resolveFieldName fuzzy matching', () => { + it.each([ + ['snake_case variant', 'full_name', 'Full Name', 'name'], + ['camelCase variant', 'fullName', 'Full Name', 'name'], + ['lowercase no separator', 'fullname', 'Full Name', 'name'], + ['hyphen variant', 'full-name', 'Full Name', 'name'], + ])( + 'resolves field when LLM returns %s (%s)', + async (_label, aiReturnedName, _displayName, expectedFieldName) => { + const agentPort = makeMockAgentPort(); + const mockModel = makeMockModel({ + input: { fieldName: aiReturnedName, value: 'John Doe', reasoning: 'test' }, + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(agentPort.updateRecord).toHaveBeenCalledWith( + expect.objectContaining({ values: { [expectedFieldName]: 'John Doe' } }), + expect.anything(), + ); + }, + ); + }); + describe('relationship fields excluded from update tool', () => { it('excludes relationship fields from the tool schema', async () => { const mockModel = makeMockModel({ From 065d4b0f9d6dad426e355a84922604cbe097ca79 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 20 May 2026 12:49:18 +0200 Subject: [PATCH 2/3] refactor(workflow-executor): return undefined on ambiguous fuzzy field match in findField Co-Authored-By: Claude Sonnet 4.6 --- .../src/executors/record-step-executor.ts | 14 ++++++++--- .../update-record-step-executor.test.ts | 25 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/packages/workflow-executor/src/executors/record-step-executor.ts b/packages/workflow-executor/src/executors/record-step-executor.ts index 1af05b5281..4430dfacc4 100644 --- a/packages/workflow-executor/src/executors/record-step-executor.ts +++ b/packages/workflow-executor/src/executors/record-step-executor.ts @@ -84,12 +84,18 @@ export default abstract class RecordStepExecutor< const normalizeFieldName = (s: string) => s.toLowerCase().replace(/[\s_-]/g, ''); const normalized = normalizeFieldName(name); - return ( + const exact = schema.fields.find(f => f.displayName === name) ?? - schema.fields.find(f => f.fieldName === name) ?? - schema.fields.find(f => normalizeFieldName(f.displayName) === normalized) ?? - schema.fields.find(f => normalizeFieldName(f.fieldName) === normalized) + schema.fields.find(f => f.fieldName === name); + if (exact) return exact; + + const fuzzy = schema.fields.filter( + f => + normalizeFieldName(f.displayName) === normalized || + normalizeFieldName(f.fieldName) === normalized, ); + + return fuzzy.length === 1 ? fuzzy[0] : undefined; } private async toRecordIdentifier(record: RecordRef): Promise { 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 f405df7fa1..48a4c6d9bd 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 @@ -528,6 +528,31 @@ describe('UpdateRecordStepExecutor', () => { ); }, ); + + it('returns undefined (field not found) when two fields normalize to the same string', async () => { + // { displayName: "Full Name", fieldName: "fullname" } and + // { displayName: "FullName", fieldName: "full_name" } both normalize to "fullname". + // Returning either one would be a silent wrong pick — undefined is safer. + const ambiguousSchema = makeCollectionSchema({ + fields: [ + { fieldName: 'fullname', displayName: 'Full Name', isRelationship: false, type: 'String' }, + { fieldName: 'full_name', displayName: 'FullName', isRelationship: false, type: 'String' }, + ], + }); + const mockModel = makeMockModel({ + input: { fieldName: 'Full-Name', value: 'John', reasoning: 'test' }, + }); + const context = makeContext({ + model: mockModel.model, + workflowPort: makeMockWorkflowPort({ customers: ambiguousSchema }), + stepDefinition: makeStep({ automaticExecution: true }), + }); + const executor = new UpdateRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + }); }); describe('relationship fields excluded from update tool', () => { From e3f8e58d5beceb2b74f1d6546876c714fac11e8f Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Wed, 20 May 2026 12:56:28 +0200 Subject: [PATCH 3/3] style(workflow-executor): fix prettier formatting on ambiguous fuzzy field test Co-Authored-By: Claude Sonnet 4.6 --- .../executors/update-record-step-executor.test.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) 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 48a4c6d9bd..c017607f8d 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 @@ -535,8 +535,18 @@ describe('UpdateRecordStepExecutor', () => { // Returning either one would be a silent wrong pick — undefined is safer. const ambiguousSchema = makeCollectionSchema({ fields: [ - { fieldName: 'fullname', displayName: 'Full Name', isRelationship: false, type: 'String' }, - { fieldName: 'full_name', displayName: 'FullName', isRelationship: false, type: 'String' }, + { + fieldName: 'fullname', + displayName: 'Full Name', + isRelationship: false, + type: 'String', + }, + { + fieldName: 'full_name', + displayName: 'FullName', + isRelationship: false, + type: 'String', + }, ], }); const mockModel = makeMockModel({