mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-24 23:22:38 +08:00
fix(ai-builder): Validate required inputs on AI nodes (#29159)
This commit is contained in:
@@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user