fix(ai-builder): Validate required inputs on AI nodes (#29159)

This commit is contained in:
Jaakko Husso
2026-04-27 13:47:47 +00:00
committed by GitHub
parent 3a9c922bf4
commit 97bdbd055a
5 changed files with 494 additions and 0 deletions
@@ -12,6 +12,7 @@ import {
} from '../__tests__/fixtures-download';
import type { WorkflowJSON } from '../types/base';
import { foldLegacyErrorConnections, normalizeConnections } from '../types/base';
import { validateWorkflow } from '../validation';
import {
escapeNewlinesInExpressionStrings,
isPlaceholderValue,
@@ -22,12 +23,18 @@ interface ExpectedWarning {
nodeName?: string;
}
interface ExpectedError {
code: string;
nodeName?: string;
}
interface TestWorkflow {
id: string;
name: string;
json: WorkflowJSON;
nodeCount: number;
expectedWarnings?: ExpectedWarning[];
expectedErrors?: ExpectedError[];
}
function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void {
@@ -45,6 +52,7 @@ function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void {
skip?: boolean;
skipReason?: string;
expectedWarnings?: ExpectedWarning[];
expectedErrors?: ExpectedError[];
}>;
};
@@ -61,6 +69,7 @@ function loadWorkflowsFromDir(dir: string, workflows: TestWorkflow[]): void {
json,
nodeCount: json.nodes?.length ?? 0,
expectedWarnings: entry.expectedWarnings,
expectedErrors: entry.expectedErrors,
});
}
}
@@ -2620,3 +2629,87 @@ describe('Codegen Roundtrip with Real Workflows', () => {
});
}
});
describe('Committed workflows — schema validation errors', () => {
// Mirror the relevant builderHint.inputs declarations from the real node types
// so validateWorkflow can resolve required AI inputs without pulling in the
// full nodes-langchain dependency tree.
const mockNodeTypesProvider = {
getByNameAndVersion: (type: string, _version?: number) => {
if (type === '@n8n/n8n-nodes-langchain.chatTrigger') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: {
ai_memory: {
required: true,
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
'options.loadPreviousSession': ['memory'],
},
},
},
},
},
},
};
}
if (type === '@n8n/n8n-nodes-langchain.agent') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
ai_tool: { required: false },
},
},
},
};
}
return { description: { inputs: ['main'] } };
},
getByName: (type: string) => mockNodeTypesProvider.getByNameAndVersion(type),
getKnownTypes: () => ({}),
};
const normalizeError = (e: ExpectedError): string => `${e.code}:${e.nodeName ?? ''}`;
const workflowsWithExpectedErrors = workflows.filter(
(w) => w.expectedErrors && w.expectedErrors.length > 0,
);
if (workflowsWithExpectedErrors.length === 0) {
it('has at least one fixture with expectedErrors declared', () => {
expect(workflowsWithExpectedErrors.length).toBeGreaterThan(0);
});
} else {
workflowsWithExpectedErrors.forEach(({ id, name, json, expectedErrors }) => {
it(`emits expected validation errors for workflow ${id}: "${name}"`, () => {
const expectedCodes = new Set((expectedErrors ?? []).map((e) => e.code));
const result = validateWorkflow(json, {
nodeTypesProvider: mockNodeTypesProvider as never,
// Disconnected-node warnings are unrelated to the AI-input checks
// these fixtures are designed to exercise.
allowDisconnectedNodes: true,
});
const actualErrors: ExpectedError[] = result.errors
.filter((e) => expectedCodes.has(e.code))
.map((e) => ({ code: e.code, nodeName: e.nodeName }))
.sort((a, b) => normalizeError(a).localeCompare(normalizeError(b)));
const expected = (expectedErrors ?? [])
.slice()
.sort((a, b) => normalizeError(a).localeCompare(normalizeError(b)));
expect(actualErrors).toEqual(expected);
expect(result.valid).toBe(false);
});
});
}
});
@@ -34,6 +34,7 @@ export type ValidationErrorCode =
| 'SUBNODE_NOT_CONNECTED'
| 'SUBNODE_PARAMETER_MISMATCH'
| 'UNSUPPORTED_SUBNODE_INPUT'
| 'MISSING_REQUIRED_INPUT'
| 'MAX_NODES_EXCEEDED'
| 'INVALID_EXPRESSION_PATH'
| 'PARTIAL_EXPRESSION_PATH'
@@ -491,6 +492,8 @@ export function validateWorkflow(
validateSubnodeParameters(json, options.nodeTypesProvider, warnings);
// Validate parent nodes actually support their connected AI input types
validateParentSupportsInputs(json, options.nodeTypesProvider, warnings);
// Validate required AI inputs on parent nodes are actually connected
validateRequiredInputsConnected(json, options.nodeTypesProvider, errors);
}
// Merge node input-count consistency
@@ -785,6 +788,82 @@ function validateParentSupportsInputs(
}
}
/**
* Validate that required AI inputs declared in a parent node's builderHint.inputs
* are actually connected.
*
* For each parent node with a builderHint.inputs entry that has `required: true`,
* check whether its displayOptions (if any) match the parent's current parameters;
* if so, require that a connection of that AI type terminates at the parent.
* Emits a fatal error when the connection is missing — without it, the workflow
* silently passes validation but breaks at runtime (see INS-136: chat trigger
* with `loadPreviousSession: 'memory'` but no memory subnode connected).
*/
function validateRequiredInputsConnected(
json: WorkflowJSON,
nodeTypesProvider: INodeTypes,
errors: ValidationError[],
): void {
const connectionsByDest = mapConnectionsByDestination(
json.connections as unknown as N8nIConnections,
);
for (const parentNode of json.nodes) {
if (!parentNode.name) continue;
const version =
typeof parentNode.typeVersion === 'string'
? parseFloat(parentNode.typeVersion)
: (parentNode.typeVersion ?? 1);
const parentNodeType = nodeTypesProvider.getByNameAndVersion(parentNode.type, version);
const builderHintInputs = parentNodeType?.description?.builderHint?.inputs;
if (!builderHintInputs) continue;
const parentContext: DisplayOptionsContext = {
parameters: (parentNode.parameters ?? {}) as Record<string, unknown>,
nodeVersion: version,
rootParameters: (parentNode.parameters ?? {}) as Record<string, unknown>,
};
for (const [connectionType, inputConfig] of Object.entries(builderHintInputs)) {
if (!connectionType.startsWith('ai_')) continue;
if (!inputConfig?.required) continue;
if (inputConfig.displayOptions) {
const conditionsMet = matchesDisplayOptions(
parentContext,
inputConfig.displayOptions as DisplayOptions,
);
if (!conditionsMet) continue;
}
const incoming = connectionsByDest[parentNode.name]?.[connectionType];
const hasConnection =
Array.isArray(incoming) && incoming.some((slot) => Array.isArray(slot) && slot.length > 0);
if (hasConnection) continue;
const subnodeField = AI_CONNECTION_TO_SUBNODE_FIELD[connectionType] || connectionType;
const conditionDetails = inputConfig.displayOptions
? ` ${buildConditionSummary(
inputConfig.displayOptions,
(parentNode.parameters ?? {}) as Record<string, unknown>,
)}`
: '';
errors.push(
new ValidationError(
'MISSING_REQUIRED_INPUT',
`'${parentNode.name}' requires a ${subnodeField} subnode connected to its ${connectionType} input, but none is connected.${conditionDetails}`,
parentNode.name,
undefined,
'major',
),
);
}
}
}
/**
* Check if connections use valid input indices for their target nodes.
* Reports warnings for connections to input indices that don't exist.
@@ -2220,6 +2220,273 @@ describe('Validation', () => {
});
});
describe('MISSING_REQUIRED_INPUT validation', () => {
const mockNodeTypesProvider = {
getByNameAndVersion: (type: string, _version?: number) => {
if (type === '@n8n/n8n-nodes-langchain.chatTrigger') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: {
ai_memory: {
required: true,
displayOptions: {
show: {
mode: ['hostedChat', 'webhook'],
'options.loadPreviousSession': ['memory'],
},
},
},
},
},
},
};
}
if (type === '@n8n/n8n-nodes-langchain.agent') {
return {
description: {
inputs: ['main'],
builderHint: {
inputs: {
ai_languageModel: { required: true },
ai_memory: { required: false },
},
},
},
};
}
return { description: { inputs: ['main'] } };
},
getByName: (type: string) => mockNodeTypesProvider.getByNameAndVersion(type),
getKnownTypes: () => ({}),
};
it('errors when chat trigger has loadPreviousSession=memory but no memory subnode', () => {
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'ct-1',
name: 'Chat Trigger',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.4,
position: [0, 0] as [number, number],
parameters: {
mode: 'hostedChat',
options: { loadPreviousSession: 'memory' },
},
},
],
connections: {},
};
const result = validateWorkflow(workflowJson, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(1);
expect(errors[0].nodeName).toBe('Chat Trigger');
expect(errors[0].message).toContain('ai_memory');
expect(errors[0].message).toContain('loadPreviousSession');
expect(result.valid).toBe(false);
});
it('passes when chat trigger has loadPreviousSession=memory and a memory subnode is connected', () => {
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'ct-1',
name: 'Chat Trigger',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.4,
position: [0, 0] as [number, number],
parameters: {
mode: 'hostedChat',
options: { loadPreviousSession: 'memory' },
},
},
{
id: 'mem-1',
name: 'Memory',
type: '@n8n/n8n-nodes-langchain.memoryBufferWindow',
typeVersion: 1,
position: [0, 200] as [number, number],
parameters: {},
},
],
connections: {
Memory: {
ai_memory: [[{ node: 'Chat Trigger', type: 'ai_memory', index: 0 }]],
},
},
};
const result = validateWorkflow(workflowJson, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(0);
});
it('passes when chat trigger has loadPreviousSession=notSupported and no memory', () => {
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'ct-1',
name: 'Chat Trigger',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.4,
position: [0, 0] as [number, number],
parameters: {
mode: 'hostedChat',
options: { loadPreviousSession: 'notSupported' },
},
},
],
connections: {},
};
const result = validateWorkflow(workflowJson, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(0);
});
it('errors on unconditional required input (agent without language model)', () => {
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'agent-1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0] as [number, number],
parameters: {},
},
],
connections: {},
};
const result = validateWorkflow(workflowJson, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(1);
expect(errors[0].nodeName).toBe('AI Agent');
expect(errors[0].message).toContain('ai_languageModel');
// No condition details when requirement is unconditional
expect(errors[0].message).not.toContain('Required:');
});
it('does not error for optional inputs', () => {
// Agent declares ai_memory as required:false — should never emit MISSING_REQUIRED_INPUT
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'agent-1',
name: 'AI Agent',
type: '@n8n/n8n-nodes-langchain.agent',
typeVersion: 3,
position: [0, 0] as [number, number],
parameters: {},
},
{
id: 'lm-1',
name: 'Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
typeVersion: 1,
position: [0, 200] as [number, number],
parameters: {},
},
],
connections: {
Model: {
ai_languageModel: [[{ node: 'AI Agent', type: 'ai_languageModel', index: 0 }]],
},
},
};
const result = validateWorkflow(workflowJson, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(0);
});
it('is a no-op for nodes without builderHint.inputs', () => {
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'n-1',
name: 'Some Node',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: [0, 0] as [number, number],
parameters: { url: 'https://example.com' },
},
],
connections: {},
};
const result = validateWorkflow(workflowJson, {
nodeTypesProvider: mockNodeTypesProvider as never,
allowDisconnectedNodes: true,
});
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(0);
});
it('is skipped when nodeTypesProvider is not supplied', () => {
const workflowJson = {
id: 'test',
name: 'Test',
nodes: [
{
id: 'ct-1',
name: 'Chat Trigger',
type: '@n8n/n8n-nodes-langchain.chatTrigger',
typeVersion: 1.4,
position: [0, 0] as [number, number],
parameters: {
mode: 'hostedChat',
options: { loadPreviousSession: 'memory' },
},
},
],
connections: {},
};
const result = validateWorkflow(workflowJson, { allowDisconnectedNodes: true });
const errors = result.errors.filter((e) => e.code === 'MISSING_REQUIRED_INPUT');
expect(errors).toHaveLength(0);
});
});
describe('Invalid subnode error message enhancement', () => {
beforeAll(setupTestSchemas, 120_000);
afterAll(teardownTestSchemas);
@@ -0,0 +1,46 @@
{
"nodes": [
{
"parameters": {
"mode": "hostedChat",
"public": true,
"options": {
"loadPreviousSession": "memory"
}
},
"id": "1ad0933d-072a-4187-9138-57883fdc908f",
"name": "Chat Trigger",
"type": "@n8n/n8n-nodes-langchain.chatTrigger",
"typeVersion": 1.4,
"position": [368, 240],
"webhookId": "a9f8eff2-024f-48fa-a8d2-a0af8ec5806f"
},
{
"parameters": {
"promptType": "auto",
"options": {}
},
"id": "4d2b6c5f-1f8c-4d1c-9d2e-18a2b6e3a8a1",
"name": "Agent",
"type": "@n8n/n8n-nodes-langchain.agent",
"typeVersion": 3,
"position": [640, 240]
},
{
"parameters": {},
"id": "7c5b1f82-0b34-4e82-8b91-77c3b16c5b21",
"name": "Memory",
"type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
"typeVersion": 1.3,
"position": [760, 480]
}
],
"connections": {
"Chat Trigger": {
"main": [[{ "node": "Agent", "type": "main", "index": 0 }]]
},
"Memory": {
"ai_memory": [[{ "node": "Agent", "type": "ai_memory", "index": 0 }]]
}
}
}
@@ -70,6 +70,15 @@
"id": 4,
"name": "Unknown nodes with connections",
"success": true
},
{
"id": 5,
"name": "Chat trigger with loadPreviousSession=memory but no memory subnode (INS-136)",
"success": true,
"expectedErrors": [
{ "code": "MISSING_REQUIRED_INPUT", "nodeName": "Chat Trigger" },
{ "code": "MISSING_REQUIRED_INPUT", "nodeName": "Agent" }
]
}
]
}