fix(editor): Remove fallback model connection when disabled in root node (#23089)

This commit is contained in:
Eugene
2025-12-15 10:29:42 +01:00
committed by GitHub
parent 2c7bd9ac11
commit 8c7267f347
4 changed files with 178 additions and 27 deletions
@@ -30,6 +30,7 @@ import {
EXECUTABLE_TRIGGER_NODE_TYPES,
MANUAL_TRIGGER_NODE_TYPE,
NO_OP_NODE_TYPE,
OPEN_AI_CHAT_MODEL_NODE_TYPE,
SET_NODE_TYPE,
SIMULATE_NODE_TYPE,
STICKY_NODE_TYPE,
@@ -121,6 +122,7 @@ export const mockNodes = [
mockNode({ name: 'Chat Trigger', type: CHAT_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Form Trigger', type: FORM_TRIGGER_NODE_TYPE }),
mockNode({ name: 'Agent', type: AGENT_NODE_TYPE }),
mockNode({ name: 'OpenAI Model', type: OPEN_AI_CHAT_MODEL_NODE_TYPE }),
mockNode({ name: 'Sticky', type: STICKY_NODE_TYPE }),
mockNode({ name: 'Simulate', type: SIMULATE_NODE_TYPE }),
mockNode({ name: CanvasNodeRenderType.AddNodes, type: CanvasNodeRenderType.AddNodes }),
@@ -39,6 +39,7 @@ import {
AGENT_NODE_TYPE,
EXECUTE_WORKFLOW_TRIGGER_NODE_TYPE,
FORM_TRIGGER_NODE_TYPE,
OPEN_AI_CHAT_MODEL_NODE_TYPE,
SET_NODE_TYPE,
STICKY_NODE_TYPE,
VIEWS,
@@ -2874,6 +2875,134 @@ describe('useCanvasOperations', () => {
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
});
it('should remove connections if the input port index is no longer valid for the type', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
workflowsStore.removeConnection = vi.fn();
const targetNodeId = 'target';
const targetNode = createTestNode({
id: targetNodeId,
name: 'Target Node',
type: AGENT_NODE_TYPE,
});
const targetNodeType = mockNodeTypeDescription({
name: AGENT_NODE_TYPE,
inputs: [NodeConnectionTypes.Main, NodeConnectionTypes.AiLanguageModel],
});
const sourceNodeId = 'source';
const sourceNode = createTestNode({
id: sourceNodeId,
name: 'Source Node',
type: OPEN_AI_CHAT_MODEL_NODE_TYPE,
});
const sourceNodeType = mockNodeTypeDescription({
name: OPEN_AI_CHAT_MODEL_NODE_TYPE,
outputs: [NodeConnectionTypes.AiLanguageModel],
});
workflowsStore.workflow.nodes = [sourceNode, targetNode];
workflowsStore.workflow.connections = {
[sourceNode.name]: {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: targetNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 1 }],
],
},
};
workflowsStore.getNodeById.mockImplementation((id) => {
if (id === sourceNodeId) return sourceNode;
if (id === targetNodeId) return targetNode;
return undefined;
});
nodeTypesStore.getNodeType = vi
.fn()
.mockReturnValueOnce(targetNodeType)
.mockReturnValueOnce(sourceNodeType);
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
workflowsStore.workflowObject = workflowObject;
const { revalidateNodeInputConnections } = useCanvasOperations();
revalidateNodeInputConnections(targetNodeId);
await nextTick();
expect(workflowsStore.removeConnection).toHaveBeenCalledWith({
connection: [
{ node: sourceNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 0 },
{ node: targetNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 1 },
],
});
});
it('should keep connections if the input port index is still valid for the type', async () => {
const workflowsStore = mockedStore(useWorkflowsStore);
const nodeTypesStore = mockedStore(useNodeTypesStore);
workflowsStore.removeConnection = vi.fn();
const targetNodeId = 'target';
const targetNode = createTestNode({
id: targetNodeId,
name: 'Target Node',
type: AGENT_NODE_TYPE,
});
const targetNodeType = mockNodeTypeDescription({
name: AGENT_NODE_TYPE,
inputs: [
NodeConnectionTypes.Main,
NodeConnectionTypes.AiLanguageModel,
NodeConnectionTypes.AiLanguageModel,
],
});
const sourceNodeId = 'source';
const sourceNode = createTestNode({
id: sourceNodeId,
name: 'Source Node',
type: OPEN_AI_CHAT_MODEL_NODE_TYPE,
});
const sourceNodeType = mockNodeTypeDescription({
name: OPEN_AI_CHAT_MODEL_NODE_TYPE,
outputs: [NodeConnectionTypes.AiLanguageModel],
});
workflowsStore.workflow.nodes = [sourceNode, targetNode];
workflowsStore.workflow.connections = {
[sourceNode.name]: {
[NodeConnectionTypes.AiLanguageModel]: [
[{ node: targetNode.name, type: NodeConnectionTypes.AiLanguageModel, index: 1 }],
],
},
};
workflowsStore.getNodeById.mockImplementation((id) => {
if (id === sourceNodeId) return sourceNode;
if (id === targetNodeId) return targetNode;
return undefined;
});
nodeTypesStore.getNodeType = vi.fn().mockImplementation((type) => {
if (type === AGENT_NODE_TYPE) return targetNodeType;
if (type === OPEN_AI_CHAT_MODEL_NODE_TYPE) return sourceNodeType;
return undefined;
});
const workflowObject = createTestWorkflowObject(workflowsStore.workflow);
workflowsStore.workflowObject = workflowObject;
const { revalidateNodeInputConnections } = useCanvasOperations();
revalidateNodeInputConnections(targetNodeId);
await nextTick();
expect(workflowsStore.removeConnection).not.toHaveBeenCalled();
});
});
describe('revalidateNodeOutputConnections', () => {
@@ -1589,6 +1589,25 @@ export function useCanvasOperations() {
);
};
const filterConnectionsByType = (
connections: Array<NodeConnectionType | INodeInputConfiguration | INodeOutputConfiguration>,
type: NodeConnectionType,
) =>
connections.filter((connection) => {
const connectionType = typeof connection === 'string' ? connection : connection.type;
return connectionType === type;
});
const getInputFilter = (
connection?: NodeConnectionType | INodeInputConfiguration | INodeOutputConfiguration,
) => {
if (connection && typeof connection === 'object' && 'filter' in connection) {
return connection.filter;
}
return undefined;
};
if (sourceConnection.type !== targetConnection.type) {
return false;
}
@@ -1613,13 +1632,11 @@ export function useCanvasOperations() {
) || [];
}
const sourceNodeHasOutputConnectionOfType = !!sourceNodeOutputs.find((output) => {
const outputType = typeof output === 'string' ? output : output.type;
return outputType === sourceConnection.type;
});
const sourceOutputsOfType = filterConnectionsByType(sourceNodeOutputs, sourceConnection.type);
const sourceNodeHasOutputConnectionOfType = sourceOutputsOfType.length > 0;
const sourceNodeHasOutputConnectionPortOfType =
sourceConnection.index < sourceNodeOutputs.length;
sourceConnection.index < sourceOutputsOfType.length;
const isMissingOutputConnection =
!sourceNodeHasOutputConnectionOfType || !sourceNodeHasOutputConnectionPortOfType;
@@ -1644,31 +1661,33 @@ export function useCanvasOperations() {
) || [];
}
const targetNodeHasInputConnectionOfType = !!targetNodeInputs.find((input) => {
const inputType = typeof input === 'string' ? input : input.type;
if (inputType !== targetConnection.type) return false;
const targetInputsOfType = filterConnectionsByType(targetNodeInputs, targetConnection.type);
const targetNodeHasInputConnectionOfType = targetInputsOfType.length > 0;
const targetNodeHasInputConnectionPortOfType =
targetConnection.index < targetInputsOfType.length;
const filter = typeof input === 'object' && 'filter' in input ? input.filter : undefined;
if (
(filter?.nodes?.length && !filter.nodes?.includes(sourceNode.type)) ||
(filter?.excludedNodes?.length && filter.excludedNodes?.includes(sourceNode.type))
) {
toast.showToast({
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
}),
type: 'error',
duration: 5000,
});
const targetConnectionDefinition = targetNodeHasInputConnectionPortOfType
? targetInputsOfType[targetConnection.index]
: undefined;
const targetConnectionFilter = getInputFilter(targetConnectionDefinition);
return false;
}
if (
(targetConnectionFilter?.nodes?.length &&
!targetConnectionFilter.nodes.includes(sourceNode.type)) ||
(targetConnectionFilter?.excludedNodes?.length &&
targetConnectionFilter.excludedNodes.includes(sourceNode.type))
) {
toast.showToast({
title: i18n.baseText('nodeView.showError.nodeNodeCompatible.title'),
message: i18n.baseText('nodeView.showError.nodeNodeCompatible.message', {
interpolate: { sourceNodeName: sourceNode.name, targetNodeName: targetNode.name },
}),
type: 'error',
duration: 5000,
});
return true;
});
const targetNodeHasInputConnectionPortOfType = targetConnection.index < targetNodeInputs.length;
return false;
}
const isMissingInputConnection =
!targetNodeHasInputConnectionOfType || !targetNodeHasInputConnectionPortOfType;
@@ -36,6 +36,7 @@ export const MCP_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.mcpTrigger';
export const CHAT_TRIGGER_NODE_TYPE = '@n8n/n8n-nodes-langchain.chatTrigger';
export const CHAT_NODE_TYPE = '@n8n/n8n-nodes-langchain.chat';
export const AGENT_NODE_TYPE = '@n8n/n8n-nodes-langchain.agent';
export const OPEN_AI_CHAT_MODEL_NODE_TYPE = '@n8n/n8n-nodes-langchain.lmChatOpenAi';
export const OPEN_AI_NODE_TYPE = '@n8n/n8n-nodes-langchain.openAi';
export const OPEN_AI_NODE_MESSAGE_ASSISTANT_TYPE =
'@n8n/n8n-nodes-langchain.openAi.assistant.message';