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 170f91b347..c011f4e67d 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,3 +1,4 @@ +/* eslint-disable max-classes-per-file */ import type { StepExecutionResult } from '../types/execution-context'; import type { LoadRelatedRecordCandidate, @@ -22,6 +23,7 @@ import { RelatedRecordNotFoundError, RelationNotFoundError, StepStateError, + extractErrorMessage, } from '../errors'; import RecordStepExecutor from './record-step-executor'; import { StepExecutionMode } from '../types/validated/step-definition'; @@ -42,6 +44,10 @@ Choose the fields that are most useful for determining which record best matches const SELECT_RECORD_SYSTEM_PROMPT = `You are an AI agent selecting the most relevant related record from a list of candidates. Choose the record that best matches the user request based on the provided field values.`; +// Only appended when -1 is a valid answer. The base prompt tells the AI to always pick, so without +// this it forces a weak match on an impossible request instead of declining. +const SELECT_RECORD_NONE_ALLOWED_PROMPT = `If the request states a specific requirement that NO candidate satisfies, return -1 instead of forcing an arbitrary or loosely-related match. Do NOT return -1 merely because a match is imperfect — only when no candidate genuinely fits the request.`; + // Bound only what is sent to the AI in selectBestRecordIndex — the full candidate list is // always returned to the front via availableRecordIds. These cap the prompt size, not the data. const MAX_RELEVANT_FIELDS = 6; @@ -76,6 +82,19 @@ function isFollowableRelation(field: FieldSchema): boolean { return field.isRelationship && Boolean(field.relatedCollectionName || field.polymorphicTypeField); } +// Internal marker: wraps any failure raised while calling the AI (timeout, provider outage, +// missing/malformed tool call, out-of-range choice) so the step handlers can degrade to the +// deterministic Manual path instead of erroring the run. Never leaves this module. +class AiAssistUnavailableError extends Error { + readonly reason: unknown; + + constructor(reason: unknown) { + super('AI assistance unavailable'); + this.name = 'AiAssistUnavailableError'; + this.reason = reason; + } +} + export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { protected async doExecute(): Promise { // Branch A -- Re-entry after pending execution found in RunStore @@ -103,22 +122,41 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { - if (!execution.pendingData) { + if (!execution.pendingData || !execution.selectedRecordRef) { throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); } + const useAi = this.context.stepDefinition.executionType !== StepExecutionMode.Manual; const schema = await this.getCollectionSchema(execution.selectedRecordRef.collectionName); const target = await this.buildTarget(schema, fieldName, execution.selectedRecordRef); - const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target); + + // A field switch must not error the run: an AI failure/timeout while re-listing degrades to the + // no-AI candidate path (no suggestion), like the first-call degrade. + let aiSuggested = useAi; + let candidates; + + try { + candidates = await this.collectCandidateIds(target, useAi); + } catch (error) { + if (!(useAi && error instanceof AiAssistUnavailableError)) throw error; + this.logAiDegrade(error.reason); + aiSuggested = false; + candidates = await this.collectCandidateIds(target, false); + } + + const { availableRecordIds, suggestedRecord } = candidates; await this.context.runStore.saveStepExecution(this.context.runId, { ...execution, userConfirmation: undefined, + // Rebuild pendingData for the new relation from scratch (retain only the immutable field list) + // so no stale suggestion state — suggestedRecord or suggestNoRecord — survives the field switch. pendingData: { - ...execution.pendingData, + availableFields: execution.pendingData.availableFields, suggestedField: { name: target.name, displayName: target.displayName }, availableRecordIds, suggestedRecord, + ...(aiSuggested && !suggestedRecord && { suggestNoRecord: true }), }, }); @@ -127,23 +165,51 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { stepDefinition: step } = this.context; - const target = await this.resolveTarget(); + const useAi = step.executionType !== StepExecutionMode.Manual; + + try { + if (step.executionType === StepExecutionMode.FullyAutomated) { + return await this.resolveAndLoadAutomatic(); + } + + const target = await this.resolveTarget(useAi); + const sourceSchema = await this.getCollectionSchema(target.selectedRecordRef.collectionName); + + return await this.saveAndAwaitInput(target, sourceSchema, useAi); + } catch (error) { + // AI failure/timeout in any AI mode (relation pick, field/record ranking, or a Full AI + // auto-load) degrades to Manual instead of erroring the run — never auto-skip. + if (useAi && error instanceof AiAssistUnavailableError) { + this.logAiDegrade(error.reason); - // Branch B -- fully automated execution - if (step.executionType === StepExecutionMode.FullyAutomated) { - return this.resolveAndLoadAutomatic(target); + return this.degradeToManualAwaitInput(); + } + + throw error; } + } + + private logAiDegrade(reason: unknown): void { + this.context.logger( + 'Warn', + 'load-related-record: AI unavailable, degrading to manual selection', + { ...this.logCtx, error: extractErrorMessage(reason) }, + ); + } - // Branch C -- pre-fetch candidates, await user confirmation + // Degrades to the deterministic Manual path: first eligible relation, candidate list fetched + // without AI, user picks. The re-run is AI-free, so it cannot re-trigger the failure. + private async degradeToManualAwaitInput(): Promise { + const target = await this.resolveTarget(false); const sourceSchema = await this.getCollectionSchema(target.selectedRecordRef.collectionName); - return this.saveAndAwaitInput(target, sourceSchema); + return this.saveAndAwaitInput(target, sourceSchema, false); } // Picks the (record, relation) pair to follow. Unlike a separate record-then-relation choice, // this lets the AI decide by what each relation LEADS TO — so "load the dvd" follows // store→dvds rather than latching onto a previously-loaded dvd whose collection just matches. - private async resolveTarget(): Promise { + private async resolveTarget(useAi: boolean): Promise { const { preRecordedArgs } = this.context.stepDefinition; const sourceRecords = @@ -168,8 +234,13 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor 1 && useAi + ? await this.withAiAssist(() => this.selectRelationToFollow(eligible)) + : eligible[0]; return this.targetFromCandidate(chosen); } @@ -265,15 +336,36 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { - const { selectedRecordRef, name, displayName } = target; + const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds( + target, + suggestViaAi, + ); - const { availableRecordIds, suggestedRecord } = await this.collectCandidateIds(target); + return this.persistAwaitInput(target, sourceSchema, { + availableRecordIds, + suggestedRecord, + // An AI pass that yields no record is a deliberate "nothing relevant" → pre-check "No X to load". + // A Manual pass with no suggestion just means the user picks, so no pre-check. + suggestNoRecord: suggestViaAi && !suggestedRecord, + }); + } + + private async persistAwaitInput( + target: RelationTarget, + sourceSchema: CollectionSchema, + pending: { + availableRecordIds: LoadRelatedRecordCandidate[]; + suggestedRecord?: LoadRelatedRecordCandidate; + suggestNoRecord: boolean; + }, + ): Promise { + const { selectedRecordRef, name, displayName } = target; const availableFields: RelationRef[] = sourceSchema.fields .filter(isFollowableRelation) @@ -285,8 +377,9 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { if (target.relationType === 'BelongsTo' || target.relationType === 'HasOne') { const candidate = await this.fetchXToOneCandidate(target); return candidate - ? { availableRecordIds: [candidate], suggestedRecord: candidate } - : { availableRecordIds: [] }; + ? { availableRecordIds: [candidate], suggestedRecord: candidate, ambiguous: false } + : { availableRecordIds: [], ambiguous: false }; } - const { relatedData, bestIndex, relatedSchema } = await this.selectBestFromRelatedData( - target, - 50, - ); + const { relatedData, bestIndex, confident, relatedSchema } = + await this.selectBestFromRelatedData( + target, + 50, + // allowNone: the AI may judge no candidate relevant (→ "No X to load"); only meaningful when + // ranking (Manual passes rank=false and never calls the AI). + suggestViaAi ? { rank: true, allowNone: true } : { rank: false }, + ); if (relatedData.length === 0) { - return { availableRecordIds: [] }; + return { availableRecordIds: [], ambiguous: false }; } const referenceField = relatedSchema.referenceField ?? null; @@ -325,7 +428,9 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor= 0 ? toCandidate(relatedData[bestIndex]) : undefined, + // -1 (none relevant) is not ambiguity; only a low-confidence positive pick is. + ambiguous: bestIndex >= 0 && !confident, }; } @@ -338,37 +443,37 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { - 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 resolveAndLoadAutomatic(): Promise { + // No source record throws (like Manual/AI-assisted) → the front offers "continue without". + const target = await this.resolveTarget(true); + const { availableRecordIds, suggestedRecord, ambiguous } = await this.collectCandidateIds( + target, + true, + ); - private async fetchXToOneRecordRef(target: RelationTarget): Promise { - const candidate = await this.fetchXToOneCandidate(target); + // Full AI auto-loads a confident pick and advances. It degrades to an AI-assisted confirmation + // (a human decides) instead of auto-loading when either: + // - nothing is relevant to load → "No X to load" pre-checked (suggestNoRecord); or + // - the AI cannot confidently single out one of several viable candidates → its best guess is + // pre-selected for the human to confirm (no "No X to load"). + // Mirrors the Trigger Action automated→confirmation fallback. + if (!suggestedRecord || ambiguous) { + const sourceSchema = await this.getCollectionSchema(target.selectedRecordRef.collectionName); - if (!candidate) { - throw new RelatedRecordNotFoundError(target.selectedRecordRef.collectionName, target.name); + return this.persistAwaitInput(target, sourceSchema, { + availableRecordIds, + suggestedRecord, + suggestNoRecord: !suggestedRecord, + }); } - return { + const record: RecordRef = { collectionName: target.relatedCollectionName, - recordId: candidate.recordId, + recordId: suggestedRecord.recordId, stepIndex: this.context.stepIndex, }; + + return this.persistAndReturn(record, target, undefined); } private async fetchXToOneCandidate( @@ -401,7 +506,7 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor { const { selectedRecordRef, pendingData, userConfirmation } = execution; - if (!pendingData) { + if (!pendingData || !selectedRecordRef) { throw new StepStateError(`Step at index ${this.context.stepIndex} has no pending data`); } @@ -448,65 +553,58 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor, limit: number, + // allowNone is meaningful only when ranking, so it's absent from the rank:false variant. + opts: { rank: true; allowNone: boolean } | { rank: false } = { rank: true, allowNone: false }, ): Promise<{ relatedData: RecordData[]; bestIndex: number; + // Whether the suggested record is a confident pick. The deterministic/short-circuit paths + // (single candidate, no ranking) are confident by construction; the ranked path reflects the + // AI's own confidence flag. + confident: boolean; 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. + // length<=1 short-circuits before opts.allowNone, so Full AI with a single candidate always + // loads it — the AI is never asked to reject a sole option. if (relatedData.length <= 1) { - return { relatedData, bestIndex: 0, suggestedFields: [], relatedSchema }; + return { relatedData, bestIndex: 0, confident: true, suggestedFields: [], relatedSchema }; } - // The final record stays AI-suggested + user-confirmed — only the source + relation are - // pinned deterministically. Index-based record pinning was removed (not revise-safe). - const suggestedFields = await this.selectRelevantFields( - relatedSchema, - this.context.stepDefinition.prompt, - ); - const bestIndex = await this.selectBestRecordIndex( - relatedData, - suggestedFields, - this.context.stepDefinition.prompt, - ); - - return { relatedData, bestIndex, suggestedFields, relatedSchema }; - } - - /** HasMany + fully automated execution: fetch top 50, then AI calls to select the best record. */ - private async selectBestRelatedRecord(target: RelationTarget): Promise { - const { relatedData, bestIndex } = await this.selectBestFromRelatedData(target, 50); - - if (relatedData.length === 0) { - throw new RelatedRecordNotFoundError(target.selectedRecordRef.collectionName, target.name); + if (!opts.rank) { + return { relatedData, bestIndex: -1, confident: true, suggestedFields: [], relatedSchema }; } - return this.toRecordRef(relatedData[bestIndex]); - } - - private async fetchFirstCandidate(target: RelationTarget): Promise { - const candidates = await this.fetchCandidates(target, 1); + // The final record stays AI-suggested + user-confirmed (or AI-decided in Full AI) — only the + // source + relation are pinned deterministically. Index-based record pinning was removed. + // withAiAssist tags any AI failure so callers degrade to the Manual path instead of erroring. + const suggestedFields = await this.withAiAssist(() => + this.selectRelevantFields(relatedSchema, this.context.stepDefinition.prompt), + ); + const { index: bestIndex, confident } = await this.withAiAssist(() => + this.selectBestRecordIndex( + relatedData, + suggestedFields, + this.context.stepDefinition.prompt, + opts.allowNone, + ), + ); - return candidates[0]; + return { relatedData, bestIndex, confident, suggestedFields, relatedSchema }; } - private async fetchCandidates( - target: Pick, - limit: number, - ): Promise { - const { selectedRecordRef, name } = target; - 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); + // Tags any failure raised inside an AI call so the step handlers can degrade to the deterministic + // Manual path. Passing through an already-tagged error avoids double-wrapping when calls nest. + private async withAiAssist(call: () => Promise): Promise { + try { + return await call(); + } catch (error) { + if (error instanceof AiAssistUnavailableError) throw error; + throw new AiAssistUnavailableError(error); } - - return relatedData.map(r => this.toRecordRef(r)); } private async fetchRelatedData( @@ -650,12 +748,12 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor nonRelationFields.find(f => f.displayName === dn)?.fieldName ?? dn); } - /** AI call 2 for HasMany: selects the best record by index from the candidate list. */ private async selectBestRecordIndex( candidates: RecordData[], fieldNames: string[], prompt: string | undefined, - ): Promise { + allowNone = false, + ): Promise<{ index: number; confident: boolean }> { const filteredCandidates = candidates.map((c, i) => { const entries = Object.entries(c.values).filter( ([k]) => fieldNames.length === 0 || fieldNames.includes(k), @@ -681,7 +779,9 @@ export default class LoadRelatedRecordStepExecutor extends RecordStepExecutor( - messages, - tool, - ); + const { recordIndex, confident: rawConfident } = await this.invokeWithTool<{ + recordIndex: number; + confident?: boolean; + reasoning: string; + }>(messages, tool); - // NOTE: The Zod schema's .min(0).max(maxIndex) shapes the tool prompt only — it is NOT - // validated against the AI response. This guard is the sole runtime enforcement. - if (!Number.isInteger(recordIndex) || recordIndex < 0 || recordIndex > maxIndex) { + // NOTE: The Zod schema's .min().max() shapes the tool prompt only — it is NOT validated against + // the AI response. This guard is the sole runtime enforcement. -1 (none relevant) is accepted + // only when noneAllowed (allowNone and the list was not truncated). + if (!Number.isInteger(recordIndex) || recordIndex < minIndex || recordIndex > maxIndex) { throw new InvalidAIResponseError( - `AI selected record index ${recordIndex} which is out of range (0-${maxIndex}) or not an integer`, + `AI selected record index ${recordIndex} which is out of range (${minIndex}-${maxIndex}) or not an integer`, ); } - return recordIndex; - } + // A definitive -1 ("none relevant") is not ambiguity. For a positive pick, honour the AI's + // confidence flag, defaulting to confident when omitted (the schema is not validated, and a + // missing flag must preserve the prior auto-load behaviour). + const confident = recordIndex < 0 ? true : rawConfident !== false; - private toRecordRef(data: RecordData): RecordRef { - return { - collectionName: data.collectionName, - recordId: data.recordId, - stepIndex: this.context.stepIndex, - }; + return { index: recordIndex, confident }; } } diff --git a/packages/workflow-executor/src/http/step-serializer.ts b/packages/workflow-executor/src/http/step-serializer.ts index 7a8a7de619..908382643c 100644 --- a/packages/workflow-executor/src/http/step-serializer.ts +++ b/packages/workflow-executor/src/http/step-serializer.ts @@ -15,9 +15,12 @@ export default function serializeStepForWire(step: StepExecutionData): unknown { return { ...step, selectedRecordRef: serializeRecordRef(step.selectedRecordRef) }; case 'load-related-record': { + // Omit selectedRecordRef when absent — serializing undefined throws. const result: Record = { ...step, - selectedRecordRef: serializeRecordRef(step.selectedRecordRef), + ...(step.selectedRecordRef && { + selectedRecordRef: serializeRecordRef(step.selectedRecordRef), + }), }; if (step.pendingData) { diff --git a/packages/workflow-executor/src/types/step-execution-data.ts b/packages/workflow-executor/src/types/step-execution-data.ts index 0585c237d6..537a0ae9a3 100644 --- a/packages/workflow-executor/src/types/step-execution-data.ts +++ b/packages/workflow-executor/src/types/step-execution-data.ts @@ -176,6 +176,9 @@ export interface LoadRelatedRecordPendingData { availableRecordIds: LoadRelatedRecordCandidate[]; // Absent when the relation has no linked record(s): the list is empty and there's nothing to suggest. suggestedRecord?: LoadRelatedRecordCandidate; + // The AI actively judged no candidate relevant (incl. Full AI degrading to confirmation) → the front + // pre-checks "No X to load". Distinct from a plain absent suggestedRecord (Manual: the user picks). + suggestNoRecord?: boolean; } export interface LoadRelatedRecordStepExecutionData @@ -183,7 +186,8 @@ export interface LoadRelatedRecordStepExecutionData WithUserConfirmation { type: 'load-related-record'; pendingData?: LoadRelatedRecordPendingData; - selectedRecordRef: RecordRef; + // Set on every await/load path (and preserved through the user-initiated "continue without" skip). + selectedRecordRef?: RecordRef; executionParams?: RelationRef; executionResult?: { relation: RelationRef; record: RecordRef } | { skipped: true }; } diff --git a/packages/workflow-executor/src/types/validated/step-definition.ts b/packages/workflow-executor/src/types/validated/step-definition.ts index 6ac1a42c46..a2c6cda898 100644 --- a/packages/workflow-executor/src/types/validated/step-definition.ts +++ b/packages/workflow-executor/src/types/validated/step-definition.ts @@ -98,10 +98,10 @@ export type TriggerActionStepDefinition = z.infer { expect(bindTools).not.toHaveBeenCalled(); }); - it('returns error outcome when AI selects an out-of-range record index', async () => { + it('degrades to manual selection when the AI returns an out-of-range record index', async () => { const hasManySchema = makeCollectionSchema({ fields: [ { @@ -835,14 +835,16 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - "The AI made an unexpected choice. Try rephrasing the step's prompt.", - ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + // An out-of-range index is an AI failure → degrade to the Manual path (deterministic list, + // user picks) rather than erroring the run or auto-loading a guess. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(saved.pendingData.suggestNoRecord).toBeUndefined(); }); - it('returns error when AI returns empty fieldNames violating the min:1 constraint', async () => { + it('degrades to manual selection when the AI returns empty fieldNames (min:1 violation)', async () => { const hasManySchema = makeCollectionSchema({ fields: [ { @@ -884,11 +886,12 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - "The AI made an unexpected choice. Try rephrasing the step's prompt.", - ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + // An invalid field-selection response is an AI failure → degrade to the Manual path. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(saved.pendingData.suggestNoRecord).toBeUndefined(); }); }); @@ -975,13 +978,14 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.status).toBe('success'); - // To-many path: /relationships call with limit: 1, no parent-record projection. + // To-many path: /relationships call with the candidate-list limit (a lone record short-circuits + // ranking, so it loads without an AI call). expect(agentPort.getRelatedData).toHaveBeenCalledWith( expect.objectContaining({ collection: 'customers', id: [42], relation: 'tags', - limit: 1, + limit: 50, relatedSchema: expect.objectContaining({ collectionName: 'tags' }), }), expect.objectContaining({ id: 1 }), @@ -990,46 +994,565 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(runStore.saveStepExecution).toHaveBeenCalledWith( 'run-1', expect.objectContaining({ - executionResult: expect.objectContaining({ - record: expect.objectContaining({ collectionName: 'tags', recordId: [7] }), - }), + executionResult: expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'tags', recordId: [7] }), + }), + }), + ); + }); + + // Empty list → nothing to load: Full AI degrades to an AI-assisted confirmation ("No X to load" + // pre-checked) instead of skipping, so a human decides. + it('falls back to awaiting-input (No X to load) when getRelatedData returns an empty array (Branch B)', 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(); + + // Full AI with no candidate → hands off to the user with "No X to load" pre-checked. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(saved.pendingData.suggestNoRecord).toBe(true); + }); + }); + + describe('executionType=Manual: no AI pre-selection', () => { + const customersWithAddress = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + + it('lists the narrowed candidates with no suggestion and never invokes the AI model', async () => { + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const bindTools = jest.fn(); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.Manual, + preRecordedArgs: { relationName: 'address' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // Manual mode performs zero AI calls — no relation chooser, no field/record ranking. + expect(bindTools).not.toHaveBeenCalled(); + + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls[0][1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + }); + + it('still pre-fills the single xToOne record in Manual (the only option, not an AI pick)', async () => { + // BelongsTo 'order' → one linked record; pre-filled even in Manual, with no AI call. + const agentPort = makeMockAgentPort(); // default: 1 related order #99 via getSingleRelatedData + const bindTools = jest.fn(); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + stepDefinition: makeStep({ + executionType: StepExecutionMode.Manual, + preRecordedArgs: { relationName: 'order' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + expect(bindTools).not.toHaveBeenCalled(); + + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls[0][1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand(['99'])]); + expect(saved.pendingData.suggestedRecord).toEqual(cand(['99'])); + }); + + it('switching relation (refreshCandidatesForField) in Manual makes no AI suggestion', 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']), + }, + }); + 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 }], + }); + const bindTools = jest.fn(); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: addressesSchema, + }), + incomingPendingData: { fieldName: 'address' }, + stepDefinition: makeStep({ executionType: StepExecutionMode.Manual }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // Manual: switching to a multi-record relation must neither call the AI nor pre-select. + expect(bindTools).not.toHaveBeenCalled(); + + const finalSave = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(finalSave.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(finalSave.pendingData.suggestedRecord).toBeUndefined(); + }); + }); + + describe('executionType=AutomatedWithConfirmation: AI may decline', () => { + it('pre-checks "No X to load" (suggestNoRecord) when the AI judges no candidate relevant', async () => { + const customersWithAddress = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: -1, reasoning: 'None of the candidates is relevant' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + preRecordedArgs: { relationName: 'address' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + // AI-assisted no longer forces a pick: the AI may return -1, and the user reviews with + // "No X to load" pre-checked (rather than a potentially misleading forced suggestion). + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(saved.pendingData.suggestNoRecord).toBe(true); + }); + + it('tells the AI it may return -1 in the record-selection prompt (Bug 2)', async () => { + const customersWithAddress = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 0, reasoning: 'match' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + const context = makeContext({ + model, + agentPort, + runStore: makeMockRunStore(), + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + preRecordedArgs: { relationName: 'address' }, + }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + // 2nd AI call = record selection. Without the decline guidance the base prompt forces a pick, + // so an impossible request would return a weak match instead of -1 (Bug 2). + const recordSelectionMessages = invoke.mock.calls[1][0] as Array<{ content: unknown }>; + const content = recordSelectionMessages.map(m => String(m.content)).join('\n'); + expect(content).toContain('-1'); + }); + }); + + describe('executionType=FullyAutomated: AI judges none relevant → confirmation', () => { + it('falls back to awaiting-input (No X to load) when select-record-by-content returns -1', async () => { + const customersWithAddress = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: -1, reasoning: 'None of the candidates is relevant' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'address' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + // Two AI calls run (field + record selection); the record call returns -1 → hand off to the + // user with the candidates listed and "No X to load" pre-checked. + expect(bindTools).toHaveBeenCalledTimes(2); + expect(bindTools.mock.calls[1][0][0].name).toBe('select-record-by-content'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(saved.pendingData.suggestNoRecord).toBe(true); + }); + }); + + describe('executionType=FullyAutomated: low-confidence match → confirmation', () => { + const customersWithAddress = makeCollectionSchema({ + fields: [ + { + fieldName: 'address', + displayName: 'Address', + isRelationship: true, + relationType: 'HasMany', + relatedCollectionName: 'addresses', + }, + ], + }); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + + it('degrades to awaiting-input with the best guess pre-selected when the AI is not confident', async () => { + const agentPort = makeMockAgentPort(relatedData); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { + recordIndex: 0, + confident: false, + reasoning: 'Paris and Lyon both plausible', + }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'address' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + // Full AI can't confidently single one out → hand off to a human with the AI's best guess + // (index 0 → recordId [1]) pre-selected, rather than auto-loading an arbitrary pick. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toEqual(cand([1])); + // Not a "none relevant" outcome — the "No X to load" box must stay unchecked. + expect(saved.pendingData.suggestNoRecord).toBeUndefined(); + // Nothing was auto-loaded. + expect(saved.executionResult).toBeUndefined(); + }); + + it('auto-loads (unchanged) when the AI is confident about its pick', async () => { + const agentPort = makeMockAgentPort(relatedData); + const invoke = jest + .fn() + .mockResolvedValueOnce({ + tool_calls: [{ name: 'select-fields', args: { fieldNames: ['City'] }, id: 'c2' }], + }) + .mockResolvedValueOnce({ + tool_calls: [ + { + name: 'select-record-by-content', + args: { recordIndex: 1, confident: true, reasoning: 'Lyon clearly matches' }, + id: 'c3', + }, + ], + }); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + const context = makeContext({ + model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'address' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('success'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.executionResult).toEqual( + expect.objectContaining({ + record: expect.objectContaining({ collectionName: 'addresses', recordId: [2] }), }), ); }); + }); - // 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({ + describe('AI failure/timeout degrades to Manual (does not error the run)', () => { + it('Full AI: an AI invoke that throws degrades to awaiting-input with the candidate list', async () => { + const customersWithAddress = makeCollectionSchema({ fields: [ { - fieldName: 'tags', - displayName: 'Tags', + fieldName: 'address', + displayName: 'Address', isRelationship: true, - relationType: 'BelongsToMany', - relatedCollectionName: 'tags', + relationType: 'HasMany', + relatedCollectionName: 'addresses', }, ], }); - const agentPort = makeMockAgentPort([]); - const mockModel = makeMockModel({ relationName: 'Tags', reasoning: 'Load tags' }); + const addressSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const relatedData: RecordData[] = [ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + { collectionName: 'addresses', recordId: [2], values: { city: 'Lyon' } }, + ]; + const agentPort = makeMockAgentPort(relatedData); + // AI provider outage/timeout: the model invoke rejects. + const invoke = jest.fn().mockRejectedValue(new Error('AI provider timed out')); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; const runStore = makeMockRunStore(); const context = makeContext({ - model: mockModel.model, + model, agentPort, runStore, - workflowPort: makeMockWorkflowPort({ customers: belongsToManySchema }), - stepDefinition: makeStep({ executionType: StepExecutionMode.FullyAutomated }), + workflowPort: makeMockWorkflowPort({ + customers: customersWithAddress, + addresses: addressSchema, + }), + stepDefinition: makeStep({ + executionType: StepExecutionMode.FullyAutomated, + preRecordedArgs: { relationName: 'address' }, + }), }); - const executor = new LoadRelatedRecordStepExecutor(context); - const result = await executor.execute(); + const result = await new LoadRelatedRecordStepExecutor(context).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(); + // Deterministic list presented for the user to pick — no error, no auto-skip, no suggestion. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + expect(saved.pendingData.suggestedRecord).toBeUndefined(); + expect(saved.pendingData.suggestNoRecord).toBeUndefined(); + expect(saved.executionResult).toBeUndefined(); + }); + + it('AI-assisted: an AI invoke that throws degrades to awaiting-input (no error)', async () => { + const agentPort = makeMockAgentPort(); + const invoke = jest.fn().mockRejectedValue(new Error('AI provider unavailable')); + const bindTools = jest.fn().mockReturnValue({ invoke }); + const model = { bindTools } as unknown as ExecutionContext['model']; + const runStore = makeMockRunStore(); + // Default 2-relation schema → relation selection calls the AI (which throws) → degrade. + const context = makeContext({ + model, + agentPort, + runStore, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + // Degrade picks the first eligible relation (Order) and pre-fills its single linked record. + expect(saved.pendingData.suggestedField).toEqual({ name: 'order', displayName: 'Order' }); + expect(saved.pendingData.suggestedRecord).toEqual(cand(['99'])); }); }); @@ -1887,6 +2410,149 @@ describe('LoadRelatedRecordStepExecutor', () => { ); }); + it('clears a stale suggestNoRecord flag when switching to a relation that yields a suggestion', async () => { + // Previous field ended in "No X to load" (suggestNoRecord true, no suggestion). Switching to a + // relation that resolves a record must drop the stale flag and surface the new suggestion — + // pendingData is rebuilt for the new field, not spread over the old one. + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'address', displayName: 'Address' }, + availableRecordIds: [cand([1]), cand([2])], + suggestedRecord: undefined, + suggestNoRecord: true, + }, + }); + const agentPort = makeMockAgentPort(); // default: order #99 via getSingleRelatedData + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const mockModel = makeMockModel({}); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ customers: makeCollectionSchema() }), + incomingPendingData: { fieldName: 'order' }, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + const finalSave = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(finalSave.pendingData.suggestedField).toEqual({ name: 'order', displayName: 'Order' }); + expect(finalSave.pendingData.suggestedRecord).toEqual(cand(['99'])); + // The stale "No X to load" flag from the previous field must not linger. + expect(finalSave.pendingData.suggestNoRecord).toBeUndefined(); + }); + + it('degrades to the no-AI candidate list when the AI fails during a field switch', async () => { + // AI mode + user switches to a HasMany relation; the ranking AI call throws → refresh must + // fall back to the deterministic list (no suggestion) instead of erroring the run. + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand(['99'])], + suggestedRecord: cand(['99']), + }, + }); + 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 ranking AI call rejects (provider outage/timeout). + const invoke = jest.fn().mockRejectedValue(new Error('AI provider timed out')); + 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: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: addressesSchema, + }), + incomingPendingData: { fieldName: 'address' }, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('awaiting-input'); + const finalSave = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(finalSave.pendingData.suggestedField).toEqual({ + name: 'address', + displayName: 'Address', + }); + expect(finalSave.pendingData.availableRecordIds).toEqual([cand([1]), cand([2])]); + // Degraded → no AI suggestion and no stale "No X to load" flag. + expect(finalSave.pendingData.suggestedRecord).toBeUndefined(); + expect(finalSave.pendingData.suggestNoRecord).toBeUndefined(); + }); + + it('rethrows a non-AI failure during a field switch instead of degrading', async () => { + // A data-fetch failure (not an AI failure) must surface as a step error — only tagged AI + // failures degrade to the Manual path. + const execution = makePendingExecution({ + pendingData: { + availableFields: [ + { name: 'order', displayName: 'Order' }, + { name: 'address', displayName: 'Address' }, + ], + suggestedField: { name: 'order', displayName: 'Order' }, + availableRecordIds: [cand(['99'])], + suggestedRecord: cand(['99']), + }, + }); + const agentPort = makeMockAgentPort([ + { collectionName: 'addresses', recordId: [1], values: { city: 'Paris' } }, + ]); + (agentPort.getRelatedData as jest.Mock).mockRejectedValue( + new AgentPortError('getRelatedData', new Error('db down')), + ); + const addressesSchema = makeCollectionSchema({ + collectionName: 'addresses', + collectionDisplayName: 'Addresses', + fields: [{ fieldName: 'city', displayName: 'City', isRelationship: false }], + }); + const mockModel = makeMockModel({}); + const runStore = makeMockRunStore({ + getStepExecutions: jest.fn().mockResolvedValue([execution]), + }); + const context = makeContext({ + model: mockModel.model, + agentPort, + runStore, + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: addressesSchema, + }), + incomingPendingData: { fieldName: 'address' }, + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + }); + 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 @@ -2347,6 +3013,38 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(saved.pendingData.availableRecordIds).toHaveLength(50); }); + // Safety core (noneAllowed = allowNone && !truncated): when the candidate list is truncated the + // AI must NOT be offered the -1 "none relevant" option, so it can't decline based on a partial + // view — a match could be hiding in the unseen tail. + it('withholds the -1 (none) option from the AI when the candidate list is truncated', async () => { + const big = 'y'.repeat(80); + const values = Object.fromEntries(Array.from({ length: 6 }, (_, i) => [`f${i}`, big])); + const relatedData: RecordData[] = Array.from({ length: 50 }, (_, i) => ({ + collectionName: 'addresses', + recordId: [i + 1], + values, + })); + const { invoke, model } = buildModel(Array.from({ length: 6 }, (_, i) => `F${i}`)); + const context = makeContext({ + model, + logger: jest.fn(), + agentPort: makeMockAgentPort(relatedData), + runStore: makeMockRunStore(), + workflowPort: makeMockWorkflowPort({ + customers: makeCollectionSchema(), + addresses: wideSchema(6), + }), + // AI-assisted so allowNone is true at the call site — the truncation guard is what suppresses + // the -1 option, not the mode. + stepDefinition: makeStep({ executionType: StepExecutionMode.AutomatedWithConfirmation }), + }); + + await new LoadRelatedRecordStepExecutor(context).execute(); + + // Neither the decline guidance nor the "-1" index appears in the record-selection prompt. + expect(selectRecordPrompt(invoke)).not.toContain('-1'); + }); + // The AI sees only the budgeted prefix, but its index maps back into the FULL list — so a // non-zero pick must resolve to the correct full-list record (cap the prompt, not the data). it('maps a non-zero AI index back into the full candidate list after truncation', async () => { @@ -2523,8 +3221,8 @@ describe('LoadRelatedRecordStepExecutor', () => { }); }); - describe('RelatedRecordNotFoundError', () => { - it('returns error when BelongsTo getRelatedData returns empty array (Branch B)', async () => { + describe('empty candidate list → awaiting-input (No X to load)', () => { + it('falls back to awaiting-input when BelongsTo has no linked record (Branch B)', async () => { const agentPort = makeMockAgentPort([]); const mockModel = makeMockModel({ relation: relationOption({ @@ -2545,14 +3243,14 @@ describe('LoadRelatedRecordStepExecutor', () => { 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(); + // Full AI: linked record absent → hand off to the user with "No X to load" pre-checked. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([]); + expect(saved.pendingData.suggestNoRecord).toBe(true); }); - it('returns error when HasMany getRelatedData returns empty array (Branch B)', async () => { + it('falls back to awaiting-input when HasMany returns an empty array (Branch B)', async () => { const hasManySchema = makeCollectionSchema({ fields: [ { @@ -2578,11 +3276,11 @@ describe('LoadRelatedRecordStepExecutor', () => { 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(); + // Full AI: empty candidate list → hand off to the user with "No X to load" pre-checked. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.availableRecordIds).toEqual([]); + expect(saved.pendingData.suggestNoRecord).toBe(true); }); }); @@ -2637,9 +3335,10 @@ describe('LoadRelatedRecordStepExecutor', () => { }); describe('select-relation-to-follow failure', () => { - // With >=2 candidates the AI must echo back one of the offered labels. A label that - // matches no option (here a fabricated relation) is rejected as an invalid AI response. - it('returns error when AI selects a relation label not among the offered options', async () => { + // With >=2 candidates the AI must echo back one of the offered labels. A label that matches no + // option (here a fabricated relation) is an invalid AI response → the step degrades to the + // deterministic Manual path instead of erroring the run. + it('degrades to manual selection when AI selects a relation label not among the offered options', async () => { const agentPort = makeMockAgentPort(); // Default schema has two relations (Order, Address) → select-relation-to-follow IS invoked. const mockModel = makeMockModel({ @@ -2661,16 +3360,19 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - "The AI made an unexpected choice. Try rephrasing the step's prompt.", - ); - expect(agentPort.getRelatedData).not.toHaveBeenCalled(); + // Degrade picks the first eligible relation (Order, BelongsTo) deterministically and presents + // its single linked record for confirmation — no error, no auto-load off a bad relation pick. + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.suggestedField).toEqual({ name: 'order', displayName: 'Order' }); + expect(saved.pendingData.suggestedRecord).toEqual(cand(['99'])); }); }); describe('AI malformed/missing tool call', () => { - it('returns error on malformed tool call', async () => { + // A malformed or missing tool call is an AI failure. In an AI mode it degrades to the Manual + // path (first eligible relation + its candidate) rather than erroring the run. + it('degrades to manual selection on a malformed tool call', async () => { const invoke = jest.fn().mockResolvedValue({ tool_calls: [], invalid_tool_calls: [ @@ -2688,16 +3390,13 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.type).toBe('record'); - expect(result.stepOutcome.stepId).toBe('load-1'); - expect(result.stepOutcome.stepIndex).toBe(0); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - "The AI returned an unexpected response. Try rephrasing the step's prompt.", - ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.suggestedField).toEqual({ name: 'order', displayName: 'Order' }); + expect(saved.pendingData.suggestedRecord).toEqual(cand(['99'])); }); - it('returns error when AI returns no tool call', async () => { + it('degrades to manual selection when AI returns no tool call', async () => { const invoke = jest.fn().mockResolvedValue({ tool_calls: [] }); const bindTools = jest.fn().mockReturnValue({ invoke }); const runStore = makeMockRunStore(); @@ -2710,13 +3409,10 @@ describe('LoadRelatedRecordStepExecutor', () => { const result = await executor.execute(); expect(result.stepOutcome.type).toBe('record'); - expect(result.stepOutcome.stepId).toBe('load-1'); - expect(result.stepOutcome.stepIndex).toBe(0); - expect(result.stepOutcome.status).toBe('error'); - expect(result.stepOutcome.error).toBe( - "The AI couldn't decide what to do. Try rephrasing the step's prompt.", - ); - expect(runStore.saveStepExecution).not.toHaveBeenCalled(); + expect(result.stepOutcome.status).toBe('awaiting-input'); + const saved = (runStore.saveStepExecution as jest.Mock).mock.calls.at(-1)?.[1]; + expect(saved.pendingData.suggestedField).toEqual({ name: 'order', displayName: 'Order' }); + expect(saved.pendingData.suggestedRecord).toEqual(cand(['99'])); }); }); @@ -3610,8 +4306,10 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.error).toBe('The pre-configured step parameters are invalid'); }); - it('surfaces a distinct "no source record" message when the source step loaded nothing', async () => { - // The source step exists in the live path but its run-store execution has no record. + it('errors (Full AI) when the source step loaded nothing — no longer auto-skips', async () => { + // The source step exists in the live path but its run-store execution has no record. Full AI + // now surfaces the error like the await modes (no silent skip); the front offers "continue + // without". const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]) }); const context = makeContext({ runStore, @@ -3628,6 +4326,25 @@ describe('LoadRelatedRecordStepExecutor', () => { expect(result.stepOutcome.error).toContain("didn't load any record"); }); + it('surfaces a "no source record" error in await modes when the source step loaded nothing', async () => { + // Same as Full AI now — every mode surfaces the error so the front can offer + // "continue without". + const runStore = makeMockRunStore({ getStepExecutions: jest.fn().mockResolvedValue([]) }); + const context = makeContext({ + runStore, + previousSteps: [makeLoadRelatedPreviousStep(1)], + stepDefinition: makeStep({ + executionType: StepExecutionMode.AutomatedWithConfirmation, + preRecordedArgs: { selectedRecordStepId: 'load-1', relationName: 'customer' }, + }), + }); + + const result = await new LoadRelatedRecordStepExecutor(context).execute(); + + expect(result.stepOutcome.status).toBe('error'); + expect(result.stepOutcome.error).toContain("didn't load any record"); + }); + it('errors with the pre-recorded-args message when the pinned relation matches nothing', async () => { const context = makeContext({ stepDefinition: makeStep({ diff --git a/packages/workflow-executor/test/http/step-serializer.test.ts b/packages/workflow-executor/test/http/step-serializer.test.ts index 1ff16a3c6c..3dc5cc8021 100644 --- a/packages/workflow-executor/test/http/step-serializer.test.ts +++ b/packages/workflow-executor/test/http/step-serializer.test.ts @@ -108,6 +108,19 @@ describe('serializeStepForWire', () => { expect(result.executionResult).toEqual({ skipped: true }); }); + + it('does not throw when selectedRecordRef is absent (Full AI no-source skip)', () => { + const step: LoadRelatedRecordStepExecutionData = { + type: 'load-related-record', + stepIndex: 2, + executionResult: { skipped: true }, + }; + + const result = serializeStepForWire(step) as Record; + + expect(result.executionResult).toEqual({ skipped: true }); + expect('selectedRecordRef' in result).toBe(false); + }); }); it('returns non-record steps unchanged', () => { diff --git a/packages/workflow-executor/test/types/step-definition.test.ts b/packages/workflow-executor/test/types/step-definition.test.ts new file mode 100644 index 0000000000..04fdd1c6aa --- /dev/null +++ b/packages/workflow-executor/test/types/step-definition.test.ts @@ -0,0 +1,43 @@ +import { + LoadRelatedRecordStepDefinitionSchema, + StepExecutionMode, + StepType, +} from '../../src/types/validated/step-definition'; + +describe('LoadRelatedRecordStepDefinitionSchema executionType', () => { + const base = { type: StepType.LoadRelatedRecord as const }; + + it('parses each valid execution mode to its own value', () => { + expect( + LoadRelatedRecordStepDefinitionSchema.parse({ ...base, executionType: 'manual' }) + .executionType, + ).toBe(StepExecutionMode.Manual); + expect( + LoadRelatedRecordStepDefinitionSchema.parse({ + ...base, + executionType: 'automated-with-confirmation', + }).executionType, + ).toBe(StepExecutionMode.AutomatedWithConfirmation); + expect( + LoadRelatedRecordStepDefinitionSchema.parse({ ...base, executionType: 'fully-automated' }) + .executionType, + ).toBe(StepExecutionMode.FullyAutomated); + }); + + it('defaults a missing executionType to AutomatedWithConfirmation', () => { + expect(LoadRelatedRecordStepDefinitionSchema.parse(base).executionType).toBe( + StepExecutionMode.AutomatedWithConfirmation, + ); + }); + + // No `.catch` on the enum: an invalid value must be rejected, not silently coerced to + // AutomatedWithConfirmation (which would turn AI prefill back on for a `manual` typo). + it('rejects an invalid executionType instead of coercing it', () => { + const result = LoadRelatedRecordStepDefinitionSchema.safeParse({ + ...base, + executionType: 'not-a-mode', + }); + + expect(result.success).toBe(false); + }); +});