From 24951fa2f4e5a7fd37dc5cb70a7f680ad71430c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ra=C3=BAl=20G=C3=B3mez=20Morales?= Date: Thu, 17 Sep 2026 07:05:33 +0000 Subject: [PATCH] feat(core): Count injected trigger output as simulated in AI Assistant verification (no-changelog) (#38626) --- .../__tests__/analyze-result.test.ts | 46 ++++++++++++++++++- .../verification/analyze-result.ts | 27 +++++++++++ .../verification/scripted-gate-run.ts | 6 +++ packages/@n8n/instance-ai/src/types.ts | 6 +++ .../instance-ai.adapter.service.test.ts | 29 ++++++++++++ .../instance-ai.adapter.service.ts | 15 ++++-- 6 files changed, 125 insertions(+), 4 deletions(-) diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/verification/__tests__/analyze-result.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/verification/__tests__/analyze-result.test.ts index f800b117a6e..7d2d1bb83ee 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/verification/__tests__/analyze-result.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/verification/__tests__/analyze-result.test.ts @@ -1,5 +1,9 @@ import type { WorkflowBuildOutcome } from '../../../../workflow-loop/workflow-loop-state'; -import { analyzeVerificationResult } from '../analyze-result'; +import { + analyzeVerificationResult, + INJECTED_TRIGGER_SIMULATION_REASON, + WORKFLOW_PIN_SIMULATION_REASON, +} from '../analyze-result'; import type { ExecutionRunResult } from '../types'; function makeBuildOutcome(overrides: Partial = {}): WorkflowBuildOutcome { @@ -326,6 +330,46 @@ describe('analyzeVerificationResult — workflow-pinned nodes', () => { }); }); +describe('analyzeVerificationResult — injected trigger output', () => { + const injectedRunResult = { + executionId: 'exec-1', + status: 'success', + executedNodeNames: ['Webhook', 'Format'], + lastNodeExecuted: 'Format', + data: { Webhook: [{}], Format: [{}] }, + injectedTriggerNodeName: 'Webhook', + } as unknown as ExecutionRunResult; + + it('counts the injected trigger as simulated so the run is not a live trigger test', () => { + const analysis = analyzeVerificationResult({ + result: injectedRunResult, + buildOutcome: makeBuildOutcome(), + simulatedNodes: [], + stateBefore: undefined, + runId: 'run-1', + }); + + expect(analysis.reachedSimulatedNodes).toEqual([ + { nodeName: 'Webhook', reason: INJECTED_TRIGGER_SIMULATION_REASON }, + ]); + expect(analysis.simulationNote).toContain('Webhook'); + }); + + it('does not duplicate a trigger that workflow pins already disclose', () => { + const analysis = analyzeVerificationResult({ + result: { ...injectedRunResult, workflowPinnedNodeNames: ['Webhook'] }, + buildOutcome: makeBuildOutcome(), + simulatedNodes: [], + stateBefore: undefined, + runId: 'run-1', + }); + + expect(analysis.reachedSimulatedNodes).toEqual([ + { nodeName: 'Webhook', reason: WORKFLOW_PIN_SIMULATION_REASON }, + ]); + }); +}); + describe('analyzeVerificationResult — trigger-scoped coverage', () => { const twoBranchOutcome = makeBuildOutcome({ nodeSimulationPlan: [ diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/verification/analyze-result.ts b/packages/@n8n/instance-ai/src/tools/orchestration/verification/analyze-result.ts index d723398180a..85d39423358 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/verification/analyze-result.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/verification/analyze-result.ts @@ -30,6 +30,26 @@ type ExecutionNodeError = NonNullable[number]; export const WORKFLOW_PIN_SIMULATION_REASON = 'Output came from pinned data saved on the workflow — unpin it for a live test'; +/** Disclosure for a trigger whose output the assistant injected instead of a real event. */ +export const INJECTED_TRIGGER_SIMULATION_REASON = + 'Trigger output was injected test input, not a real event — run a live test to prove the trigger'; + +/** Injected trigger output, deduplicated against the other simulation sources. */ +export function injectedTriggerSimulations( + injectedTriggerNodeName: string | undefined, + reachedNames: ReadonlySet, + alreadySimulatedNames: ReadonlySet, +): Array<{ nodeName: string; reason: string }> { + if ( + injectedTriggerNodeName === undefined || + !reachedNames.has(injectedTriggerNodeName) || + alreadySimulatedNames.has(injectedTriggerNodeName) + ) { + return []; + } + return [{ nodeName: injectedTriggerNodeName, reason: INJECTED_TRIGGER_SIMULATION_REASON }]; +} + const CREDENTIAL_FAILURE_KEYWORDS = [ 'credential', 'unauthorized', @@ -496,9 +516,16 @@ export function analyzeVerificationResult(args: { const workflowPinnedNodes = (result.workflowPinnedNodeNames ?? []) .filter((name) => reachedNames.has(name) && !plannedSimulatedNames.has(name)) .map((name) => ({ nodeName: name, reason: WORKFLOW_PIN_SIMULATION_REASON })); + // An injected trigger (inputData or verification pin) never fired for real, + // so a run that starts from it is not a live test of the trigger either. const reachedSimulatedNodes = [ ...simulatedNodes.filter((n) => reachedNames.has(n.nodeName)), ...workflowPinnedNodes, + ...injectedTriggerSimulations( + result.injectedTriggerNodeName, + reachedNames, + new Set([...plannedSimulatedNames, ...workflowPinnedNodes.map((n) => n.nodeName)]), + ), ]; const nodesNotReached = (buildOutcome.nodeSimulationPlan ?? []) .map((verdict) => verdict.nodeName) diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/verification/scripted-gate-run.ts b/packages/@n8n/instance-ai/src/tools/orchestration/verification/scripted-gate-run.ts index 2b752e461c1..11bf70e3bdd 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/verification/scripted-gate-run.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/verification/scripted-gate-run.ts @@ -8,6 +8,7 @@ import { analyzeVerificationResult, buildSimulationNote, + injectedTriggerSimulations, WORKFLOW_PIN_SIMULATION_REASON, type ChatModelRecoveryOptions, type VerificationAnalysis, @@ -182,6 +183,11 @@ function mergeAnalyses( nodeName: name, reason: WORKFLOW_PIN_SIMULATION_REASON, })), + ...injectedTriggerSimulations( + passes.find((pass) => pass.result.injectedTriggerNodeName)?.result.injectedTriggerNodeName, + reachedNames, + new Set([...plannedSimulatedNames, ...workflowPinnedNodeNames]), + ), ]; return { diff --git a/packages/@n8n/instance-ai/src/types.ts b/packages/@n8n/instance-ai/src/types.ts index 386e5fac841..3073146e25d 100644 --- a/packages/@n8n/instance-ai/src/types.ts +++ b/packages/@n8n/instance-ai/src/types.ts @@ -177,6 +177,12 @@ export interface ExecutionResult { * unsaved workflow, absent when the record could not be read. */ workflowVersionId?: string | null; + /** + * Set when the trigger did not fire from a real event: its output came from + * injected `inputData` or verification pin data. Such a run proves nothing + * about the trigger's ingress (auth, payload shape, response mode). + */ + injectedTriggerNodeName?: string; error?: string; startedAt?: string; finishedAt?: string; diff --git a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts index 5ee5551b371..abedefd71a5 100644 --- a/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts +++ b/packages/cli/src/modules/instance-ai/__tests__/instance-ai.adapter.service.test.ts @@ -4527,6 +4527,35 @@ describe('createExecutionAdapter run()', () => { vi.clearAllMocks(); }); + it('names the trigger whose output the caller injected', async () => { + const { adapter } = createRunAdapterForTests( + { + id: 'wf-1', + nodes: [ + { + id: 'n1', + name: 'Webhook', + type: 'n8n-nodes-base.webhook', + typeVersion: 2, + position: [0, 0], + }, + ], + connections: {}, + }, + { execution: makeExecution({ status: 'success' }) }, + ); + + const injected = await adapter.run('wf-1', { body: { name: 'Ada' } }); + const pinned = await adapter.run('wf-1', undefined, { + verificationPinData: { Webhook: [{ body: { name: 'Ada' } }] }, + }); + const live = await adapter.run('wf-1'); + + expect(injected.injectedTriggerNodeName).toBe('Webhook'); + expect(pinned.injectedTriggerNodeName).toBe('Webhook'); + expect(live).not.toHaveProperty('injectedTriggerNodeName'); + }); + it('reports workflow-pinned nodes on the run result', async () => { const { adapter } = createRunAdapterForTests( { diff --git a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts index 59ff7275c3f..cd10464fd77 100644 --- a/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts +++ b/packages/cli/src/modules/instance-ai/instance-ai.adapter.service.ts @@ -2048,9 +2048,18 @@ export class InstanceAiAdapterService { // Saved workflow pins fed this run (they ride every instance-ai run) — // report them so callers don't mistake pin-fed nodes for live ones. const workflowPinnedNodeNames = Object.keys(workflow.pinData ?? {}); - return workflowPinnedNodeNames.length > 0 - ? { ...result, workflowPinnedNodeNames } - : result; + // The trigger did not fire when the assistant supplied its output. + const injectedTriggerNodeName = + triggerNode && + (pinDataPlan.mockDataSources.includes('trigger_input') || + Object.hasOwn(pinDataPlan.verificationPinData, triggerNode.name)) + ? triggerNode.name + : undefined; + return { + ...result, + ...(workflowPinnedNodeNames.length > 0 ? { workflowPinnedNodeNames } : {}), + ...(injectedTriggerNodeName ? { injectedTriggerNodeName } : {}), + }; } catch (error) { // A failure to launch (or any other unsettled error) is still an // errored builder run — track it before rethrowing so it isn't