From 7d8207adb635fe624daf9fdda13b98d854ef5c48 Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 5 Jun 2026 11:57:42 +0200 Subject: [PATCH 1/5] fix(workflow): load-related chooses the relation by its target collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A load-related step picked the source record first, by "which available record's collection best matches the request", then the relation on it. With records accumulated from prior load-related steps, "load the dvd X" latched onto an already-loaded dvd (collection matches) instead of the store that has the dvds relation — so it followed dvd→store and re-loaded a store. Fuse the two choices into one: gather every (record, relation) pair across available records and pick by what the relation LEADS TO. "Load the dvd X" now follows store→dvds regardless of accumulated dvds. preRecordedArgs (selectedRecordStepIndex / relationDisplayName) still pin source/relation; single candidate skips the AI call. The shared select-record heuristic is untouched for read/update/trigger (acting on an existing record of that type). NOTE: load-related unit tests still mock the old select-relation flow — to be migrated in a follow-up commit. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../load-related-record-step-executor.ts | 161 +++++++++++++----- 1 file changed, 115 insertions(+), 46 deletions(-) diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 9fce505981..4a86d06b29 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -4,7 +4,12 @@ import type { LoadRelatedRecordStepExecutionData, RelationRef, } from '../types/step-execution-data'; -import type { CollectionSchema, RecordData, RecordRef } from '../types/validated/collection'; +import type { + CollectionSchema, + FieldSchema, + RecordData, + RecordRef, +} from '../types/validated/collection'; import type { LoadRelatedRecordStepDefinition } from '../types/validated/step-definition'; import { DynamicStructuredTool, HumanMessage, SystemMessage } from '@forestadmin/ai-proxy'; @@ -22,10 +27,12 @@ import RecordStepExecutor from './record-step-executor'; import { StepExecutionMode } from '../types/validated/step-definition'; const SELECT_RELATION_SYSTEM_PROMPT = `You are an AI agent loading a related record based on a user request. -Select the relation to follow. +You are given relations to follow, each shown as " (→ )". +Choose the relation that LEADS TO the collection the user wants to load — decide from each +relation's target collection, NOT from which source record happens to resemble the request. Important rules: -- Be precise: only select the relation directly relevant to the request. +- Pick the relation whose target collection matches the requested record. - 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.`; @@ -54,6 +61,13 @@ interface RelationTarget extends RelationRef { relatedCollectionName: string; } +// A relationship reachable from one available record — the unit the AI chooses among. +interface RelationCandidate { + record: RecordRef; + schema: CollectionSchema; + field: FieldSchema; +} + export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore @@ -105,18 +119,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { stepDefinition: step } = this.context; - const { preRecordedArgs } = step; - const records = await this.getAvailableRecordRefs(); - const selectedRecordRef = await this.resolveRecordRef( - records, - step.prompt, - preRecordedArgs?.selectedRecordStepIndex, - ); - const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); - const args = preRecordedArgs?.relationDisplayName - ? { relationName: preRecordedArgs.relationDisplayName } - : await this.selectRelation(schema, step.prompt); - const target = this.buildTarget(schema, args.relationName, selectedRecordRef); + const target = await this.resolveTarget(); // Branch B -- fully automated execution if (step.executionType === StepExecutionMode.FullyAutomated) { @@ -124,7 +127,67 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { + const { preRecordedArgs } = this.context.stepDefinition; + const records = await this.getAvailableRecordRefs(); + + const sourceRecords = + preRecordedArgs?.selectedRecordStepIndex !== undefined + ? [this.requireRecordAtStepIndex(records, preRecordedArgs.selectedRecordStepIndex)] + : records; + + const candidates = await this.buildRelationCandidates(sourceRecords); + const eligible = preRecordedArgs?.relationDisplayName + ? candidates.filter(c => this.matchesRelation(c.field, preRecordedArgs.relationDisplayName)) + : candidates; + + if (eligible.length === 0) { + throw new NoRelationshipFieldsError(sourceRecords[0]?.collectionName ?? 'unknown'); + } + + const chosen = + eligible.length === 1 ? eligible[0] : await this.selectRelationToFollow(eligible); + + return this.buildTarget(chosen.schema, chosen.field.displayName, chosen.record); + } + + private requireRecordAtStepIndex(records: RecordRef[], stepIndex: number): RecordRef { + const match = records.find(r => r.stepIndex === stepIndex); + + if (!match) { + throw new InvalidPreRecordedArgsError(`No record found at step index ${stepIndex}`); + } + + return match; + } + + private async buildRelationCandidates(records: RecordRef[]): Promise { + const candidates: RelationCandidate[] = []; + + for (const record of records) { + // eslint-disable-next-line no-await-in-loop + const schema = await this.getCollectionSchema(record.collectionName); + + for (const field of schema.fields) { + if (field.isRelationship && field.relatedCollectionName) { + candidates.push({ record, schema, field }); + } + } + } + + return candidates; + } + + private matchesRelation(field: FieldSchema, relationDisplayName: string): boolean { + return field.displayName === relationDisplayName || field.fieldName === relationDisplayName; } private buildTarget( @@ -449,47 +512,53 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { - const tool = this.buildSelectRelationTool(schema); + private relationOptionLabel(candidate: RelationCandidate): string { + const { record, schema, field } = candidate; + + return `Step ${record.stepIndex} - ${schema.collectionDisplayName} #${record.recordId} → ${field.displayName} (→ ${field.relatedCollectionName})`; + } + + private async selectRelationToFollow( + candidates: RelationCandidate[], + ): Promise { + const labels = candidates.map(c => this.relationOptionLabel(c)); + const labelTuple = labels as [string, ...string[]]; + + const tool = new DynamicStructuredTool({ + name: 'select-relation-to-follow', + description: 'Select the relation to follow to load the requested related record.', + schema: z.object({ + relation: z + .enum(labelTuple) + .describe('The relation to follow, chosen by the collection it leads to'), + reasoning: z.string().describe('Why this relation leads to the requested record'), + }), + func: undefined, + }); + const messages = [ this.buildContextMessage(), ...(await this.buildPreviousStepsMessages()), new SystemMessage(SELECT_RELATION_SYSTEM_PROMPT), - new SystemMessage( - `The selected record belongs to the "${schema.collectionDisplayName}" collection.`, + new HumanMessage( + `**Request**: ${this.context.stepDefinition.prompt ?? 'Load the relevant related record.'}`, ), - new HumanMessage(`**Request**: ${prompt ?? 'Load the relevant related record.'}`), ]; - return this.invokeWithTool<{ relationName: string; reasoning: string }>(messages, tool); - } + const { relation } = await this.invokeWithTool<{ relation: string; reasoning: string }>( + messages, + tool, + ); - private buildSelectRelationTool(schema: CollectionSchema): DynamicStructuredTool { - const relationFields = schema.fields.filter(f => f.isRelationship); + const index = labels.indexOf(relation); - if (relationFields.length === 0) { - throw new NoRelationshipFieldsError(schema.collectionName); + if (index === -1) { + throw new InvalidAIResponseError( + `AI selected relation "${relation}" which does not match any available option`, + ); } - const displayNames = relationFields.map(f => f.displayName) as [string, ...string[]]; - const technicalNames = relationFields - .map(f => `${f.displayName} (technical name: ${f.fieldName})`) - .join(', '); - - return new DynamicStructuredTool({ - name: 'select-relation', - description: 'Select the relation to follow from the record.', - schema: z.object({ - relationName: z - .enum(displayNames) - .describe(`The name of the relation to follow. Available: ${technicalNames}`), - reasoning: z.string().describe('Why this relation was chosen'), - }), - func: undefined, - }); + return candidates[index]; } /** AI call 1 for HasMany: selects the most relevant fields to compare candidates. */ From 98df52af087212c2f7213fd5567d83fee770c6de Mon Sep 17 00:00:00 2001 From: alban bertolini Date: Fri, 5 Jun 2026 12:20:23 +0200 Subject: [PATCH 2/5] feat(workflow): thread the step title through to the AI context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The orchestrator sends a `title` on every step (ServerWorkflowStepBase.title) but the mapper dropped it. Carry it into the domain StepDefinition and inject it into buildContextMessage, so every AI call sees the step's human intent — which matters when the prompt is weak or empty (e.g. a load-related step whose prompt arrives as "Load " while the title says "Load the store"). - add optional `title` to shared StepDefinition fields - map `task.title` / `condition.title` in step-definition-mapper - buildContextMessage appends `Step title: ""` when present Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../src/adapters/step-definition-mapper.ts | 3 ++- .../src/executors/base-step-executor.ts | 19 +++++++++++-------- .../src/types/validated/step-definition.ts | 1 + .../run-to-available-step-mapper.test.ts | 2 ++ .../adapters/step-definition-mapper.test.ts | 8 ++++++++ 5 files changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/workflow-executor/src/adapters/step-definition-mapper.ts b/packages/workflow-executor/src/adapters/step-definition-mapper.ts index f1723ce26d..f343a390f2 100644 --- a/packages/workflow-executor/src/adapters/step-definition-mapper.ts +++ b/packages/workflow-executor/src/adapters/step-definition-mapper.ts @@ -21,7 +21,7 @@ import { function mapTask(task: ServerWorkflowTask): StepDefinition { // executionType is passed through as-is; each schema's .default().catch() handles // missing or unsupported values without requiring an explicit mapping here. - const base = { prompt: task.prompt, executionType: task.executionType }; + const base = { prompt: task.prompt, executionType: task.executionType, title: task.title }; switch (task.taskType) { case ServerTaskTypeEnum.McpServer: @@ -65,6 +65,7 @@ function mapCondition(condition: ServerWorkflowCondition): ConditionStepDefiniti type: StepType.Condition, prompt: condition.prompt, executionType: condition.executionType, + title: condition.title, options, }); } diff --git a/packages/workflow-executor/src/executors/base-step-executor.ts b/packages/workflow-executor/src/executors/base-step-executor.ts index 3cc0f521e8..2d8ff35901 100644 --- a/packages/workflow-executor/src/executors/base-step-executor.ts +++ b/packages/workflow-executor/src/executors/base-step-executor.ts @@ -241,16 +241,19 @@ export default abstract class BaseStepExecutor<TStep extends StepDefinition = St } protected buildContextMessage(): SystemMessage { - const { user } = this.context; + const { user, stepDefinition } = this.context; const now = new Date(); - return new SystemMessage( - [ - `Step executed by: ${user.firstName} ${user.lastName} (${user.email}, id: ${user.id})`, - `Role: ${user.role} | Team: ${user.team}`, - `Current date and time: ${now.toISOString()} (UTC)`, - ].join('\n'), - ); + const lines = [ + `Step executed by: ${user.firstName} ${user.lastName} (${user.email}, id: ${user.id})`, + `Role: ${user.role} | Team: ${user.team}`, + `Current date and time: ${now.toISOString()} (UTC)`, + ]; + + // The step title carries the designer's intent — useful when the prompt is weak or empty. + if (stepDefinition.title) lines.push(`Step title: "${stepDefinition.title}"`); + + return new SystemMessage(lines.join('\n')); } protected async buildPreviousStepsMessages(): Promise<SystemMessage[]> { diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 6c2916cb14..43d0bcee63 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -27,6 +27,7 @@ export enum StepExecutionMode { const sharedFields = { prompt: z.string().optional(), aiConfigName: z.string().optional(), + title: z.string().optional(), }; // Use z.enum(EnumObject), not z.nativeEnum — the latter is deprecated in zod 4. diff --git a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts index 8919712a43..444d6f8415 100644 --- a/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/run-to-available-step-mapper.test.ts @@ -118,6 +118,7 @@ describe('toAvailableStepExecution', () => { stepDefinition: { type: StepType.ReadRecord, prompt: 'prompt', + title: 'Task', executionType: ServerStepExecutionTypeEnum.FullyAutomated, }, previousSteps: [], @@ -189,6 +190,7 @@ describe('toAvailableStepExecution', () => { expect(result?.stepDefinition).toEqual({ type: StepType.Guidance, prompt: 'follow the guide', + title: 'guidance', executionType: ServerStepExecutionTypeEnum.Manual, }); }); diff --git a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts index 594a8b8d07..ae5541702b 100644 --- a/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts +++ b/packages/workflow-executor/test/adapters/step-definition-mapper.test.ts @@ -60,6 +60,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(task)).toEqual({ type: StepType.ReadRecord, prompt: 'read it', + title: 'Test task', executionType: ServerStepExecutionTypeEnum.FullyAutomated, }); }); @@ -70,6 +71,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(task)).toEqual({ type: StepType.UpdateRecord, prompt: 'update it', + title: 'Test task', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); @@ -80,6 +82,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(task)).toEqual({ type: StepType.TriggerAction, prompt: 'trigger it', + title: 'Test task', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); @@ -93,6 +96,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(task)).toEqual({ type: StepType.LoadRelatedRecord, prompt: 'load it', + title: 'Test task', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); @@ -108,6 +112,7 @@ describe('toStepDefinition', () => { type: StepType.Mcp, prompt: 'run mcp', mcpServerId: 'mcp-abc', + title: 'Test task', executionType: ServerStepExecutionTypeEnum.AutomatedWithConfirmation, }); }); @@ -128,6 +133,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(task)).toEqual({ type: StepType.Guidance, prompt: 'guide them', + title: 'Test task', executionType: StepExecutionMode.Manual, }); }); @@ -196,6 +202,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(condition)).toEqual({ type: StepType.Condition, prompt: 'Choose one', + title: 'Test condition', options: ['Yes', 'No'], executionType: StepExecutionMode.FullyAutomated, }); @@ -210,6 +217,7 @@ describe('toStepDefinition', () => { expect(toStepDefinition(condition)).toEqual({ type: StepType.Condition, prompt: 'Choose one', + title: 'Test condition', options: ['Approve', 'Reject'], executionType: StepExecutionMode.FullyAutomated, }); From e2435b3ec74e7e4d054443f1e372cdfb14e8d520 Mon Sep 17 00:00:00 2001 From: alban bertolini <albanb@forestadmin.com> Date: Fri, 5 Jun 2026 12:38:19 +0200 Subject: [PATCH 3/5] test(workflow): migrate load-related tests to combined relation selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapt the suite to the single `select-relation-to-follow` AI call (was `select-record` + `select-relation`): mocks return `{ relation: <label> }`, single-candidate schemas make no AI call, bindTools counts/names updated. Adds a repro test: with account + a loaded store + a loaded dvd available, "Load the dvd titanic" follows store→dvds (not a relation off the dvd). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../load-related-record-step-executor.test.ts | 639 +++++++++++++----- 1 file changed, 461 insertions(+), 178 deletions(-) diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index 66ed147ee8..c014cba268 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -33,6 +33,23 @@ function cand( return { recordId, referenceFieldValue }; } +// Builds the exact label the executor's `select-relation-to-follow` tool offers for a +// (record, relation) candidate — mirrors LoadRelatedRecordStepExecutor#relationOptionLabel: +// `Step ${stepIndex} - ${collectionDisplayName} #${recordId} → ${relationDisplayName} (→ ${relatedCollectionName})` +// recordId is an array and is interpolated with the same Array#toString as the executor. +function relationOption(o: { + stepIndex?: number; + collectionDisplayName?: string; + recordId: Array<string | number>; + relationDisplayName: string; + relatedCollectionName: string; +}): string { + const stepIndex = o.stepIndex ?? 0; + const collectionDisplayName = o.collectionDisplayName ?? 'Customers'; + + return `Step ${stepIndex} - ${collectionDisplayName} #${o.recordId} → ${o.relationDisplayName} (→ ${o.relatedCollectionName})`; +} + function makeRecordRef(overrides: Partial<RecordRef> = {}): RecordRef { return { collectionName: 'customers', @@ -128,7 +145,10 @@ function makeMockWorkflowPort( }; } -function makeMockModel(toolCallArgs?: Record<string, unknown>, toolName = 'select-relation') { +function makeMockModel( + toolCallArgs?: Record<string, unknown>, + toolName = 'select-relation-to-follow', +) { const invoke = jest.fn().mockResolvedValue({ tool_calls: toolCallArgs ? [{ name: toolName, args: toolCallArgs, id: 'call_1' }] : undefined, }); @@ -148,7 +168,14 @@ function makeContext( collectionId: 'col-1', baseRecordRef: makeRecordRef(), stepDefinition: makeStep(), - model: makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }).model, + model: makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'User requested order', + }).model, agentPort: makeMockAgentPort(), workflowPort: makeMockWorkflowPort(), runStore: makeMockRunStore(), @@ -201,7 +228,14 @@ describe('LoadRelatedRecordStepExecutor', () => { describe('executionType=FullyAutomated: BelongsTo — load direct (Branch B)', () => { it('fetches 1 related record and returns success', async () => { const agentPort = makeMockAgentPort(); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'User requested order', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, @@ -277,19 +311,10 @@ describe('LoadRelatedRecordStepExecutor', () => { ], }); - // Call 1: select-relation → Address; Call 2: select-fields → ['City'] (displayName); - // Call 3: select-record-by-content → index 1 (Lyon) + // Source has a single relation (Address) → auto-picked, no select-relation-to-follow call. + // Call 1: select-fields → ['City'] (displayName); Call 2: select-record-by-content → index 1 (Lyon) const invoke = jest .fn() - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load addresses' }, - id: 'c1', - }, - ], - }) .mockResolvedValueOnce({ tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], }) @@ -321,10 +346,9 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(bindTools).toHaveBeenCalledTimes(3); - expect(bindTools.mock.calls[0][0][0].name).toBe('select-relation'); - expect(bindTools.mock.calls[1][0][0].name).toBe('select-fields'); - expect(bindTools.mock.calls[2][0][0].name).toBe('select-record-by-content'); + expect(bindTools).toHaveBeenCalledTimes(2); + expect(bindTools.mock.calls[0][0][0].name).toBe('select-fields'); + expect(bindTools.mock.calls[1][0][0].name).toBe('select-record-by-content'); // Fetches 50 candidates (HasMany) expect(agentPort.getRelatedData).toHaveBeenCalledWith( @@ -371,27 +395,17 @@ describe('LoadRelatedRecordStepExecutor', () => { fields: [], }); - // Call 1: select-relation; Call 2: select-record-by-content (no select-fields) - const invoke = jest - .fn() - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load addresses' }, - id: 'c1', - }, - ], - }) - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-record-by-content', - args: { recordIndex: 0, reasoning: 'First is best' }, - id: 'c2', - }, - ], - }); + // Source has a single relation (Address) → auto-picked, no select-relation-to-follow call. + // Call 1: select-record-by-content (no select-fields — related collection has no fields). + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'First is best' }, + id: 'c2', + }, + ], + }); const bindTools = jest.fn().mockReturnValue({ invoke }); const model = { bindTools } as unknown as ExecutionContext['model']; @@ -406,9 +420,8 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(bindTools).toHaveBeenCalledTimes(2); - expect(bindTools.mock.calls[0][0][0].name).toBe('select-relation'); - expect(bindTools.mock.calls[1][0][0].name).toBe('select-record-by-content'); + expect(bindTools).toHaveBeenCalledTimes(1); + expect(bindTools.mock.calls[0][0][0].name).toBe('select-record-by-content'); }); it('takes the single candidate directly without AI record-selection calls', async () => { @@ -427,15 +440,7 @@ describe('LoadRelatedRecordStepExecutor', () => { { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, ]); - const invoke = jest.fn().mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load address' }, - id: 'c1', - }, - ], - }); + const invoke = jest.fn(); const bindTools = jest.fn().mockReturnValue({ invoke }); const model = { bindTools } as unknown as ExecutionContext['model']; @@ -450,8 +455,9 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - // Only select-relation was called — no field/record AI calls for single candidate - expect(bindTools).toHaveBeenCalledTimes(1); + // Single relation (auto-picked, no select-relation-to-follow) AND single related + // candidate (no field/record AI calls) → no AI calls at all. + expect(bindTools).not.toHaveBeenCalled(); }); it('returns error outcome when AI selects an out-of-range record index', async () => { @@ -477,18 +483,9 @@ describe('LoadRelatedRecordStepExecutor', () => { fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], }); - // Call 1: select-relation; Call 2: select-fields; Call 3: out-of-range index 999 + // Single relation (auto-picked). Call 1: select-fields; Call 2: out-of-range index 999 const invoke = jest .fn() - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load addresses' }, - id: 'c1', - }, - ], - }) .mockResolvedValueOnce({ tool_calls: [{ name: 'select-fields', args: { fieldNames: ['city'] }, id: 'c2' }], }) @@ -546,21 +543,10 @@ describe('LoadRelatedRecordStepExecutor', () => { fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], }); - // Call 1: select-relation; Call 2: select-fields returns empty array (AI violation) - const invoke = jest - .fn() - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load addresses' }, - id: 'c1', - }, - ], - }) - .mockResolvedValueOnce({ - tool_calls: [{ name: 'select-fields', args: { fieldNames: [] }, id: 'c2' }], - }); + // Single relation (auto-picked). Call 1: select-fields returns empty array (AI violation) + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: [] }, id: 'c2' }], + }); const bindTools = jest.fn().mockReturnValue({ invoke }); const model = { bindTools } as unknown as ExecutionContext['model']; @@ -733,7 +719,14 @@ describe('LoadRelatedRecordStepExecutor', () => { markSucceeded: jest.fn().mockResolvedValue(undefined), markFailed: jest.fn().mockResolvedValue(undefined), }; - const { model } = makeMockModel({ relationName: 'Order', reasoning: 'r' }); + const { model } = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'r', + }); const context = makeContext({ model, runStore, @@ -759,7 +752,14 @@ describe('LoadRelatedRecordStepExecutor', () => { markSucceeded: jest.fn().mockResolvedValue(undefined), markFailed: jest.fn().mockResolvedValue(undefined), }; - const { model } = makeMockModel({ relationName: 'Order', reasoning: 'r' }); + const { model } = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'r', + }); const context = makeContext({ model, runStore, activityLogPort }); const result = await new LoadRelatedRecordStepExecutor(context).execute(); @@ -780,7 +780,14 @@ describe('LoadRelatedRecordStepExecutor', () => { describe('without executionType=FullyAutomated: awaiting-input (Branch C)', () => { it('saves AI suggestion in pendingData and returns awaiting-input (single record — no field/record AI calls)', async () => { const agentPort = makeMockAgentPort(); // returns 1 record: orders #99 - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'User requested order' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'User requested order', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -799,7 +806,8 @@ describe('LoadRelatedRecordStepExecutor', () => { expect.objectContaining({ id: 1 }), ); expect(agentPort.getRelatedData).not.toHaveBeenCalled(); - // xToOne has exactly one candidate → only select-relation AI call (no field/record selection) + // 2 relations on the source → one select-relation-to-follow AI call; xToOne target then + // yields a single candidate, so no field/record-selection AI calls. expect(mockModel.bindTools).toHaveBeenCalledTimes(1); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -843,8 +851,15 @@ describe('LoadRelatedRecordStepExecutor', () => { .mockResolvedValueOnce({ tool_calls: [ { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load address' }, + name: 'select-relation-to-follow', + args: { + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'Load address', + }, id: 'c1', }, ], @@ -916,8 +931,15 @@ describe('LoadRelatedRecordStepExecutor', () => { .mockResolvedValueOnce({ tool_calls: [ { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load address' }, + name: 'select-relation-to-follow', + args: { + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'Load address', + }, id: 'c1', }, ], @@ -949,7 +971,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('awaiting-input'); - // select-relation + select-record-by-content (no select-fields) + // select-relation-to-follow + select-record-by-content (no select-fields) expect(bindTools).toHaveBeenCalledTimes(2); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -966,7 +988,14 @@ describe('LoadRelatedRecordStepExecutor', () => { // candidate list (no suggestedRecord) — the user can switch relation. It is NOT an error. it('returns awaiting-input with an empty candidate list when the xToOne relation has no linked record', async () => { const agentPort = makeMockAgentPort([]); // getSingleRelatedData → null - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'Load order' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'Load order', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1662,8 +1691,15 @@ describe('LoadRelatedRecordStepExecutor', () => { .mockResolvedValueOnce({ tool_calls: [ { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'Load address' }, + name: 'select-relation-to-follow', + args: { + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'Load address', + }, id: 'c1', }, ], @@ -1764,7 +1800,14 @@ describe('LoadRelatedRecordStepExecutor', () => { recordId: ['99'], values: { id: '99', reference: 'ORD-2026-001' }, }); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'Load order' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'Load order', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, @@ -1800,7 +1843,14 @@ describe('LoadRelatedRecordStepExecutor', () => { // Default makeCollectionSchema doesn't set referenceField → executor omits `fields` // when calling getSingleRelatedData and writes null on every candidate. const agentPort = makeMockAgentPort(); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'Load order' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'Load order', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1834,15 +1884,22 @@ describe('LoadRelatedRecordStepExecutor', () => { })), }); - // Answers the 3 HasMany AI calls in order: select-relation, select-fields, select-record. + // Answers the 3 HasMany AI calls in order: select-relation-to-follow, select-fields, select-record. const buildModel = (selectedFieldDisplayNames: string[], recordIndex = 0) => { const invoke = jest .fn() .mockResolvedValueOnce({ tool_calls: [ { - name: 'select-relation', - args: { relationName: 'Address', reasoning: 'r' }, + name: 'select-relation-to-follow', + args: { + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'r', + }, id: 'c1', }, ], @@ -2099,10 +2156,11 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('StepStateError on malformed schema', () => { - // A relationship field with no relatedCollectionName can't be followed — throw rather than - // silently falling back to the field name (which would 404 later as a bogus collection). - it('returns error when the selected relation has no relatedCollectionName', async () => { + describe('malformed schema — relation without relatedCollectionName', () => { + // A relationship field with no relatedCollectionName can't be followed, so it is excluded + // from the candidate set. With it being the only relationship field, there are zero + // candidates → NoRelationshipFieldsError rather than a silent fallback to the field name. + it('excludes the relation and returns the no-relations error', async () => { const schema = makeCollectionSchema({ fields: [ { @@ -2113,7 +2171,14 @@ describe('LoadRelatedRecordStepExecutor', () => { }, ], }); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'test', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, @@ -2127,7 +2192,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - 'An unexpected error occurred while processing this step.', + 'This record type has no relations configured in Forest Admin.', ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); @@ -2136,7 +2201,14 @@ describe('LoadRelatedRecordStepExecutor', () => { describe('RelatedRecordNotFoundError', () => { it('returns error when BelongsTo getRelatedData returns empty array (Branch B)', async () => { const agentPort = makeMockAgentPort([]); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'test', + }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, @@ -2239,28 +2311,25 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('resolveRelationName failure', () => { - it('returns error when AI returns a relation name not found in the schema', async () => { + describe('select-relation-to-follow failure', () => { + // With >=2 candidates the AI must echo back one of the offered labels. A label that + // matches no option (here a fabricated relation) is rejected as an invalid AI response. + it('returns error when AI selects a relation label not among the offered options', async () => { const agentPort = makeMockAgentPort(); - const mockModel = makeMockModel({ relationName: 'NonExistentRelation', reasoning: 'test' }); - const schema = makeCollectionSchema({ - fields: [ - { - fieldName: 'order', - displayName: 'Order', - isRelationship: true, - relationType: 'BelongsTo', - relatedCollectionName: 'orders', - }, - ], + // Default schema has two relations (Order, Address) → select-relation-to-follow IS invoked. + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'NonExistentRelation', + relatedCollectionName: 'ghosts', + }), + reasoning: 'test', }); - const workflowPort = makeMockWorkflowPort({ customers: schema }); const runStore = makeMockRunStore(); const context = makeContext({ model: mockModel.model, agentPort, runStore, - workflowPort, stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -2269,7 +2338,7 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('error'); expect(result.stepOutcome.error).toBe( - "The AI selected a relation that doesn't exist on this record. Try rephrasing the step's prompt.", + "The AI made an unexpected choice. Try rephrasing the step's prompt.", ); expect(agentPort.getRelatedData).not.toHaveBeenCalled(); }); @@ -2280,7 +2349,7 @@ describe('LoadRelatedRecordStepExecutor', () => { const invoke = jest.fn().mockResolvedValue({ tool_calls: [], invalid_tool_calls: [ - { name: 'select-relation', args: '{bad json', error: 'JSON parse error' }, + { name: 'select-relation-to-follow', args: '{bad json', error: 'JSON parse error' }, ], }); const bindTools = jest.fn().mockReturnValue({ invoke }); @@ -2332,7 +2401,14 @@ describe('LoadRelatedRecordStepExecutor', () => { it('returns error outcome for getRelatedData infrastructure errors (Branch B)', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'test', + }); const context = makeContext({ model: mockModel.model, agentPort, @@ -2347,7 +2423,14 @@ describe('LoadRelatedRecordStepExecutor', () => { it('returns error outcome for getRelatedData infrastructure errors (Branch C)', async () => { const agentPort = makeMockAgentPort(); (agentPort.getRelatedData as jest.Mock).mockRejectedValue(new Error('Connection refused')); - const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'test', + }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -2361,7 +2444,14 @@ describe('LoadRelatedRecordStepExecutor', () => { (agentPort.getRelatedData as jest.Mock).mockRejectedValue( new AgentPortError('getRelatedData', new Error('DB connection lost')), ); - const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'test', + }); const context = makeContext({ model: mockModel.model, agentPort, @@ -2384,7 +2474,7 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('multi-record AI selection (base record pool)', () => { - it('uses AI to select among multiple base records then loads relation', async () => { + it('follows a relation on a loaded record, not the base record', async () => { const baseRecordRef = makeRecordRef({ stepIndex: 1 }); const relatedRecord = makeRecordRef({ stepIndex: 2, @@ -2406,27 +2496,26 @@ describe('LoadRelatedRecordStepExecutor', () => { ], }); - // Call 1: select-record; Call 2: select-relation - const invoke = jest - .fn() - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-record', - args: { recordIdentifier: 'Step 2 - Orders #99' }, - id: 'call_1', - }, - ], - }) - .mockResolvedValueOnce({ - tool_calls: [ - { - name: 'select-relation', - args: { relationName: 'Invoice', reasoning: 'Load the invoice' }, - id: 'call_2', + // One combined AI call over all candidates (base customers #42 relations + the loaded + // orders #99 relations). The AI follows the loaded order's Invoice relation. + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation-to-follow', + args: { + relation: relationOption({ + stepIndex: 2, + collectionDisplayName: 'Orders', + recordId: [99], + relationDisplayName: 'Invoice', + relatedCollectionName: 'invoices', + }), + reasoning: 'Load the invoice', }, - ], - }); + id: 'call_1', + }, + ], + }); const bindTools = jest.fn().mockReturnValue({ invoke }); const model = { bindTools } as unknown as ExecutionContext['model']; @@ -2457,13 +2546,11 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('awaiting-input'); - expect(bindTools).toHaveBeenCalledTimes(2); - - const selectRecordTool = bindTools.mock.calls[0][0][0]; - expect(selectRecordTool.name).toBe('select-record'); + // One combined relation-following AI call (xToOne target needs no further AI calls). + expect(bindTools).toHaveBeenCalledTimes(1); - const selectRelationTool = bindTools.mock.calls[1][0][0]; - expect(selectRelationTool.name).toBe('select-relation'); + const selectRelationTool = bindTools.mock.calls[0][0][0]; + expect(selectRelationTool.name).toBe('select-relation-to-follow'); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -2499,8 +2586,15 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('previous steps context', () => { - it('includes previous steps summary in select-relation messages', async () => { - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + it('includes previous steps summary in select-relation-to-follow messages', async () => { + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'test', + }); const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([ { @@ -2549,7 +2643,14 @@ describe('LoadRelatedRecordStepExecutor', () => { describe('default prompt', () => { it('uses default prompt when step.prompt is undefined', async () => { - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'test', + }); const context = makeContext({ model: mockModel.model, stepDefinition: makeStep({ prompt: undefined }), @@ -2666,7 +2767,14 @@ describe('LoadRelatedRecordStepExecutor', () => { collectionDisplayName: 'Addresses', }), }); - const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'Load address' }); + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Address', + relatedCollectionName: 'addresses', + }), + reasoning: 'Load address', + }); const context = makeContext({ model: mockModel.model, workflowPort, @@ -2719,25 +2827,161 @@ describe('LoadRelatedRecordStepExecutor', () => { ], }); - // Call 1: select-record (picks the completed related record) - // Call 2: select-relation + // One combined select-relation-to-follow call over all candidates. The AI follows the + // completed order's Order relation. + const orderOnLoaded = relationOption({ + stepIndex: 2, + collectionDisplayName: 'Orders', + recordId: [99], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }); + const invoke = jest.fn().mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation-to-follow', + args: { relation: orderOnLoaded, reasoning: 'test' }, + id: 'call_1', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 2, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: completedRecord, + }, + selectedRecordRef: makeRecordRef(), + }, + pendingExecution, + ]), + }); + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }); + const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + // Candidate pool = base customers #42 relations (Order, Address) + completed orders #99 + // relation (Order). The pending step-3 record (no record field) contributes none. + expect(bindTools).toHaveBeenCalledTimes(1); + const selectRelationTool = bindTools.mock.calls[0][0][0]; + expect(selectRelationTool.name).toBe('select-relation-to-follow'); + // base customers: 2 relations; loaded order: 1 relation → 3 options. + expect(selectRelationTool.schema.shape.relation.options).toHaveLength(3); + expect(selectRelationTool.schema.shape.relation.options).toContain(orderOnLoaded); + // The excluded pending step-3 record contributes no candidate label. + expect( + selectRelationTool.schema.shape.relation.options.some((o: string) => + o.startsWith('Step 3 -'), + ), + ).toBe(false); + }); + }); + + // Repro for the "latch onto a previously-loaded record" bug: when a dvd was already loaded + // by a prior step, "Load the dvd titanic" must still follow store → dvds (the relation that + // LEADS TO a dvd), not read a relation off the already-loaded dvd. resolveTarget now offers + // every relation across all available records and lets the AI choose by target collection. + describe('follows the relation leading to the requested collection (PRD-214 repro)', () => { + it('follows store → dvds rather than a relation on the already-loaded dvd', async () => { + // Available records: base account #1 (store BelongsTo), loaded store #6 (dvds HasMany), + // loaded dvd #32 (store BelongsTo). + const baseRecordRef: RecordRef = { + collectionName: 'account', + recordId: [1], + stepIndex: 0, + }; + const loadedStore: RecordRef = { collectionName: 'store', recordId: [6], stepIndex: 1 }; + const loadedDvd: RecordRef = { collectionName: 'dvd', recordId: [32], stepIndex: 3 }; + + const accountSchema = makeCollectionSchema({ + collectionName: 'account', + collectionDisplayName: 'Account', + fields: [ + { + fieldName: 'store', + displayName: 'Store', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'store', + }, + ], + }); + const storeSchema = makeCollectionSchema({ + collectionName: 'store', + collectionDisplayName: 'Store', + fields: [ + { + fieldName: 'dvds', + displayName: 'Dvds', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'dvd', + }, + ], + }); + const dvdSchema = makeCollectionSchema({ + collectionName: 'dvd', + collectionDisplayName: 'Dvd', + referenceField: 'title', + fields: [ + { fieldName: 'title', displayName: 'Title', isRelationship: false }, + { + fieldName: 'store', + displayName: 'Store', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'store', + }, + ], + }); + + // store → dvds returns two dvds; the AI ranks them and picks "Titanic". + const dvds: RecordData[] = [ + { collectionName: 'dvd', recordId: [40], values: { title: 'Avatar' } }, + { collectionName: 'dvd', recordId: [41], values: { title: 'Titanic' } }, + ]; + const agentPort = makeMockAgentPort(dvds); + + // 1 combined relation-following call → store → dvds (HasMany), then select-fields + + // select-record-by-content to pick Titanic. + const storeDvdsLabel = relationOption({ + stepIndex: 1, + collectionDisplayName: 'Store', + recordId: [6], + relationDisplayName: 'Dvds', + relatedCollectionName: 'dvd', + }); const invoke = jest .fn() .mockResolvedValueOnce({ tool_calls: [ { - name: 'select-record', - args: { recordIdentifier: 'Step 2 - Orders #99' }, - id: 'call_1', + name: 'select-relation-to-follow', + args: { relation: storeDvdsLabel, reasoning: 'store leads to dvds' }, + id: 'c1', }, ], }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['Title'] }, id: 'c2' }], + }) .mockResolvedValueOnce({ tool_calls: [ { - name: 'select-relation', - args: { relationName: 'Order', reasoning: 'test' }, - id: 'call_2', + name: 'select-record-by-content', + args: { recordIndex: 1, reasoning: 'Titanic matches' }, + id: 'c3', }, ], }); @@ -2748,33 +2992,72 @@ describe('LoadRelatedRecordStepExecutor', () => { getStepExecutions: jest.fn().mockResolvedValue([ { type: 'load-related-record', - stepIndex: 2, + stepIndex: 1, executionResult: { - relation: { name: 'order', displayName: 'Order' }, - record: completedRecord, + relation: { name: 'store', displayName: 'Store' }, + record: loadedStore, }, - selectedRecordRef: makeRecordRef(), + selectedRecordRef: baseRecordRef, + }, + { + type: 'load-related-record', + stepIndex: 3, + executionResult: { + relation: { name: 'dvd', displayName: 'Dvd' }, + record: loadedDvd, + }, + selectedRecordRef: loadedStore, }, - pendingExecution, ]), }); const workflowPort = makeMockWorkflowPort({ - customers: makeCollectionSchema(), - orders: ordersSchema, + account: accountSchema, + store: storeSchema, + dvd: dvdSchema, + }); + const context = makeContext({ + baseRecordRef, + model, + agentPort, + runStore, + workflowPort, + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + prompt: 'Load the dvd titanic', + }), }); - const context = makeContext({ baseRecordRef, model, runStore, workflowPort }); const executor = new LoadRelatedRecordStepExecutor(context); - await executor.execute(); + const result = await executor.execute(); - // Pool = [base, completedRecord] = 2 items → select-record IS invoked - // Pool does NOT include pending execution (no record) → only 2 options, not 3 - expect(bindTools).toHaveBeenCalledTimes(2); - const selectRecordTool = bindTools.mock.calls[0][0][0]; - expect(selectRecordTool.name).toBe('select-record'); - expect(selectRecordTool.schema.shape.recordIdentifier.options).toHaveLength(2); - expect(selectRecordTool.schema.shape.recordIdentifier.options).not.toContain( - expect.stringContaining('stepIndex: 3'), + expect(result.stepOutcome.status).toBe('success'); + + // The relation read goes through the loaded STORE's dvds relation, NOT the dvd. + expect(agentPort.getRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'store', + id: [6], + relation: 'dvds', + limit: 50, + relatedSchema: expect.objectContaining({ collectionName: 'dvd' }), + }), + expect.objectContaining({ id: 1 }), + ); + + // The persisted result is the AI-ranked dvd (Titanic #41), sourced from the store. + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionParams: { displayName: 'Dvds', name: 'dvds' }, + executionResult: expect.objectContaining({ + relation: { name: 'dvds', displayName: 'Dvds' }, + record: expect.objectContaining({ collectionName: 'dvd', recordId: [41] }), + }), + selectedRecordRef: expect.objectContaining({ + collectionName: 'store', + recordId: [6], + }), + }), ); }); }); From 0b8500f2fda2df3e0248e03bfe943b98accb4645 Mon Sep 17 00:00:00 2001 From: alban bertolini <albanb@forestadmin.com> Date: Fri, 5 Jun 2026 14:01:01 +0200 Subject: [PATCH 4/5] test(workflow): cover the step-title context and selectedRecordStepIndex Add coverage for the diff lines flagged by the coverage gate: the step-title branch in buildContextMessage, and resolveTarget's requireRecordAtStepIndex (pinned source via selectedRecordStepIndex, plus the no-match error path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../test/executors/base-step-executor.test.ts | 28 ++++++++++++++++ .../load-related-record-step-executor.test.ts | 32 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/packages/workflow-executor/test/executors/base-step-executor.test.ts b/packages/workflow-executor/test/executors/base-step-executor.test.ts index 8f1479afe2..60282bce45 100644 --- a/packages/workflow-executor/test/executors/base-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/base-step-executor.test.ts @@ -54,6 +54,10 @@ class TestableExecutor extends BaseStepExecutor { return super.buildPreviousStepsMessages(); } + override buildContextMessage(): SystemMessage { + return super.buildContextMessage(); + } + override invokeWithTool<T = Record<string, unknown>>( messages: BaseMessage[], tool: DynamicStructuredTool, @@ -144,6 +148,30 @@ function makeContext(overrides: Partial<ExecutionContext> = {}): ExecutionContex } describe('BaseStepExecutor', () => { + describe('buildContextMessage', () => { + it('appends the step title when present', () => { + const context = makeContext({ + stepDefinition: { + type: StepType.Condition, + executionType: StepExecutionMode.Manual, + options: ['A', 'B'], + prompt: 'Pick one', + title: 'Load the store', + }, + }); + + const message = new TestableExecutor(context).buildContextMessage(); + + expect(message.content as string).toContain('Step title: "Load the store"'); + }); + + it('omits the title line when the step has no title', () => { + const message = new TestableExecutor(makeContext()).buildContextMessage(); + + expect(message.content as string).not.toContain('Step title'); + }); + }); + describe('buildPreviousStepsMessages', () => { it('returns empty array for empty history', async () => { const executor = new TestableExecutor(makeContext()); diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index c014cba268..d708c87155 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -3082,6 +3082,38 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(bindTools).not.toHaveBeenCalled(); }); + it('pins the source record via selectedRecordStepIndex (no AI relation choice)', async () => { + const { model, bindTools } = makeMockModel(); + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + runStore, + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + // step 0 is the base customers record; pin it + its Order relation → single candidate. + preRecordedArgs: { selectedRecordStepIndex: 0, relationDisplayName: 'Order' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + expect(bindTools).not.toHaveBeenCalled(); + }); + + it('errors when selectedRecordStepIndex matches no available record', async () => { + const context = makeContext({ + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { selectedRecordStepIndex: 99 }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + }); + it('skips AI record selection when selectedRecordIndex is pre-recorded with HasMany', async () => { const relatedData = [ makeRelatedRecordData({ From c264c518d2e91d094fdd1e7aabe526312358f437 Mon Sep 17 00:00:00 2001 From: alban bertolini <albanb@forestadmin.com> Date: Fri, 5 Jun 2026 14:12:35 +0200 Subject: [PATCH 5/5] refactor(workflow): clearer load-related errors + direct target build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review findings: - a pinned relationDisplayName / selectedRecordStepIndex that matches nothing now throws InvalidPreRecordedArgsError, not the misleading NoRelationshipFieldsError ("no relations configured"); the latter is kept only for the genuine zero-relations case. - matchesRelation normalizes (case + separators) like findField, restoring fuzzy parity for pre-recorded relation names. - resolveTarget builds the RelationTarget straight from the chosen candidate's field (targetFromCandidate) instead of re-resolving by displayName via buildTarget — removes a redundant lookup and a same-displayName mis-resolve hazard. RelationCandidate.field is narrowed to guarantee relatedCollectionName. - tests: pinned-relation/source assertions made meaningful (executionParams + source record among several), error userMessages asserted, and a test that the step title reaches the select-relation-to-follow context. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --- .../load-related-record-step-executor.ts | 44 ++++++-- .../load-related-record-step-executor.test.ts | 102 ++++++++++++++++-- 2 files changed, 131 insertions(+), 15 deletions(-) diff --git a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts index 4a86d06b29..8f22fed577 100644 --- a/packages/workflow-executor/src/executors/load-related-record-step-executor.ts +++ b/packages/workflow-executor/src/executors/load-related-record-step-executor.ts @@ -62,10 +62,11 @@ interface RelationTarget extends RelationRef { } // A relationship reachable from one available record — the unit the AI chooses among. +// `relatedCollectionName` is guaranteed non-null (buildRelationCandidates filters on it). interface RelationCandidate { record: RecordRef; schema: CollectionSchema; - field: FieldSchema; + field: FieldSchema & { relatedCollectionName: string }; } export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<LoadRelatedRecordStepDefinition> { @@ -145,18 +146,39 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo : records; const candidates = await this.buildRelationCandidates(sourceRecords); - const eligible = preRecordedArgs?.relationDisplayName - ? candidates.filter(c => this.matchesRelation(c.field, preRecordedArgs.relationDisplayName)) + + if (candidates.length === 0) { + throw new NoRelationshipFieldsError(sourceRecords[0]?.collectionName ?? 'unknown'); + } + + const pinned = preRecordedArgs?.relationDisplayName; + const eligible = pinned + ? candidates.filter(c => this.matchesRelation(c.field, pinned)) : candidates; if (eligible.length === 0) { - throw new NoRelationshipFieldsError(sourceRecords[0]?.collectionName ?? 'unknown'); + // Relations exist, but the pre-recorded one doesn't match any of them. + throw new InvalidPreRecordedArgsError( + `No relation matching "${pinned}" on the selected record`, + ); } const chosen = eligible.length === 1 ? eligible[0] : await this.selectRelationToFollow(eligible); - return this.buildTarget(chosen.schema, chosen.field.displayName, chosen.record); + return this.targetFromCandidate(chosen); + } + + private targetFromCandidate(candidate: RelationCandidate): RelationTarget { + const { record, field } = candidate; + + return { + selectedRecordRef: record, + displayName: field.displayName, + name: field.fieldName, + relationType: field.relationType, + relatedCollectionName: field.relatedCollectionName, + }; } private requireRecordAtStepIndex(records: RecordRef[], stepIndex: number): RecordRef { @@ -178,7 +200,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo for (const field of schema.fields) { if (field.isRelationship && field.relatedCollectionName) { - candidates.push({ record, schema, field }); + candidates.push({ + record, + schema, + field: { ...field, relatedCollectionName: field.relatedCollectionName }, + }); } } } @@ -187,7 +213,11 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor<Lo } private matchesRelation(field: FieldSchema, relationDisplayName: string): boolean { - return field.displayName === relationDisplayName || field.fieldName === relationDisplayName; + // Normalize like findField, so a pre-recorded "my_relation" still matches "My Relation". + const normalize = (s: string) => s.toLowerCase().replace(/[\s_-]/g, ''); + const target = normalize(relationDisplayName); + + return normalize(field.displayName) === target || normalize(field.fieldName) === target; } private buildTarget( diff --git a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts index d708c87155..2515338909 100644 --- a/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts +++ b/packages/workflow-executor/test/executors/load-related-record-step-executor.test.ts @@ -2639,6 +2639,26 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(messages[0].content).toContain('"answer":"Yes"'); expect(messages[0].content).toContain('loading a related record'); }); + + it('surfaces the step title in the select-relation-to-follow context', async () => { + const mockModel = makeMockModel({ + relation: relationOption({ + recordId: [42], + relationDisplayName: 'Order', + relatedCollectionName: 'orders', + }), + reasoning: 'test', + }); + const context = makeContext({ + model: mockModel.model, + stepDefinition: makeStep({ title: 'Load the customer order' }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + const messages = mockModel.invoke.mock.calls[0][0]; + expect(messages[0].content).toContain('Step title: "Load the customer order"'); + }); }); describe('default prompt', () => { @@ -3063,7 +3083,7 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('pre-recorded args', () => { - it('skips AI relation selection when relationName is pre-recorded', async () => { + it('follows the pre-recorded relation without an AI call', async () => { const { model, bindTools } = makeMockModel(); const runStore = makeMockRunStore(); const context = makeContext({ @@ -3074,24 +3094,63 @@ describe('LoadRelatedRecordStepExecutor', () => { preRecordedArgs: { relationDisplayName: 'Order' }, }), }); - const executor = new LoadRelatedRecordStepExecutor(context); - const result = await executor.execute(); + const result = await new LoadRelatedRecordStepExecutor(context).execute(); expect(result.stepOutcome.status).toBe('success'); expect(bindTools).not.toHaveBeenCalled(); + // The pinned 'Order' relation is the one actually followed (not just the first eligible). + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ executionParams: { displayName: 'Order', name: 'order' } }), + ); }); - it('pins the source record via selectedRecordStepIndex (no AI relation choice)', async () => { + it('pins the source record via selectedRecordStepIndex (among several records)', async () => { + // Base customers #42 (step 0) + a loaded order #99 (step 1) are both available; + // pinning step 1 must make the relation follow the ORDER, not the base customer. + const loadedOrder: RecordRef = { collectionName: 'orders', recordId: [99], stepIndex: 1 }; + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + fields: [ + { + fieldName: 'customer', + displayName: 'Customer', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'customers', + }, + ], + }); const { model, bindTools } = makeMockModel(); - const runStore = makeMockRunStore(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 1, + executionResult: { + relation: { name: 'order', displayName: 'Order' }, + record: loadedOrder, + }, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const agentPort = makeMockAgentPort([ + makeRelatedRecordData({ collectionName: 'customers', recordId: [7], values: {} }), + ]); const context = makeContext({ model, runStore, + agentPort, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, - // step 0 is the base customers record; pin it + its Order relation → single candidate. - preRecordedArgs: { selectedRecordStepIndex: 0, relationDisplayName: 'Order' }, + preRecordedArgs: { selectedRecordStepIndex: 1, relationDisplayName: 'Customer' }, }), }); @@ -3099,9 +3158,21 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.status).toBe('success'); expect(bindTools).not.toHaveBeenCalled(); + // The relation is read off the pinned order #99, not the base customer. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ collection: 'orders', id: [99], relation: 'customer' }), + expect.objectContaining({ id: 1 }), + ); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionParams: { displayName: 'Customer', name: 'customer' }, + selectedRecordRef: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), + }), + ); }); - it('errors when selectedRecordStepIndex matches no available record', async () => { + it('errors with the pre-recorded-args message when selectedRecordStepIndex matches no record', async () => { const context = makeContext({ stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated, @@ -3112,6 +3183,21 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await new LoadRelatedRecordStepExecutor(context).execute(); expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); + }); + + it('errors with the pre-recorded-args message when the pinned relation matches nothing', async () => { + const context = makeContext({ + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationDisplayName: 'Nonexistent' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); }); it('skips AI record selection when selectedRecordIndex is pre-recorded with HasMany', async () => {