diff --git a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts index 24bec25231..4ec3b9e3b9 100644 --- a/packages/workflow-executor/src/adapters/agent-client-agent-port.ts +++ b/packages/workflow-executor/src/adapters/agent-client-agent-port.ts @@ -4,6 +4,7 @@ import type { GetActionFormInfoQuery, GetRecordQuery, GetRelatedDataQuery, + GetSingleRelatedDataQuery, UpdateRecordQuery, } from '../ports/agent-port'; import type SchemaCache from '../schema-cache'; @@ -22,6 +23,10 @@ import { extractErrorMessage, } from '../errors'; +function toCamelCase(name: string): string { + return name.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase()); +} + // The agent-client HTTP layer deserializes JSON:API responses with camelCase keys. // Field names in the schema and in GetRecordQuery.fields use the original format (e.g. snake_case). // This function restores the original field names so callers can look up values by schema fieldName. @@ -34,8 +39,7 @@ function restoreFieldNames( const camelToOriginal: Record = {}; for (const name of originalFieldNames) { - const camelName = name.replace(/_([a-zA-Z0-9])/g, (_, c: string) => c.toUpperCase()); - camelToOriginal[camelName] = name; + camelToOriginal[toCamelCase(name)] = name; } return Object.fromEntries(Object.entries(values).map(([k, v]) => [camelToOriginal[k] ?? k, v])); @@ -111,17 +115,12 @@ export default class AgentClientAgentPort implements AgentPort { } async getRelatedData( - { collection, id, relation, limit, fields }: GetRelatedDataQuery, + { collection, id, relation, relatedSchema, limit, fields }: GetRelatedDataQuery, user: StepUser, ): Promise { return this.callAgent('getRelatedData', async () => { const client = this.createClient(user); - const parentSchema = this.resolveSchema(collection); - const relationField = parentSchema.fields.find(f => f.fieldName === relation); - const relatedCollectionName = relationField?.relatedCollectionName ?? relation; - const relatedSchema = this.resolveSchema(relatedCollectionName); - - const records = await client + const rows = await client .collection(collection) .relation(relation, id) .list>({ @@ -129,11 +128,11 @@ export default class AgentClientAgentPort implements AgentPort { ...(fields?.length && { fields }), }); - return records.map(record => { - const restored = restoreFieldNames(record, [ - ...relatedSchema.primaryKeyFields, - ...(fields ?? []), - ]); + return rows.map(row => { + const restored = restoreFieldNames( + row, + relatedSchema.fields.map(f => f.fieldName), + ); return { collectionName: relatedSchema.collectionName, @@ -144,6 +143,49 @@ export default class AgentClientAgentPort implements AgentPort { }); } + // xToOne relations have no /relationships/ route on the agent. We read the + // parent record with a `@@@` projection and unpack the relation linkage + // jsonapi-serializer emits as a nested object on the parent (with the related PK packed + // under "id" when composite). + async getSingleRelatedData( + { collection, id, relation, relatedSchema, fields }: GetSingleRelatedDataQuery, + user: StepUser, + ): Promise { + return this.callAgent('getSingleRelatedData', async () => { + // The agent can't parse multiple sub-fields on one relation in a single projection + // (`fields[store]=id,name` is read as a single field name → ValidationError). The linkage + // `id` carries the (packed) related PK regardless of projection, so project at most ONE + // field: the requested reference field for display, else a single PK field just to pull the + // relation into the response. + const projectedField = fields?.[0] ?? relatedSchema.primaryKeyFields[0]; + const parent = await this.getRecord( + { + collection, + id, + fields: [`${relation}@@@${projectedField}`], + }, + user, + ); + + // agent-client camelCases relation keys; look the linkage up under the camelCased name. + const linkage = parent.values[toCamelCase(relation)] as + | Record + | null + | undefined; + const packedId = linkage?.id as string | undefined; + + if (!linkage || !packedId) return null; + + const restored = restoreFieldNames(linkage, [projectedField]); + + return { + collectionName: relatedSchema.collectionName, + recordId: packedId.split('|'), + values: restored, + }; + }); + } + async executeAction( { collection, action, id }: ExecuteActionQuery, user: StepUser, diff --git a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts index 78ee0d027b..ab38d77e52 100644 --- a/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts +++ b/packages/workflow-executor/src/adapters/forest-server-workflow-port.ts @@ -43,6 +43,12 @@ const ROUTES = { mcpServerConfigs: '/liana/mcp-server-configs-with-details', }; +// Forest sends relatedCollectionName as a `collection.targetKey` reference (e.g. "store.id"); +// normalize it to a plain collection name (the related PK comes from the schema's primaryKeyFields). +function stripReferenceKey(name: string | undefined): string | undefined { + return name?.includes('.') ? name.slice(0, name.lastIndexOf('.')) : name; +} + export default class ForestServerWorkflowPort implements WorkflowPort { private readonly options: HttpOptions; private readonly logger: Logger; @@ -188,7 +194,15 @@ export default class ForestServerWorkflowPort implements WorkflowPort { ); try { - return CollectionSchemaSchema.parse(response); + const schema = CollectionSchemaSchema.parse(response); + + return { + ...schema, + fields: schema.fields.map(field => ({ + ...field, + relatedCollectionName: stripReferenceKey(field.relatedCollectionName), + })), + }; } catch (err) { if (err instanceof z.ZodError) { // runId is passed for observability — the schema call is scoped to a run. diff --git a/packages/workflow-executor/src/errors.ts b/packages/workflow-executor/src/errors.ts index 8e0ba5d919..21f30c25ab 100644 --- a/packages/workflow-executor/src/errors.ts +++ b/packages/workflow-executor/src/errors.ts @@ -253,7 +253,7 @@ export class WorkflowPortError extends WorkflowExecutorError { `Workflow port "${operation}" failed: ${ cause instanceof Error ? cause.message : String(cause) }`, - 'Failed to communicate with the workflow orchestrator. Please try again.', + "This step couldn't be completed. Please try again, and contact your administrator if the problem continues.", ); this.cause = cause; } 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 2d985faf9c..d327d3dd24 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 @@ -1,6 +1,10 @@ import type { CreateActivityLogArgs } from '../ports/activity-log-port'; import type { StepExecutionResult } from '../types/execution-context'; -import type { LoadRelatedRecordStepExecutionData, RelationRef } from '../types/step-execution-data'; +import type { + LoadRelatedRecordCandidate, + LoadRelatedRecordStepExecutionData, + RelationRef, +} from '../types/step-execution-data'; import type { CollectionSchema, RecordData, RecordRef } from '../types/validated/collection'; import type { LoadRelatedRecordStepDefinition } from '../types/validated/step-definition'; @@ -35,6 +39,7 @@ Choose the record that best matches the user request based on the provided field interface RelationTarget extends RelationRef { selectedRecordRef: RecordRef; relationType?: 'BelongsTo' | 'HasMany' | 'HasOne' | 'BelongsToMany'; + relatedCollectionName: string; } export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { @@ -55,6 +60,12 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor(pending, async exec => this.resolveFromSelection(exec), ); @@ -64,6 +75,32 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { + if (!execution.pendingData) { + throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); + } + + const schema = await this.getCollectionSchema(execution.selectedRecordRef.collectionName); + const target = this.buildTarget(schema, fieldName, execution.selectedRecordRef); + const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target); + + await this.context.runStore.saveStepExecution(this.context.runId, { + ...execution, + userConfirmation: undefined, + pendingData: { + ...execution.pendingData, + suggestedField: { name: target.name, displayName: target.displayName }, + availableRecordIds, + suggestedRecord, + }, + }); + + return this.buildOutcomeResult({ status: 'awaiting-input' }); + } + private async handleFirstCall(): Promise { const { stepDefinition: step } = this.context; const { preRecordedArgs } = step; @@ -85,7 +122,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { + private async saveAndAwaitInput( + target: RelationTarget, + sourceSchema: CollectionSchema, + ): Promise { const { selectedRecordRef, name, displayName } = target; - const { relatedData, bestIndex, suggestedFields } = await this.selectBestFromRelatedData( - target, - 50, - ); + const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target); - const selectedRecordId = relatedData[bestIndex].recordId; + const availableFields: RelationRef[] = sourceSchema.fields + .filter(f => f.isRelationship) + .map(f => ({ name: f.fieldName, displayName: f.displayName })); await this.context.runStore.saveStepExecution(this.context.runId, { type: 'load-related-record', stepIndex: this.context.stepIndex, - pendingData: { displayName, name, suggestedFields, selectedRecordId }, + pendingData: { + availableFields, + suggestedField: { name, displayName }, + availableRecordIds, + suggestedRecord, + }, selectedRecordRef, }); return this.buildOutcomeResult({ status: 'awaiting-input' }); } - /** Branch B: automatic execution. HasMany uses 2 AI calls; others take the first result. */ + private async collectCandidateIds(target: RelationTarget): Promise<{ + availableRecordIds: LoadRelatedRecordCandidate[]; + suggestedRecord?: LoadRelatedRecordCandidate; + }> { + if (target.relationType === 'BelongsTo' || target.relationType === 'HasOne') { + const candidate = await this.fetchXToOneCandidate(target); + + return candidate + ? { availableRecordIds: [candidate], suggestedRecord: candidate } + : { availableRecordIds: [] }; + } + + const { relatedData, bestIndex, relatedSchema } = await this.selectBestFromRelatedData( + target, + 50, + ); + + if (relatedData.length === 0) { + return { availableRecordIds: [] }; + } + + const referenceField = relatedSchema.referenceField ?? null; + const toCandidate = (r: RecordData): LoadRelatedRecordCandidate => ({ + recordId: r.recordId, + referenceFieldValue: referenceField + ? this.extractReferenceFieldValue(r.values, referenceField) + : null, + }); + + return { + availableRecordIds: relatedData.map(toCandidate), + suggestedRecord: toCandidate(relatedData[bestIndex]), + }; + } + + private extractReferenceFieldValue( + values: Record, + referenceField: string, + ): string | null { + const v = values[referenceField]; + + return v === undefined || v === null ? null : String(v); + } + + /** Branch B: fully automated. xToOne loads the linked record; HasMany ranks candidates via AI; BelongsToMany takes the first. */ private async resolveAndLoadAutomatic(target: RelationTarget): Promise { - const record = - target.relationType === 'HasMany' - ? await this.selectBestRelatedRecord(target) - : await this.fetchFirstCandidate(target); + const record = await this.fetchRecordForRelation(target); return this.persistAndReturn(record, target, undefined); } + private async fetchRecordForRelation(target: RelationTarget): Promise { + if (target.relationType === 'BelongsTo' || target.relationType === 'HasOne') { + return this.fetchXToOneRecordRef(target); + } + + if (target.relationType === 'HasMany') { + return this.selectBestRelatedRecord(target); + } + + return this.fetchFirstCandidate(target); + } + + private async fetchXToOneRecordRef(target: RelationTarget): Promise { + const candidate = await this.fetchXToOneCandidate(target); + + if (!candidate) { + throw new RelatedRecordNotFoundError(target.selectedRecordRef.collectionName, target.name); + } + + return { + collectionName: target.relatedCollectionName, + recordId: candidate.recordId, + stepIndex: this.context.stepIndex, + }; + } + + private async fetchXToOneCandidate( + target: RelationTarget, + ): Promise { + const relatedSchema = await this.getCollectionSchema(target.relatedCollectionName); + const referenceField = relatedSchema.referenceField ?? null; + + const candidate = await this.agentPort.getSingleRelatedData( + { + collection: target.selectedRecordRef.collectionName, + id: target.selectedRecordRef.recordId, + relation: target.name, + relatedSchema, + ...(referenceField && { fields: [referenceField] }), + }, + this.context.user, + ); + + if (!candidate) return null; + + return { + recordId: candidate.recordId, + referenceFieldValue: referenceField + ? this.extractReferenceFieldValue(candidate.values, referenceField) + : null, + }; + } + // Branch A: builds RecordRef from the user-confirmed selection without a new getRelatedData call. private async resolveFromSelection( execution: LoadRelatedRecordStepExecutionData, @@ -149,10 +294,25 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor f.name === userConfirmation.fieldName) + : pendingData.suggestedField; - // Re-derive relatedCollectionName and displayName because the user may have swapped the relation. + if (!relationRef) { + throw new StepStateError( + `Step at index ${this.context.stepIndex} could not resolve relation "${userConfirmation?.fieldName}" from available fields`, + ); + } + + const { name, displayName } = relationRef; + const selectedRecordId = + userConfirmation?.selectedRecordId ?? pendingData.suggestedRecord?.recordId; + + if (!selectedRecordId) { + throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); + } + + // Re-derive relatedCollectionName from the live schema — frontend never sends it. const schema = await this.getCollectionSchema(selectedRecordRef.collectionName); const field = schema.fields.find(f => f.fieldName === name); @@ -162,10 +322,8 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor, + target: Pick, limit: number, - ): Promise<{ relatedData: RecordData[]; bestIndex: number; suggestedFields: string[] }> { - const { selectedRecordRef, name } = target; - - const relatedData = await this.agentPort.getRelatedData( - { - collection: selectedRecordRef.collectionName, - id: selectedRecordRef.recordId, - relation: name, - limit, - }, - this.context.user, - ); - - if (relatedData.length === 0) { - throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); - } - - if (relatedData.length === 1) { - return { relatedData, bestIndex: 0, suggestedFields: [] }; + ): Promise<{ + relatedData: RecordData[]; + bestIndex: number; + suggestedFields: string[]; + relatedSchema: CollectionSchema; + }> { + const relatedSchema = await this.getCollectionSchema(target.relatedCollectionName); + const relatedData = await this.fetchRelatedData(target, relatedSchema, limit); + + // Empty (bestIndex unused — callers guard on length) or single → no ranking needed. + if (relatedData.length <= 1) { + return { relatedData, bestIndex: 0, suggestedFields: [], relatedSchema }; } const { preRecordedArgs } = this.context.stepDefinition; @@ -212,10 +363,14 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { relatedData, bestIndex } = await this.selectBestFromRelatedData(target, 50); + if (relatedData.length === 0) { + throw new RelatedRecordNotFoundError(target.selectedRecordRef.collectionName, target.name); + } + return this.toRecordRef(relatedData[bestIndex]); } - /** BelongsTo / HasOne: fetch 1 record and take it directly. */ private async fetchFirstCandidate(target: RelationTarget): Promise { const candidates = await this.fetchCandidates(target, 1); return candidates[0]; } - // Throws RelatedRecordNotFoundError when the result is empty. private async fetchCandidates( - target: Pick, + target: Pick, limit: number, ): Promise { const { selectedRecordRef, name } = target; - const relatedData = await this.agentPort.getRelatedData( - { - collection: selectedRecordRef.collectionName, - id: selectedRecordRef.recordId, - relation: name, - limit, - }, - this.context.user, - ); + const relatedSchema = await this.getCollectionSchema(target.relatedCollectionName); + const relatedData = await this.fetchRelatedData(target, relatedSchema, limit); if (relatedData.length === 0) { throw new RelatedRecordNotFoundError(selectedRecordRef.collectionName, name); @@ -266,6 +416,23 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor this.toRecordRef(r)); } + private async fetchRelatedData( + target: Pick, + relatedSchema: CollectionSchema, + limit: number, + ): Promise { + return this.agentPort.getRelatedData( + { + collection: target.selectedRecordRef.collectionName, + id: target.selectedRecordRef.recordId, + relation: target.name, + relatedSchema, + limit, + }, + this.context.user, + ); + } + /** Persists the loaded record ref and returns a success outcome. */ private async persistAndReturn( record: RecordRef, diff --git a/packages/workflow-executor/src/http/pending-data-validators.ts b/packages/workflow-executor/src/http/pending-data-validators.ts index d739e1b40d..2c4f849b45 100644 --- a/packages/workflow-executor/src/http/pending-data-validators.ts +++ b/packages/workflow-executor/src/http/pending-data-validators.ts @@ -23,25 +23,46 @@ const triggerActionPatchSchema = z const mcpPatchSchema = z.object({ userConfirmed: z.boolean() }).strict(); +// Accepts two shapes: +// 1. Confirmation patch: `userConfirmed: boolean` (+ optional overrides) — finalizes +// the step or skips it. +// 2. Field-preview patch: `fieldName: string` alone, with `userConfirmed` omitted — +// asks the executor to re-list candidates for a different relation WITHOUT +// finalizing. The executor refreshes pendingData and stays awaiting-input. +// Required when the frontend lets the user switch relations: the IDs originally +// stored under `availableRecordIds` belong to the AI-suggested relation only. const loadRelatedRecordPatchSchema = z .object({ - userConfirmed: z.boolean(), + userConfirmed: z.boolean().optional(), // User may intentionally switch to a different relation than the one the AI selected. - // The executor re-derives relatedCollectionName and displayName from FieldSchema when - // processing the confirmation. - name: z.string().min(1).optional(), + // Sent as the technical fieldName (matches CollectionSchemaField.fieldName from the + // orchestrator); the executor re-derives displayName + relatedCollectionName from + // the live schema when processing the confirmation. + fieldName: z.string().min(1).optional(), // User may override the AI-selected record; must be non-empty when provided. - // Required when overriding the relation name — the original record ID belongs to a - // different collection and cannot be reused for the new relation. + // Required when confirming with a relation override — the original record ID + // belongs to a different collection and cannot be reused for the new relation. selectedRecordId: z .array(z.union([z.string(), z.number()])) .min(1) .optional(), }) .strict() - .refine(data => data.name === undefined || data.selectedRecordId !== undefined, { - message: 'selectedRecordId is required when overriding the relation name', - }); + .refine( + data => { + // Preview patch (no confirm): fieldName alone is sufficient. + if (data.userConfirmed === undefined) return data.fieldName !== undefined; + // Confirm patch with relation override: selectedRecordId required. + if (data.fieldName !== undefined) return data.selectedRecordId !== undefined; + + return true; + }, + { + message: + 'selectedRecordId is required when confirming with a relation override, ' + + 'or omit userConfirmed to preview candidates for a different relation', + }, + ); const guidancePatchSchema = z .object({ userInput: z.string().optional(), diff --git a/packages/workflow-executor/src/ports/agent-port.ts b/packages/workflow-executor/src/ports/agent-port.ts index 4ccb652409..5fa71a042e 100644 --- a/packages/workflow-executor/src/ports/agent-port.ts +++ b/packages/workflow-executor/src/ports/agent-port.ts @@ -1,7 +1,7 @@ /** @draft Types derived from the workflow-executor spec -- subject to change. */ import type { StepUser } from '../types/execution-context'; -import type { RecordData } from '../types/validated/collection'; +import type { CollectionSchema, RecordData } from '../types/validated/collection'; export type Id = string | number; @@ -15,9 +15,28 @@ export type GetRelatedDataQuery = { collection: string; id: Id[]; relation: string; + // Schema of the RELATED collection — supplied by the caller so the port can extract the + // record ID and restore original field names without consulting any cache. + relatedSchema: CollectionSchema; fields?: string[]; } & Limit; +// xToOne relations (BelongsTo / HasOne) — the agent does not serve +// /forest///relationships/ for these; the port instead reads +// the parent record with a `@@@` projection, then unpacks the relation +// linkage embedded on the parent. +export type GetSingleRelatedDataQuery = { + collection: string; + id: Id[]; + relation: string; + // Schema of the RELATED collection — needed to extract the record ID and (when set) + // include the referenceField in the projection. + relatedSchema: CollectionSchema; + // Extra fields to project on the related side, beyond the related collection's PK. + // Pass referenceField here when the caller wants to read it from the linkage. + fields?: string[]; +}; + export type ExecuteActionQuery = { collection: string; action: string; id?: Id[] }; export type GetActionFormInfoQuery = { collection: string; action: string; id: Id[] }; @@ -26,6 +45,11 @@ export interface AgentPort { getRecord(query: GetRecordQuery, user: StepUser): Promise; updateRecord(query: UpdateRecordQuery, user: StepUser): Promise; getRelatedData(query: GetRelatedDataQuery, user: StepUser): Promise; + // Returns null when the parent has no related record (xToOne with no linkage). + getSingleRelatedData( + query: GetSingleRelatedDataQuery, + user: StepUser, + ): Promise; executeAction(query: ExecuteActionQuery, user: StepUser): Promise; // Old Ruby agents with hooks.load=false return 404; agent-client falls back to the fields // passed via ActionEndpointsByCollection (populated from the orchestrator's schema). diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 1c37b1460d..06bc3e9b12 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -132,12 +132,17 @@ export interface RecordStepExecutionData extends BaseStepExecutionData { } // -- Load Related Record -- +export interface LoadRelatedRecordCandidate { + recordId: Array; + referenceFieldValue: string | null; +} -export interface LoadRelatedRecordPendingData extends RelationRef { - // undefined when not computed (record has no non-relation fields). - suggestedFields?: string[]; - // AI-selected initially; frontend can override via userConfirmation.selectedRecordId. - selectedRecordId: Array; +export interface LoadRelatedRecordPendingData { + availableFields: RelationRef[]; + suggestedField: RelationRef; + availableRecordIds: LoadRelatedRecordCandidate[]; + // Absent when the relation has no linked record(s): the list is empty and there's nothing to suggest. + suggestedRecord?: LoadRelatedRecordCandidate; } export interface LoadRelatedRecordStepExecutionData @@ -147,7 +152,6 @@ export interface LoadRelatedRecordStepExecutionData pendingData?: LoadRelatedRecordPendingData; selectedRecordRef: RecordRef; executionParams?: RelationRef; - // Source is always selectedRecordRef, not repeated here (consistent with other step types). executionResult?: { relation: RelationRef; record: RecordRef } | { skipped: true }; } diff --git a/packages/workflow-executor/src/types/validated/collection.ts b/packages/workflow-executor/src/types/validated/collection.ts index 6d5b73d2de..fe1776196b 100644 --- a/packages/workflow-executor/src/types/validated/collection.ts +++ b/packages/workflow-executor/src/types/validated/collection.ts @@ -77,6 +77,9 @@ export const CollectionSchemaSchema = z // null when the rendering has no explicit displayName configured — normalized to collectionName. collectionDisplayName: z.string().nullable(), primaryKeyFields: z.array(z.string().min(1)).min(1), + // Layout-level "reference field" used to display a record (e.g. "name", "title"). + // Null when the team didn't configure one; callers fall back to the primary key. + referenceField: z.string().nullable().optional(), fields: z.array(FieldSchemaSchema), actions: z.array(ActionSchemaSchema).optional().default([]), }) diff --git a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts index aacaff0652..6fa0c9e8de 100644 --- a/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts +++ b/packages/workflow-executor/test/adapters/agent-client-agent-port.test.ts @@ -247,7 +247,23 @@ describe('AgentClientAgentPort', () => { }); describe('getRelatedData', () => { - it('should return RecordData[] with recordId extracted from PK fields', async () => { + const postsSchema = { + collectionName: 'posts', + collectionDisplayName: 'Posts', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'id', displayName: 'id', isRelationship: false, type: 'Number' as const }, + { + fieldName: 'title', + displayName: 'title', + isRelationship: false, + type: 'String' as const, + }, + ], + actions: [], + }; + + it('maps raw rows to RecordData using the supplied related schema', async () => { mockRelation.list.mockResolvedValue([ { id: 10, title: 'Post A' }, { id: 11, title: 'Post B' }, @@ -258,6 +274,7 @@ describe('AgentClientAgentPort', () => { collection: 'users', id: [42], relation: 'posts', + relatedSchema: postsSchema, limit: null, }, user, @@ -265,61 +282,118 @@ describe('AgentClientAgentPort', () => { expect(mockCollection.relation).toHaveBeenCalledWith('posts', [42]); expect(result).toEqual([ + { collectionName: 'posts', recordId: [10], values: { id: 10, title: 'Post A' } }, + { collectionName: 'posts', recordId: [11], values: { id: 11, title: 'Post B' } }, + ]); + }); + + it('restores snake_case field names from camelCase deserialized rows', async () => { + const snakeSchema = { + ...postsSchema, + primaryKeyFields: ['post_id'], + fields: [ + { + fieldName: 'post_id', + displayName: 'Post id', + isRelationship: false, + type: 'Number' as const, + }, + { + fieldName: 'created_at', + displayName: 'Created at', + isRelationship: false, + type: 'Date' as const, + }, + ], + }; + mockRelation.list.mockResolvedValue([{ postId: 99, createdAt: '2024-01-01' }]); + + const result = await port.getRelatedData( { - collectionName: 'posts', - recordId: [10], - values: { id: 10, title: 'Post A' }, + collection: 'users', + id: [42], + relation: 'posts', + relatedSchema: snakeSchema, + limit: null, }, + user, + ); + + expect(result).toEqual([ { collectionName: 'posts', - recordId: [11], - values: { id: 11, title: 'Post B' }, + recordId: [99], + values: { post_id: 99, created_at: '2024-01-01' }, }, ]); }); - it('should apply pagination when limit is a number', async () => { - mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); + it('extracts composite primary keys in the order declared by the schema', async () => { + const compositeSchema = { + ...postsSchema, + primaryKeyFields: ['tenantId', 'postId'], + fields: [ + { + fieldName: 'tenantId', + displayName: 'Tenant', + isRelationship: false, + type: 'String' as const, + }, + { + fieldName: 'postId', + displayName: 'Post', + isRelationship: false, + type: 'Number' as const, + }, + ], + }; + mockRelation.list.mockResolvedValue([{ tenantId: 'acme', postId: 7 }]); - await port.getRelatedData( - { collection: 'users', id: [42], relation: 'posts', limit: 5 }, + const result = await port.getRelatedData( + { + collection: 'users', + id: [42], + relation: 'posts', + relatedSchema: compositeSchema, + limit: null, + }, user, ); - expect(mockRelation.list).toHaveBeenCalledWith( - expect.objectContaining({ pagination: { size: 5, number: 1 } }), - ); + expect(result[0].recordId).toEqual(['acme', 7]); }); - it('should not apply pagination when limit is null', async () => { - mockRelation.list.mockResolvedValue([]); + it('applies pagination when limit is a number', async () => { + mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); await port.getRelatedData( - { collection: 'users', id: [42], relation: 'posts', limit: null }, + { collection: 'users', id: [42], relation: 'posts', relatedSchema: postsSchema, limit: 5 }, user, ); - expect(mockRelation.list).toHaveBeenCalledWith({}); + expect(mockRelation.list).toHaveBeenCalledWith( + expect.objectContaining({ pagination: { size: 5, number: 1 } }), + ); }); - it('should fallback to relationName when no CollectionSchema exists', async () => { - mockRelation.list.mockResolvedValue([{ id: 1 }]); + it('does not apply pagination when limit is null', async () => { + mockRelation.list.mockResolvedValue([]); - const result = await port.getRelatedData( + await port.getRelatedData( { collection: 'users', id: [42], - relation: 'unknownRelation', + relation: 'posts', + relatedSchema: postsSchema, limit: null, }, user, ); - expect(result[0].collectionName).toBe('unknownRelation'); - expect(result[0].recordId).toEqual([1]); + expect(mockRelation.list).toHaveBeenCalledWith({}); }); - it('should return an empty array when no related data exists', async () => { + it('returns an empty array when no related data exists', async () => { mockRelation.list.mockResolvedValue([]); expect( @@ -328,6 +402,7 @@ describe('AgentClientAgentPort', () => { collection: 'users', id: [42], relation: 'posts', + relatedSchema: postsSchema, limit: null, }, user, @@ -335,7 +410,7 @@ describe('AgentClientAgentPort', () => { ).toEqual([]); }); - it('should forward fields to the list call when provided', async () => { + it('forwards fields to the list call when provided', async () => { mockRelation.list.mockResolvedValue([{ id: 10, title: 'Post A' }]); await port.getRelatedData( @@ -343,6 +418,7 @@ describe('AgentClientAgentPort', () => { collection: 'users', id: [42], relation: 'posts', + relatedSchema: postsSchema, limit: null, fields: ['title'], }, @@ -354,11 +430,17 @@ describe('AgentClientAgentPort', () => { ); }); - it('should omit fields from the list call when not provided', async () => { + it('omits fields from the list call when not provided', async () => { mockRelation.list.mockResolvedValue([{ id: 10 }]); await port.getRelatedData( - { collection: 'users', id: [42], relation: 'posts', limit: null }, + { + collection: 'users', + id: [42], + relation: 'posts', + relatedSchema: postsSchema, + limit: null, + }, user, ); @@ -366,50 +448,213 @@ describe('AgentClientAgentPort', () => { expect.not.objectContaining({ fields: expect.anything() }), ); }); + }); - it('should restore snake_case field names in recordId and values when agent returns camelCase keys', async () => { - const cache = new SchemaCache(); - cache.set('users', { - collectionName: 'users', - collectionDisplayName: 'Users', - primaryKeyFields: ['id'], + describe('getSingleRelatedData', () => { + // xToOne relations don't expose /relationships/ on the agent. The port reads + // the parent record with a `@@@` projection and unpacks the linkage + // that jsonapi-serializer emits as a nested object on the parent. + const ordersSchema = { + collectionName: 'orders', + collectionDisplayName: 'Orders', + primaryKeyFields: ['id'], + fields: [ + { fieldName: 'id', displayName: 'id', isRelationship: false, type: 'Number' as const }, + { + fieldName: 'reference', + displayName: 'Reference', + isRelationship: false, + type: 'String' as const, + }, + ], + actions: [], + }; + + it('projects the related PK on the parent and unpacks the linkage as RecordData', async () => { + mockCollection.list.mockResolvedValue([{ order: { id: '99' } }]); + + const result = await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'order', + relatedSchema: ordersSchema, + }, + user, + ); + + expect(mockCollection.list).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['order@@@id'] }), + ); + expect(result).toEqual({ + collectionName: 'orders', + recordId: ['99'], + values: { id: '99' }, + }); + }); + + it('projects only the caller field (e.g. referenceField), not the PK — the linkage id comes free', async () => { + mockCollection.list.mockResolvedValue([{ order: { id: '99', reference: 'ORD-2026-001' } }]); + + const result = await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'order', + relatedSchema: ordersSchema, + fields: ['reference'], + }, + user, + ); + + // Single sub-field only: the agent can't parse `fields[order]=id,reference`. + expect(mockCollection.list).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['order@@@reference'] }), + ); + expect(result?.values).toEqual({ id: '99', reference: 'ORD-2026-001' }); + }); + + it('projects at most one sub-field even when the caller passes several', async () => { + mockCollection.list.mockResolvedValue([{ order: { id: '99', reference: 'ORD-2026-001' } }]); + + await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'order', + relatedSchema: ordersSchema, + fields: ['reference', 'label'], + }, + user, + ); + + expect(mockCollection.list).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['order@@@reference'] }), + ); + }); + + // Regression: jsonapi-serializer emits the nested linkage with camelCased attribute + // keys (full_name → fullName). The adapter must restore those keys before returning + // values, otherwise snake_case referenceFields silently resolve to undefined upstream. + it('restores camelCased keys on the linkage when fields are snake_case', async () => { + const snakeSchema = { + ...ordersSchema, fields: [ + { fieldName: 'id', displayName: 'id', isRelationship: false, type: 'Number' as const }, { - fieldName: 'posts', - displayName: 'Posts', - isRelationship: true, - relatedCollectionName: 'posts', + fieldName: 'full_name', + displayName: 'Full name', + isRelationship: false, + type: 'String' as const, }, ], - actions: [], - }); - cache.set('posts', { - collectionName: 'posts', - collectionDisplayName: 'Posts', - primaryKeyFields: ['post_id'], - fields: [], - actions: [], - }); - const localPort = new AgentClientAgentPort({ - agentUrl: 'http://agent', - authSecret: 'secret', - schemaCache: cache, + }; + mockCollection.list.mockResolvedValue([{ order: { id: '99', fullName: 'John Doe' } }]); + + const result = await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'order', + relatedSchema: snakeSchema, + fields: ['full_name'], + }, + user, + ); + + expect(result?.values).toEqual({ id: '99', full_name: 'John Doe' }); + }); + + // Regression: the relation NAME itself can be snake_case (billing_address). jsonapi-serializer + // emits the linkage under the camelCased key (billingAddress), so looking it up by the raw + // name returned null and the relation never loaded. + it('finds the linkage when the relation name is snake_case', async () => { + mockCollection.list.mockResolvedValue([{ billingAddress: { id: '7|2' } }]); + + const result = await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'billing_address', + relatedSchema: { ...ordersSchema, collectionName: 'addresses' }, + }, + user, + ); + + expect(mockCollection.list).toHaveBeenCalledWith( + expect.objectContaining({ fields: ['billing_address@@@id'] }), + ); + expect(result).toEqual({ + collectionName: 'addresses', + recordId: ['7', '2'], + values: { id: '7|2' }, }); - mockRelation.list.mockResolvedValue([{ postId: 99, createdAt: '2024-01-01' }]); + }); - const result = await localPort.getRelatedData( + it('splits composite PKs from the packed "id" linkage', async () => { + const compositeSchema = { + ...ordersSchema, + primaryKeyFields: ['tenantId', 'orderId'], + fields: [ + { + fieldName: 'tenantId', + displayName: 'Tenant', + isRelationship: false, + type: 'String' as const, + }, + { + fieldName: 'orderId', + displayName: 'Order', + isRelationship: false, + type: 'Number' as const, + }, + ], + }; + mockCollection.list.mockResolvedValue([{ order: { id: 'acme|7' } }]); + + const result = await port.getSingleRelatedData( { collection: 'users', id: [42], - relation: 'posts', - limit: null, - fields: ['post_id', 'created_at'], + relation: 'order', + relatedSchema: compositeSchema, + }, + user, + ); + + expect(result?.recordId).toEqual(['acme', '7']); + }); + + it('returns null when the parent has no linkage to the xToOne relation', async () => { + mockCollection.list.mockResolvedValue([{ order: null }]); + + const result = await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'order', + relatedSchema: ordersSchema, + }, + user, + ); + + expect(result).toBeNull(); + }); + + it('returns null when the linkage object is present but has no id', async () => { + mockCollection.list.mockResolvedValue([{ order: {} }]); + + const result = await port.getSingleRelatedData( + { + collection: 'users', + id: [42], + relation: 'order', + relatedSchema: ordersSchema, }, user, ); - expect(result[0].recordId).toEqual([99]); - expect(result[0].values).toEqual({ post_id: 99, created_at: '2024-01-01' }); + expect(result).toBeNull(); }); }); diff --git a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts index 5759b395f1..30c8a4eaca 100644 --- a/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts +++ b/packages/workflow-executor/test/adapters/forest-server-workflow-port.test.ts @@ -641,6 +641,7 @@ describe('ForestServerWorkflowPort', () => { collectionDisplayName: 'Users', primaryKeyFields: ['id'], referenceField: 'name', + futureUnknownField: 'ignored', fields: [ { fieldName: 'store', @@ -648,7 +649,7 @@ describe('ForestServerWorkflowPort', () => { isRelationship: true, relationType: 'BelongsTo', relatedCollectionName: 'stores', - relatedPrimaryKey: 'id', + futureFieldKey: 'ignored', type: null, }, ], @@ -657,8 +658,10 @@ describe('ForestServerWorkflowPort', () => { const result = await port.getCollectionSchema('users', '42'); - expect(result).not.toHaveProperty('referenceField'); - expect(result.fields[0]).not.toHaveProperty('relatedPrimaryKey'); + // Genuinely-unknown keys are stripped; declared fields (referenceField) are preserved. + expect(result).not.toHaveProperty('futureUnknownField'); + expect(result.fields[0]).not.toHaveProperty('futureFieldKey'); + expect(result.referenceField).toBe('name'); expect(result.fields[0]).toMatchObject({ fieldName: 'store', relatedCollectionName: 'stores', @@ -732,6 +735,52 @@ describe('ForestServerWorkflowPort', () => { }); }); + it("strips the target key from relatedCollectionName (Forest 'collection.key' reference)", async () => { + mockQuery.mockResolvedValue({ + collectionName: 'accounts', + collectionDisplayName: 'Accounts', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'store', + displayName: 'Store', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'store.id', + type: null, + }, + ], + actions: [], + }); + + const result = await port.getCollectionSchema('accounts', '42'); + + expect(result.fields[0].relatedCollectionName).toBe('store'); + }); + + it('leaves relatedCollectionName unchanged when it carries no target key (no dot)', async () => { + mockQuery.mockResolvedValue({ + collectionName: 'accounts', + collectionDisplayName: 'Accounts', + primaryKeyFields: ['id'], + fields: [ + { + fieldName: 'store', + displayName: 'Store', + isRelationship: true, + relationType: 'BelongsTo', + relatedCollectionName: 'store', + type: null, + }, + ], + actions: [], + }); + + const result = await port.getCollectionSchema('accounts', '42'); + + expect(result.fields[0].relatedCollectionName).toBe('store'); + }); + it('accepts type File (Forest Admin extension)', async () => { mockQuery.mockResolvedValue({ collectionName: 'users', 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 f7f7a25883..67217b290c 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 @@ -22,6 +22,17 @@ function makeStep( }; } +// Wraps a raw recordId array into a LoadRelatedRecordCandidate for pendingData +// fixtures and assertions. Tests that care about referenceFieldValue pass the +// second argument; everything else gets `null`, which matches the executor's +// behavior when the related collection has no referenceField configured. +function cand( + recordId: Array, + referenceFieldValue: string | null = null, +): { recordId: Array; referenceFieldValue: string | null } { + return { recordId, referenceFieldValue }; +} + function makeRecordRef(overrides: Partial = {}): RecordRef { return { collectionName: 'customers', @@ -41,10 +52,18 @@ function makeRelatedRecordData(overrides: Partial = {}): RecordData } function makeMockAgentPort(relatedData: RecordData[] = [makeRelatedRecordData()]): AgentPort { + // xToOne path uses getSingleRelatedData; mock returns the first relatedData entry shaped + // as a RecordData with the recordId stringified, mirroring the real adapter which packs + // composite ids via "|" and splits them back into strings. Tests can override per-call. + const first = relatedData[0]; + const xToOneCandidate = first ? { ...first, recordId: first.recordId.map(String) } : null; + const getSingleRelatedData = jest.fn(async () => xToOneCandidate); + return { getRecord: jest.fn(), updateRecord: jest.fn(), getRelatedData: jest.fn().mockResolvedValue(relatedData), + getSingleRelatedData, executeAction: jest.fn(), } as unknown as AgentPort; } @@ -164,10 +183,13 @@ function makePendingExecution( type: 'load-related-record', stepIndex: 0, pendingData: { - displayName: 'Order', - name: 'order', - selectedRecordId: [99], - suggestedFields: ['status', 'amount'], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, selectedRecordRef: makeRecordRef(), ...overrides, @@ -191,10 +213,17 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.getRelatedData).toHaveBeenCalledWith( - { collection: 'customers', id: [42], relation: 'order', limit: 1 }, + // BelongsTo: port's xToOne method; no /relationships call. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'order', + relatedSchema: expect.objectContaining({ collectionName: 'orders' }), + }), expect.objectContaining({ id: 1 }), ); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ @@ -204,7 +233,7 @@ describe('LoadRelatedRecordStepExecutor', () => { executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'orders', - recordId: [99], + recordId: ['99'], stepIndex: 0, }), }), @@ -227,6 +256,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Address', isRelationship: true, relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); @@ -297,7 +327,13 @@ describe('LoadRelatedRecordStepExecutor', () => { // Fetches 50 candidates (HasMany) expect(agentPort.getRelatedData).toHaveBeenCalledWith( - { collection: 'customers', id: [42], relation: 'address', limit: 50 }, + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'address', + limit: 50, + relatedSchema: expect.objectContaining({ collectionName: 'addresses' }), + }), expect.objectContaining({ id: 1 }), ); @@ -319,6 +355,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Address', isRelationship: true, relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); @@ -381,6 +418,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Address', isRelationship: true, relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); @@ -423,6 +461,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Address', isRelationship: true, relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); @@ -491,6 +530,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Address', isRelationship: true, relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); @@ -552,6 +592,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Profile', isRelationship: true, relationType: 'HasOne', + relatedCollectionName: 'profiles', }, ], }); @@ -572,20 +613,115 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - // HasOne uses the same fetchFirstCandidate path as BelongsTo — limit: 1 + // HasOne uses the same xToOne path as BelongsTo. No /relationships call. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'profile', + relatedSchema: expect.objectContaining({ collectionName: 'profiles' }), + }), + expect.objectContaining({ id: 1 }), + ); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).toHaveBeenCalledWith( + 'run-1', + expect.objectContaining({ + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'profiles', recordId: ['5'] }), + }), + }), + ); + }); + }); + + // BelongsToMany falls through to the same to-many candidate path as the default + // branch (neither xToOne nor HasMany). Routes through fetchFirstCandidate -> + // fetchCandidates -> getRelatedData with limit: 1, then picks the first row. + describe('executionType=FullyAutomated: BelongsToMany — load direct (Branch B)', () => { + it('fetches 1 related record via /relationships and returns success', async () => { + const belongsToManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'tags', + displayName: 'Tags', + isRelationship: true, + relationType: 'BelongsToMany', + relatedCollectionName: 'tags', + }, + ], + }); + const agentPort = makeMockAgentPort([{ collectionName: 'tags', recordId: [7], values: {} }]); + const mockModel = makeMockModel({ relationName: 'Tags', reasoning: 'Load tags' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: belongsToManySchema }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('success'); + // To-many path: /relationships call with limit: 1, no parent-record projection. expect(agentPort.getRelatedData).toHaveBeenCalledWith( - { collection: 'customers', id: [42], relation: 'profile', limit: 1 }, + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'tags', + limit: 1, + relatedSchema: expect.objectContaining({ collectionName: 'tags' }), + }), expect.objectContaining({ id: 1 }), ); + expect(agentPort.getSingleRelatedData).not.toHaveBeenCalled(); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ executionResult: expect.objectContaining({ - record: expect.objectContaining({ collectionName: 'profiles', recordId: [5] }), + record: expect.objectContaining({ collectionName: 'tags', recordId: [7] }), }), }), ); }); + + // fetchCandidates throws RelatedRecordNotFoundError when the agent returns an + // empty list. Same user-facing message as the other empty-result paths. + it('returns error when getRelatedData returns an empty array', async () => { + const belongsToManySchema = makeCollectionSchema({ + fields: [ + { + fieldName: 'tags', + displayName: 'Tags', + isRelationship: true, + relationType: 'BelongsToMany', + relatedCollectionName: 'tags', + }, + ], + }); + const agentPort = makeMockAgentPort([]); + const mockModel = makeMockModel({ relationName: 'Tags', reasoning: 'Load tags' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: belongsToManySchema }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The related record could not be found. It may have been deleted.', + ); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); }); describe('without executionType=FullyAutomated: awaiting-input (Branch C)', () => { @@ -599,11 +735,18 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('awaiting-input'); - expect(agentPort.getRelatedData).toHaveBeenCalledWith( - { collection: 'customers', id: [42], relation: 'order', limit: 50 }, + // BelongsTo → xToOne path. No /relationships call. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'order', + relatedSchema: expect.objectContaining({ collectionName: 'orders' }), + }), expect.objectContaining({ id: 1 }), ); - // Single record → only select-relation AI call + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + // xToOne has exactly one candidate → only select-relation AI call (no field/record selection) expect(mockModel.bindTools).toHaveBeenCalledTimes(1); expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', @@ -611,10 +754,13 @@ describe('LoadRelatedRecordStepExecutor', () => { type: 'load-related-record', stepIndex: 0, pendingData: { - displayName: 'Order', - name: 'order', - selectedRecordId: [99], - suggestedFields: [], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand(['99'])], + suggestedRecord: cand(['99']), }, selectedRecordRef: expect.objectContaining({ collectionName: 'customers', @@ -624,17 +770,19 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); + // Uses HasMany ('Address') because BelongsTo/HasOne now short-circuit to a single + // xToOne candidate (no select-fields/select-record-by-content AI calls). it('runs field-selection + record-selection AI calls when multiple related records exist', async () => { const relatedData: RecordData[] = [ - { collectionName: 'orders', recordId: [1], values: { status: 'pending' } }, - { collectionName: 'orders', recordId: [2], values: { status: 'completed' } }, + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, ]; const agentPort = makeMockAgentPort(relatedData); - const ordersSchema = makeCollectionSchema({ - collectionName: 'orders', - collectionDisplayName: 'Orders', - fields: [{ fieldName: 'status', displayName: 'Status', isRelationship: false }], + const addressesSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], }); const invoke = jest @@ -643,19 +791,19 @@ describe('LoadRelatedRecordStepExecutor', () => { tool_calls: [ { name: 'select-relation', - args: { relationName: 'Order', reasoning: 'Load order' }, + args: { relationName: 'Address', reasoning: 'Load address' }, id: 'c1', }, ], }) .mockResolvedValueOnce({ - tool_calls: [{ name: 'select-fields', args: { fieldNames: ['Status'] }, id: 'c2' }], + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], }) .mockResolvedValueOnce({ tool_calls: [ { name: 'select-record-by-content', - args: { recordIndex: 1, reasoning: 'Completed is best' }, + args: { recordIndex: 1, reasoning: 'Lyon is best' }, id: 'c3', }, ], @@ -670,7 +818,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runStore, workflowPort: makeMockWorkflowPort({ customers: makeCollectionSchema(), - orders: ordersSchema, + addresses: addressesSchema, }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -683,25 +831,30 @@ describe('LoadRelatedRecordStepExecutor', () => { 'run-1', expect.objectContaining({ pendingData: { - displayName: 'Order', - name: 'order', - selectedRecordId: [2], // record at index 1 - suggestedFields: ['status'], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [cand([1]), cand([2])], + suggestedRecord: cand([2]), // record at index 1 }, }), ); }); + // Uses HasMany ('Address') because BelongsTo/HasOne now short-circuit to a single + // xToOne candidate (no field/record AI selection). it('skips field-selection AI call when related collection has no non-relation fields', async () => { const relatedData: RecordData[] = [ - { collectionName: 'orders', recordId: [1], values: {} }, - { collectionName: 'orders', recordId: [2], values: {} }, + { collectionName: 'addresses', recordId: [1], values: {} }, + { collectionName: 'addresses', recordId: [2], values: {} }, ]; const agentPort = makeMockAgentPort(relatedData); - const ordersSchema = makeCollectionSchema({ - collectionName: 'orders', - collectionDisplayName: 'Orders', + const addressesSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', fields: [], }); @@ -711,7 +864,7 @@ describe('LoadRelatedRecordStepExecutor', () => { tool_calls: [ { name: 'select-relation', - args: { relationName: 'Order', reasoning: 'Load order' }, + args: { relationName: 'Address', reasoning: 'Load address' }, id: 'c1', }, ], @@ -735,7 +888,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runStore, workflowPort: makeMockWorkflowPort({ customers: makeCollectionSchema(), - orders: ordersSchema, + addresses: addressesSchema, }), }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -749,23 +902,76 @@ describe('LoadRelatedRecordStepExecutor', () => { 'run-1', expect.objectContaining({ pendingData: expect.objectContaining({ - selectedRecordId: [1], - suggestedFields: [], + suggestedRecord: cand([1]), + availableRecordIds: [cand([1]), cand([2])], }), }), ); }); + + // When the xToOne relation has no linked record, the step still awaits input with an empty + // 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 runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls[0][1]; + expect(saved.pendingData.availableRecordIds).toEqual([]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + }); + + it('returns awaiting-input with an empty candidate list when the to-many relation has no records', async () => { + const agentPort = makeMockAgentPort([]); // getRelatedData → [] + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'Load address' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }), + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls[0][1]; + expect(saved.pendingData.availableRecordIds).toEqual([]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(agentPort.getRelatedData).toHaveBeenCalled(); + }); }); describe('confirmation accepted (Branch A)', () => { - it('uses selectedRecordId from pendingData, no getRelatedData call', async () => { + it('uses suggestedRecord from pendingData, no getRelatedData call', async () => { const agentPort = makeMockAgentPort(); const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: ['status', 'amount'], - selectedRecordId: [99], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: true }, }); @@ -788,22 +994,24 @@ describe('LoadRelatedRecordStepExecutor', () => { record: expect.objectContaining({ collectionName: 'orders', recordId: [99] }), }), pendingData: expect.objectContaining({ - displayName: 'Order', - name: 'order', - selectedRecordId: [99], + suggestedField: { name: 'order', displayName: 'Order' }, + suggestedRecord: cand([99]), }), }), ); }); - it('uses selectedRecordId when the user overrides the AI suggestion', async () => { + it('uses suggestedRecord when the user does not override the AI suggestion', async () => { const agentPort = makeMockAgentPort(); const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: ['status', 'amount'], - selectedRecordId: [42], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([42])], + suggestedRecord: cand([42]), }, userConfirmation: { userConfirmed: true }, }); @@ -826,17 +1034,48 @@ describe('LoadRelatedRecordStepExecutor', () => { }), ); }); + + // Confirming while the candidate list is empty (no suggestedRecord, no override) leaves + // nothing to load → error. The front is expected to prevent this by offering a relation switch. + it('returns error when confirming an empty candidate list with no selection', async () => { + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [], + }, + userConfirmation: { userConfirmed: true }, + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort: makeMockAgentPort(), runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'The related record could not be found. It may have been deleted.', + ); + }); }); describe('confirmation with user override of selectedRecordId (Branch A)', () => { - it('preserves AI suggestion in pendingData and writes user choice to executionParams', async () => { + it('preserves AI suggestion in pendingData and writes user choice to executionResult', async () => { // Persisted state: AI suggested record [99], awaiting confirmation. const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - selectedRecordId: [99], - suggestedFields: ['status', 'amount'], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99]), cand([42])], + suggestedRecord: cand([99]), }, }); const agentPort = makeMockAgentPort(); @@ -862,9 +1101,8 @@ describe('LoadRelatedRecordStepExecutor', () => { expect.objectContaining({ type: 'load-related-record', pendingData: expect.objectContaining({ - displayName: 'Order', - name: 'order', - selectedRecordId: [99], // AI suggestion preserved + suggestedField: { name: 'order', displayName: 'Order' }, + suggestedRecord: cand([99]), // AI suggestion preserved }), executionResult: expect.objectContaining({ record: expect.objectContaining({ collectionName: 'orders', recordId: [42] }), @@ -874,15 +1112,18 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('confirmation with user override of relation name (Branch A)', () => { + describe('confirmation with user override of relation (Branch A)', () => { it('re-derives relatedCollectionName when the user switches to a different relation', async () => { - // AI suggested "order" (→ orders collection). User switches to "address" (→ addresses). + // AI suggested "order" (→ orders collection). User switches to "Address" (→ addresses). const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - selectedRecordId: [99], - suggestedFields: [], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, }); const runStore = makeMockRunStore({ @@ -892,7 +1133,7 @@ describe('LoadRelatedRecordStepExecutor', () => { runStore, incomingPendingData: { userConfirmed: true, - name: 'address', + fieldName: 'address', selectedRecordId: [7], }, }); @@ -906,9 +1147,8 @@ describe('LoadRelatedRecordStepExecutor', () => { expect.objectContaining({ // AI suggestion preserved on pendingData pendingData: expect.objectContaining({ - name: 'order', - displayName: 'Order', - selectedRecordId: [99], + suggestedField: { name: 'order', displayName: 'Order' }, + suggestedRecord: cand([99]), }), // User-overridden relation resolves to the addresses collection executionParams: { name: 'address', displayName: 'Address' }, @@ -936,10 +1176,10 @@ describe('LoadRelatedRecordStepExecutor', () => { }); const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: [], - selectedRecordId: [99], + availableFields: [{ name: 'order', displayName: 'Order' }], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: true }, }); @@ -977,10 +1217,10 @@ describe('LoadRelatedRecordStepExecutor', () => { }); const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: [], - selectedRecordId: [99], + availableFields: [{ name: 'order', displayName: 'Order' }], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: true }, }); @@ -1000,7 +1240,38 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - it('uses overridden relation name from pendingData to derive relatedCollectionName', async () => { + it('returns error when the confirmed relation is not in availableFields (stale/renamed)', async () => { + const agentPort = makeMockAgentPort(); + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), + }, + // Frontend confirms a relation that no longer exists in availableFields. + userConfirmation: { userConfirmed: true, fieldName: 'ghost', selectedRecordId: [7] }, + }); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + 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.getRelatedData).not.toHaveBeenCalled(); + expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + + it('uses overridden suggestedField from pendingData to derive relatedCollectionName', async () => { const schema = makeCollectionSchema({ fields: [ { @@ -1019,13 +1290,17 @@ describe('LoadRelatedRecordStepExecutor', () => { }, ], }); - // User overrode AI's suggestion of 'order' to 'address' via PATCH + // Pending data already reflects 'address' as the suggested relation (e.g. user override + // was previously persisted, or the AI picked it directly). const execution = makePendingExecution({ pendingData: { - displayName: 'Address', - name: 'address', - suggestedFields: [], - selectedRecordId: [77], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [cand([77])], + suggestedRecord: cand([77]), }, userConfirmation: { userConfirmed: true }, }); @@ -1053,10 +1328,10 @@ describe('LoadRelatedRecordStepExecutor', () => { const execution = makePendingExecution({ selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: [], - selectedRecordId: [99], + availableFields: [{ name: 'order', displayName: 'Order' }], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: true }, }); @@ -1081,10 +1356,13 @@ describe('LoadRelatedRecordStepExecutor', () => { const agentPort = makeMockAgentPort(); const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: ['status', 'amount'], - selectedRecordId: [99], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: false }, }); @@ -1102,12 +1380,393 @@ describe('LoadRelatedRecordStepExecutor', () => { 'run-1', expect.objectContaining({ executionResult: { skipped: true }, - pendingData: expect.objectContaining({ displayName: 'Order', name: 'order' }), + pendingData: expect.objectContaining({ + suggestedField: { name: 'order', displayName: 'Order' }, + }), }), ); }); }); + // The frontend lets the user switch to a different relation before confirming. To + // populate the new relation's `availableRecordIds`, it POSTs a "preview" patch: + // `{ fieldName }` with no `userConfirmed`. The executor re-lists candidates, + // refreshes pendingData, clears userConfirmation, and stays awaiting-input. + describe('field-preview patch (Branch A — no confirm)', () => { + it('re-lists candidates for the new relation and stays awaiting-input', async () => { + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand(['99'])], + suggestedRecord: cand(['99']), + }, + }); + // User switched to Address (HasMany). The default mock returns the order fixture; + // override with address candidates so we can verify the new IDs land in pendingData. + const agentPort = makeMockAgentPort([ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]); + const addressesSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + // The schema-cache fetch for 'addresses' goes through the workflow port. + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: addressesSchema, + }); + // With 2 candidates, selectBestFromRelatedData calls the AI for field + record + // selection. Wire those up so the preview can pick a suggestedRecord. + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c1' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 1, reasoning: 'Lyon' }, + id: 'c2', + }, + ], + }); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort, + incomingPendingData: { fieldName: 'address' }, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + + // Two saves: one from patchAndReloadPendingData persisting userConfirmation, + // one from refreshCandidatesForField writing the new pendingData. The latter + // is the one the frontend reads. + const finalSave = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(finalSave).toEqual( + expect.objectContaining({ + type: 'load-related-record', + // userConfirmation cleared so the next bodyless trigger re-emits awaiting-input + // cleanly via handleConfirmationFlow (no stale fieldName ghost-confirms). + userConfirmation: undefined, + pendingData: expect.objectContaining({ + // availableFields is immutable — only suggestedField + candidates change. + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [cand([1]), cand([2])], + suggestedRecord: cand([2]), // AI's select-record-by-content pick + }), + }), + ); + }); + + it('reruns xToOne candidate lookup when previewing a BelongsTo relation', async () => { + // Same setup but switching to Order (BelongsTo). Verifies the xToOne path is + // used inside refreshCandidatesForField — no AI calls, single candidate from + // the parent's projected relation linkage. + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [cand([1]), cand([2])], + suggestedRecord: cand([2]), + }, + }); + const agentPort = makeMockAgentPort(); // default: order recordId [99] + const workflowPort = makeMockWorkflowPort({ customers: makeCollectionSchema() }); + const mockModel = makeMockModel({}); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort, + incomingPendingData: { fieldName: 'order' }, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // xToOne path goes through the port's getSingleRelatedData method. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'order', + relatedSchema: expect.objectContaining({ collectionName: 'orders' }), + }), + expect.objectContaining({ id: 1 }), + ); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + + const finalSave = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(finalSave).toEqual( + expect.objectContaining({ + userConfirmation: undefined, + pendingData: expect.objectContaining({ + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand(['99'])], + suggestedRecord: cand(['99']), + }), + }), + ); + }); + + it('returns error when the previewed relation does not exist on the source collection', async () => { + const execution = makePendingExecution(); + const agentPort = makeMockAgentPort(); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ + agentPort, + runStore, + incomingPendingData: { fieldName: 'notAField' }, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + }); + + // refreshCandidatesForField guards against a corrupted/partial execution where + // a preview patch lands but the persisted execution carries no pendingData. + // Twin of the "no pending data in confirmation flow" test for the resolve path. + it('returns error when execution exists but pendingData is absent', async () => { + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([ + { + type: 'load-related-record', + stepIndex: 0, + selectedRecordRef: makeRecordRef(), + }, + ]), + }); + const context = makeContext({ + runStore, + incomingPendingData: { fieldName: 'address' }, + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + const result = await executor.execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toBe( + 'An unexpected error occurred while processing this step.', + ); + }); + }); + + // The related collection may have a layout-level `referenceField` (e.g. `name`, + // `title`) used to display records in the UI. When configured, candidate records + // in pendingData carry the resolved value so the awaiting-input dropdown can show + // human-readable labels instead of raw ids. + describe('referenceField propagation in pendingData (Branch C)', () => { + it('exposes referenceFieldValue from the related collection on each HasMany candidate', async () => { + // HasMany path: fetchRelatedData returns full rows; the executor reads + // values[referenceField] for each candidate. + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressesSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + referenceField: 'city', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-relation', + args: { relationName: 'Address', reasoning: 'Load address' }, + id: 'c1', + }, + ], + }) + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'Paris' }, + id: 'c3', + }, + ], + }); + const model = { + bindTools: jest.fn().mockReturnValue({ invoke }), + } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: addressesSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([ + { recordId: [1], referenceFieldValue: 'Paris' }, + { recordId: [2], referenceFieldValue: 'Lyon' }, + ]); + expect(saved.pendingData.suggestedRecord).toEqual({ + recordId: [1], + referenceFieldValue: 'Paris', + }); + }); + + it('exposes a null referenceFieldValue when a HasMany candidate has no value for the reference field', async () => { + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: null } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const addressesSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + referenceField: 'city', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'Load address' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }), + addresses: addressesSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([ + { recordId: [1], referenceFieldValue: null }, + ]); + }); + + it('passes the referenceField to getSingleRelatedData and extracts its value from the result', async () => { + const ordersSchema = makeCollectionSchema({ + collectionName: 'orders', + collectionDisplayName: 'Orders', + referenceField: 'reference', + fields: [{ fieldName: 'reference', displayName: 'Reference', isRelationship: false }], + }); + + const agentPort = makeMockAgentPort(); + (agentPort.getSingleRelatedData as jest.Mock).mockResolvedValue({ + collectionName: 'orders', + recordId: ['99'], + values: { id: '99', reference: 'ORD-2026-001' }, + }); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'Load order' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + orders: ordersSchema, + }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'order', + fields: ['reference'], + }), + expect.objectContaining({ id: 1 }), + ); + + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.suggestedRecord).toEqual({ + recordId: ['99'], + referenceFieldValue: 'ORD-2026-001', + }); + }); + + it('falls back to null referenceFieldValue when the related collection has no referenceField configured', async () => { + // 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 runStore = makeMockRunStore(); + const context = makeContext({ model: mockModel.model, agentPort, runStore }); + const executor = new LoadRelatedRecordStepExecutor(context); + + await executor.execute(); + + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.not.objectContaining({ fields: expect.anything() }), + expect.objectContaining({ id: 1 }), + ); + + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.suggestedRecord).toEqual({ + recordId: ['99'], + referenceFieldValue: null, + }); + }); + }); + describe('trigger before PATCH (Branch A)', () => { it('re-emits awaiting-input when userConfirmation is not yet set', async () => { const agentPort = makeMockAgentPort(); @@ -1174,6 +1833,40 @@ 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 () => { + const schema = makeCollectionSchema({ + fields: [ + { + fieldName: 'order', + displayName: 'Order', + isRelationship: true, + relationType: 'BelongsTo', + }, + ], + }); + const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const runStore = makeMockRunStore(); + const context = makeContext({ + model: mockModel.model, + runStore, + workflowPort: makeMockWorkflowPort({ customers: schema }), + stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + }); + const executor = new LoadRelatedRecordStepExecutor(context); + + 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(runStore.saveStepExecution).not.toHaveBeenCalled(); + }); + }); + describe('RelatedRecordNotFoundError', () => { it('returns error when BelongsTo getRelatedData returns empty array (Branch B)', async () => { const agentPort = makeMockAgentPort([]); @@ -1204,6 +1897,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Address', isRelationship: true, relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); @@ -1227,22 +1921,6 @@ describe('LoadRelatedRecordStepExecutor', () => { ); expect(runStore.saveStepExecution).not.toHaveBeenCalled(); }); - - it('returns error when getRelatedData returns empty array (Branch C)', async () => { - const agentPort = makeMockAgentPort([]); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); - const runStore = makeMockRunStore(); - const context = makeContext({ model: mockModel.model, agentPort, runStore }); - const executor = new LoadRelatedRecordStepExecutor(context); - - const result = await executor.execute(); - - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - 'The related record could not be found. It may have been deleted.', - ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); - }); }); describe('RunStorePortError post-load', () => { @@ -1269,10 +1947,13 @@ describe('LoadRelatedRecordStepExecutor', () => { it('returns error outcome when saveStepExecution fails after load (Branch A confirmed)', async () => { const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: ['status', 'amount'], - selectedRecordId: [99], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: true }, }); @@ -1303,6 +1984,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Order', isRelationship: true, relationType: 'BelongsTo', + relatedCollectionName: 'orders', }, ], }); @@ -1379,10 +2061,12 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('infra error propagation', () => { + // Uses HasMany ('Address') because xToOne reads from the parent record via getRecord, + // not getRelatedData. The infra-error contract is the same for both port methods. 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: 'Order', reasoning: 'test' }); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); const context = makeContext({ model: mockModel.model, agentPort, @@ -1397,7 +2081,7 @@ 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: 'Order', reasoning: 'test' }); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); const context = makeContext({ model: mockModel.model, agentPort }); const executor = new LoadRelatedRecordStepExecutor(context); @@ -1411,7 +2095,7 @@ describe('LoadRelatedRecordStepExecutor', () => { (agentPort.getRelatedData as jest.Mock).mockRejectedValue( new AgentPortError('getRelatedData', new Error('DB connection lost')), ); - const mockModel = makeMockModel({ relationName: 'Order', reasoning: 'test' }); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'test' }); const context = makeContext({ model: mockModel.model, agentPort, @@ -1451,6 +2135,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Invoice', isRelationship: true, relationType: 'BelongsTo', + relatedCollectionName: 'invoices', }, ], }); @@ -1518,9 +2203,10 @@ describe('LoadRelatedRecordStepExecutor', () => { 'run-1', expect.objectContaining({ pendingData: expect.objectContaining({ - displayName: 'Invoice', - name: 'invoice', - selectedRecordId: [55], + suggestedField: { name: 'invoice', displayName: 'Invoice' }, + // xToOne path packs the related PK as a string via split('|') of the + // agent's serialized relation id. + suggestedRecord: cand(['55']), }), selectedRecordRef: expect.objectContaining({ recordId: [99], collectionName: 'orders' }), }), @@ -1639,10 +2325,13 @@ describe('LoadRelatedRecordStepExecutor', () => { it('returns error outcome when saveStepExecution fails on user reject (Branch A)', async () => { const execution = makePendingExecution({ pendingData: { - displayName: 'Order', - name: 'order', - suggestedFields: ['status', 'amount'], - selectedRecordId: [99], + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand([99])], + suggestedRecord: cand([99]), }, userConfirmation: { userConfirmed: false }, }); @@ -1670,6 +2359,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Order', isRelationship: true, relationType: 'BelongsTo', + relatedCollectionName: 'orders', }, ], }); @@ -1685,17 +2375,34 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - expect(agentPort.getRelatedData).toHaveBeenCalledWith( - { collection: 'customers', id: [42], relation: 'order', limit: 1 }, + // BelongsTo → xToOne path: port's getSingleRelatedData method. + expect(agentPort.getSingleRelatedData).toHaveBeenCalledWith( + expect.objectContaining({ + collection: 'customers', + id: [42], + relation: 'order', + }), expect.objectContaining({ id: 1 }), ); + expect(agentPort.getRelatedData).not.toHaveBeenCalled(); }); }); describe('schema caching', () => { - it('fetches getCollectionSchema once per collection even when called twice (Branch B)', async () => { - const workflowPort = makeMockWorkflowPort(); + // Both xToOne and HasMany now fetch the related schema (xToOne reads + // relatedSchema.referenceField for the dropdown label projection). The test + // asserts each schema is fetched at most once per run. + it('fetches getCollectionSchema once per collection (parent + related, no duplicate fetches)', async () => { + const workflowPort = makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + }), + }); + const mockModel = makeMockModel({ relationName: 'Address', reasoning: 'Load address' }); const context = makeContext({ + model: mockModel.model, workflowPort, stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), }); @@ -1703,7 +2410,10 @@ describe('LoadRelatedRecordStepExecutor', () => { await executor.execute(); - expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(1); + // Parent (customers) and related (addresses) — fetched once each, no duplicates. + expect(workflowPort.getCollectionSchema).toHaveBeenCalledTimes(2); + expect(workflowPort.getCollectionSchema).toHaveBeenCalledWith('customers', 'run-1'); + expect(workflowPort.getCollectionSchema).toHaveBeenCalledWith('addresses', 'run-1'); }); }); @@ -1722,9 +2432,10 @@ describe('LoadRelatedRecordStepExecutor', () => { stepIndex: 3, selectedRecordRef: makeRecordRef(), pendingData: { - displayName: 'Invoice', - name: 'invoice', - selectedRecordId: [55], + availableFields: [{ name: 'invoice', displayName: 'Invoice' }], + suggestedField: { name: 'invoice', displayName: 'Invoice' }, + availableRecordIds: [cand([55])], + suggestedRecord: cand([55]), }, }; @@ -1737,6 +2448,7 @@ describe('LoadRelatedRecordStepExecutor', () => { displayName: 'Order', isRelationship: true, relationType: 'BelongsTo', + relatedCollectionName: 'orders', }, ], }); diff --git a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts index 9a79847e02..a11f34029f 100644 --- a/packages/workflow-executor/test/executors/step-execution-formatters.test.ts +++ b/packages/workflow-executor/test/executors/step-execution-formatters.test.ts @@ -38,9 +38,10 @@ describe('StepExecutionFormatters', () => { stepIndex: 1, selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, pendingData: { - displayName: 'Address', - name: 'address', - selectedRecordId: [1], + availableFields: [{ name: 'address', displayName: 'Address' }], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [{ recordId: [1], referenceFieldValue: null }], + suggestedRecord: { recordId: [1], referenceFieldValue: null }, }, }; diff --git a/packages/workflow-executor/test/executors/step-summary-builder.test.ts b/packages/workflow-executor/test/executors/step-summary-builder.test.ts index 10dc86fea6..371111da20 100644 --- a/packages/workflow-executor/test/executors/step-summary-builder.test.ts +++ b/packages/workflow-executor/test/executors/step-summary-builder.test.ts @@ -275,9 +275,10 @@ describe('StepSummaryBuilder', () => { stepIndex: 1, selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, pendingData: { - displayName: 'Address', - name: 'address', - selectedRecordId: [1], + availableFields: [{ name: 'address', displayName: 'Address' }], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [{ recordId: [1], referenceFieldValue: null }], + suggestedRecord: { recordId: [1], referenceFieldValue: null }, }, }; @@ -462,7 +463,12 @@ describe('StepSummaryBuilder', () => { type: 'load-related-record', stepIndex: 1, selectedRecordRef: { collectionName: 'customers', recordId: [42], stepIndex: 0 }, - pendingData: { displayName: 'Address', name: 'address', selectedRecordId: [1] }, + pendingData: { + availableFields: [{ name: 'address', displayName: 'Address' }], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [{ recordId: [1], referenceFieldValue: null }], + suggestedRecord: { recordId: [1], referenceFieldValue: null }, + }, executionResult: { relation: { name: 'address', displayName: 'Address' }, record: { collectionName: 'addresses', recordId: [1], stepIndex: 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 72738d04c7..33ab88a942 100644 --- a/packages/workflow-executor/test/http/pending-data-validators.test.ts +++ b/packages/workflow-executor/test/http/pending-data-validators.test.ts @@ -155,24 +155,40 @@ describe('patchBodySchemas', () => { }); }); - it('accepts confirmation with both name and selectedRecordId (relation override)', () => { - expect(schema.parse({ userConfirmed: true, name: 'address', selectedRecordId: [7] })).toEqual( - { userConfirmed: true, name: 'address', selectedRecordId: [7] }, + it('accepts confirmation with both fieldName and selectedRecordId (relation override)', () => { + expect( + schema.parse({ + userConfirmed: true, + fieldName: 'address', + selectedRecordId: [7], + }), + ).toEqual({ userConfirmed: true, fieldName: 'address', selectedRecordId: [7] }); + }); + + it('rejects fieldName override on confirm without selectedRecordId — original record ID belongs to a different collection', () => { + expect(() => schema.parse({ userConfirmed: true, fieldName: 'address' })).toThrow( + 'selectedRecordId is required when confirming with a relation override', ); }); - it('rejects name override without selectedRecordId — original record ID belongs to a different collection', () => { - expect(() => schema.parse({ userConfirmed: true, name: 'address' })).toThrow( - 'selectedRecordId is required when overriding the relation name', - ); - }); - - it('rejects empty string name — empty string is not a valid relation name', () => { - expect(() => schema.parse({ userConfirmed: true, name: '' })).toThrow(); + it('rejects empty string fieldName — empty string is not a valid field name', () => { + expect(() => schema.parse({ userConfirmed: true, fieldName: '' })).toThrow(); }); it('rejects unknown fields (strict schema)', () => { expect(() => schema.parse({ userConfirmed: true, extra: 'leak' })).toThrow(); }); + + // Preview patch: fieldName alone, no userConfirmed. The executor uses this to + // re-list candidates for a different relation without finalizing the step. + it('accepts a preview patch — fieldName alone, no userConfirmed', () => { + expect(schema.parse({ fieldName: 'address' })).toEqual({ + fieldName: 'address', + }); + }); + + it('rejects an empty patch — must carry either userConfirmed or a fieldName preview', () => { + expect(() => schema.parse({})).toThrow(); + }); }); }); diff --git a/packages/workflow-executor/test/integration/workflow-execution.test.ts b/packages/workflow-executor/test/integration/workflow-execution.test.ts index 9e57a2dabc..2089b18bb4 100644 --- a/packages/workflow-executor/test/integration/workflow-execution.test.ts +++ b/packages/workflow-executor/test/integration/workflow-execution.test.ts @@ -158,6 +158,7 @@ function createMockAgentPort(): jest.Mocked { values: { id: 42, status: 'active' }, }), getRelatedData: jest.fn().mockResolvedValue([]), + getSingleRelatedData: jest.fn().mockResolvedValue(null), executeAction: jest.fn().mockResolvedValue(undefined), getActionFormInfo: jest.fn().mockResolvedValue({ hasForm: false }), probe: jest.fn().mockResolvedValue(undefined), @@ -503,9 +504,13 @@ describe('workflow execution (integration)', () => { }); const agentPort = createMockAgentPort(); - agentPort.getRelatedData.mockResolvedValue([ - { collectionName: 'orders', recordId: [99], values: { id: 99, total: 100 } }, - ]); + // BelongsTo → xToOne path: the port resolves the related record via getSingleRelatedData, + // which the adapter implements by projecting on the parent's record endpoint. + agentPort.getSingleRelatedData.mockResolvedValue({ + collectionName: 'orders', + recordId: ['99'], + values: { id: '99' }, + }); const { server, runStore } = createIntegrationSetup({ workflowPort, @@ -547,7 +552,9 @@ describe('workflow execution (integration)', () => { type: 'load-related-record', executionResult: { relation: { name: 'order', displayName: 'Order' }, - record: { collectionName: 'orders', recordId: [99], stepIndex: 0 }, + // xToOne packs the related PK as a string via split('|') of the agent's + // serialized relation id. + record: { collectionName: 'orders', recordId: ['99'], stepIndex: 0 }, }, }), );