feat: Add remove_connection tool to workflow builder (no-changelog) (#20439)

This commit is contained in:
Eugene 2025-10-07 13:26:19 +02:00 committed by GitHub
parent f68656d6ab
commit 3a78d8cd22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 1424 additions and 123 deletions

View File

@ -9,6 +9,7 @@ import { CONNECT_NODES_TOOL, createConnectNodesTool } from './connect-nodes.tool
import { createGetNodeParameterTool, GET_NODE_PARAMETER_TOOL } from './get-node-parameter.tool';
import { createNodeDetailsTool, NODE_DETAILS_TOOL } from './node-details.tool';
import { createNodeSearchTool, NODE_SEARCH_TOOL } from './node-search.tool';
import { createRemoveConnectionTool, REMOVE_CONNECTION_TOOL } from './remove-connection.tool';
import { createRemoveNodeTool, REMOVE_NODE_TOOL } from './remove-node.tool';
import {
createUpdateNodeParametersTool,
@ -31,6 +32,7 @@ export function getBuilderTools({
createNodeDetailsTool(parsedNodeTypes),
createAddNodeTool(parsedNodeTypes),
createConnectNodesTool(parsedNodeTypes, logger),
createRemoveConnectionTool(logger),
createRemoveNodeTool(logger),
createUpdateNodeParametersTool(parsedNodeTypes, llmComplexTask, logger, instanceUrl),
createGetNodeParameterTool(),
@ -50,6 +52,7 @@ export function getBuilderToolsForDisplay({
NODE_DETAILS_TOOL,
getAddNodeToolBase(nodeTypes),
CONNECT_NODES_TOOL,
REMOVE_CONNECTION_TOOL,
REMOVE_NODE_TOOL,
UPDATING_NODE_PARAMETER_TOOL,
GET_NODE_PARAMETER_TOOL,

View File

@ -112,3 +112,27 @@ export function addConnectionToWorkflow(
],
};
}
/**
* Remove a connection from the workflow state
*/
export function removeConnectionFromWorkflow(
sourceNode: string,
targetNode: string,
connectionType: string,
sourceOutputIndex: number,
targetInputIndex: number,
): Partial<typeof WorkflowState.State> {
return {
workflowOperations: [
{
type: 'removeConnection',
sourceNode,
targetNode,
connectionType,
sourceOutputIndex,
targetInputIndex,
},
],
};
}

View File

@ -0,0 +1,329 @@
import { tool } from '@langchain/core/tools';
import type { Logger } from '@n8n/backend-common';
import { z } from 'zod';
import type { BuilderTool, BuilderToolBase } from '@/utils/stream-processor';
import { ConnectionError, NodeNotFoundError, ValidationError } from '../errors';
import type { RemoveConnectionOutput } from '../types/tools';
import { createProgressReporter, reportProgress } from './helpers/progress';
import { createSuccessResponse, createErrorResponse } from './helpers/response';
import {
getCurrentWorkflow,
getWorkflowState,
removeConnectionFromWorkflow,
} from './helpers/state';
import { validateNodeExists } from './helpers/validation';
/**
* Schema for removing a connection
*/
export const removeConnectionSchema = z.object({
sourceNodeId: z
.string()
.describe('The UUID of the source node where the connection originates from'),
targetNodeId: z.string().describe('The UUID of the target node where the connection goes to'),
connectionType: z
.string()
.optional()
.default('main')
.describe('The type of connection to remove (default: "main")'),
sourceOutputIndex: z
.number()
.optional()
.default(0)
.describe('The index of the output to disconnect from (default: 0)'),
targetInputIndex: z
.number()
.optional()
.default(0)
.describe('The index of the input to disconnect from (default: 0)'),
});
export const REMOVE_CONNECTION_TOOL: BuilderToolBase = {
toolName: 'remove_connection',
displayTitle: 'Removing connection',
};
/**
* Build the response message for the removed connection
*/
function buildResponseMessage(
sourceNodeName: string,
targetNodeName: string,
connectionType: string,
sourceOutputIndex: number,
targetInputIndex: number,
): string {
const parts: string[] = [
`Successfully removed connection: ${sourceNodeName}${targetNodeName} (${connectionType})`,
];
if (sourceOutputIndex !== 0 || targetInputIndex !== 0) {
parts.push(`Output index: ${sourceOutputIndex}, Input index: ${targetInputIndex}`);
}
return parts.join('\n');
}
/**
* Factory function to create the remove connection tool
*/
export function createRemoveConnectionTool(logger?: Logger): BuilderTool {
const dynamicTool = tool(
(input, config) => {
const reporter = createProgressReporter(
config,
REMOVE_CONNECTION_TOOL.toolName,
REMOVE_CONNECTION_TOOL.displayTitle,
);
try {
// Validate input using Zod schema
const validatedInput = removeConnectionSchema.parse(input);
// Report tool start
reporter.start(validatedInput);
// Get current state
const state = getWorkflowState();
const workflow = getCurrentWorkflow(state);
// Report progress
reportProgress(reporter, 'Finding nodes to disconnect...');
// Find source and target nodes
const sourceNode = validateNodeExists(validatedInput.sourceNodeId, workflow.nodes);
const targetNode = validateNodeExists(validatedInput.targetNodeId, workflow.nodes);
// Check if both nodes exist
if (!sourceNode || !targetNode) {
const missingNodeId = !sourceNode
? validatedInput.sourceNodeId
: validatedInput.targetNodeId;
const nodeError = new NodeNotFoundError(missingNodeId);
const error = {
message: nodeError.message,
code: 'NODES_NOT_FOUND',
details: {
sourceNodeId: validatedInput.sourceNodeId,
targetNodeId: validatedInput.targetNodeId,
foundSource: !!sourceNode,
foundTarget: !!targetNode,
},
};
reporter.error(error);
return createErrorResponse(config, error);
}
logger?.debug('\n=== Remove Connection Tool ===');
logger?.debug(
`Attempting to remove connection: ${sourceNode.name} -> ${targetNode.name} (${validatedInput.connectionType})`,
);
// Report progress
reportProgress(
reporter,
`Removing connection from ${sourceNode.name} to ${targetNode.name}...`,
);
// Check if the connection exists
const sourceConnections = workflow.connections[sourceNode.name];
if (!sourceConnections) {
const connectionError = new ConnectionError(
`Source node "${sourceNode.name}" has no outgoing connections`,
{
fromNodeId: sourceNode.id,
toNodeId: targetNode.id,
},
);
const error = {
message: connectionError.message,
code: 'CONNECTION_NOT_FOUND',
details: {
sourceNode: sourceNode.name,
targetNode: targetNode.name,
connectionType: validatedInput.connectionType,
},
};
reporter.error(error);
return createErrorResponse(config, error);
}
const connectionTypeOutputs = sourceConnections[validatedInput.connectionType];
if (!connectionTypeOutputs || !Array.isArray(connectionTypeOutputs)) {
const connectionError = new ConnectionError(
`Source node "${sourceNode.name}" has no connections of type "${validatedInput.connectionType}"`,
{
fromNodeId: sourceNode.id,
toNodeId: targetNode.id,
},
);
const error = {
message: connectionError.message,
code: 'CONNECTION_TYPE_NOT_FOUND',
details: {
sourceNode: sourceNode.name,
targetNode: targetNode.name,
connectionType: validatedInput.connectionType,
availableConnectionTypes: Object.keys(sourceConnections),
},
};
reporter.error(error);
return createErrorResponse(config, error);
}
const outputConnections = connectionTypeOutputs[validatedInput.sourceOutputIndex];
if (!outputConnections || !Array.isArray(outputConnections)) {
const connectionError = new ConnectionError(
`Source node "${sourceNode.name}" has no connections at output index ${validatedInput.sourceOutputIndex}`,
{
fromNodeId: sourceNode.id,
toNodeId: targetNode.id,
},
);
const error = {
message: connectionError.message,
code: 'OUTPUT_INDEX_NOT_FOUND',
details: {
sourceNode: sourceNode.name,
targetNode: targetNode.name,
connectionType: validatedInput.connectionType,
sourceOutputIndex: validatedInput.sourceOutputIndex,
},
};
reporter.error(error);
return createErrorResponse(config, error);
}
// Check if the specific connection exists
const connectionExists = outputConnections.some(
(conn) =>
conn.node === targetNode.name &&
conn.type === validatedInput.connectionType &&
conn.index === validatedInput.targetInputIndex,
);
if (!connectionExists) {
const connectionError = new ConnectionError(
`Connection not found: ${sourceNode.name}${targetNode.name} (${validatedInput.connectionType}) at output ${validatedInput.sourceOutputIndex} to input ${validatedInput.targetInputIndex}`,
{
fromNodeId: sourceNode.id,
toNodeId: targetNode.id,
},
);
const error = {
message: connectionError.message,
code: 'SPECIFIC_CONNECTION_NOT_FOUND',
details: {
sourceNode: sourceNode.name,
targetNode: targetNode.name,
connectionType: validatedInput.connectionType,
sourceOutputIndex: validatedInput.sourceOutputIndex,
targetInputIndex: validatedInput.targetInputIndex,
existingConnections: outputConnections.map((conn) => ({
node: conn.node,
type: conn.type,
index: conn.index,
})),
},
};
reporter.error(error);
return createErrorResponse(config, error);
}
// Build success message
const message = buildResponseMessage(
sourceNode.name,
targetNode.name,
validatedInput.connectionType,
validatedInput.sourceOutputIndex,
validatedInput.targetInputIndex,
);
logger?.debug('Connection found and will be removed');
// Report completion
const output: RemoveConnectionOutput = {
sourceNode: sourceNode.name,
targetNode: targetNode.name,
connectionType: validatedInput.connectionType,
sourceOutputIndex: validatedInput.sourceOutputIndex,
targetInputIndex: validatedInput.targetInputIndex,
message,
};
reporter.complete(output);
// Return success with state updates
const stateUpdates = removeConnectionFromWorkflow(
sourceNode.name,
targetNode.name,
validatedInput.connectionType,
validatedInput.sourceOutputIndex,
validatedInput.targetInputIndex,
);
return createSuccessResponse(config, message, stateUpdates);
} catch (error) {
// Handle validation or unexpected errors
let toolError;
if (error instanceof z.ZodError) {
const validationError = new ValidationError('Invalid connection removal parameters', {
field: error.errors[0]?.path.join('.'),
value: error.errors[0]?.message,
});
toolError = {
message: validationError.message,
code: 'VALIDATION_ERROR',
details: error.errors,
};
} else {
toolError = {
message: error instanceof Error ? error.message : 'Unknown error occurred',
code: 'EXECUTION_ERROR',
};
}
reporter.error(toolError);
return createErrorResponse(config, toolError);
}
},
{
name: REMOVE_CONNECTION_TOOL.toolName,
description: `Remove a specific connection between two nodes in the workflow. This allows you to disconnect nodes without deleting them.
USAGE:
Use this tool when you need to break an existing connection while keeping both nodes in the workflow.
PARAMETERS:
- sourceNodeId: The UUID of the node that is the source of the connection (where the data comes from)
- targetNodeId: The UUID of the node that receives the connection (where the data goes to)
- connectionType: The type of connection to remove (default: "main")
* For regular data flow: "main"
* For AI connections: "ai_languageModel", "ai_tool", "ai_memory", "ai_embedding", "ai_document", etc.
- sourceOutputIndex: Which output of the source node to disconnect from (default: 0)
- targetInputIndex: Which input of the target node to disconnect from (default: 0)
EXAMPLES:
1. Remove main data connection:
sourceNodeId: "abc-123", targetNodeId: "def-456", connectionType: "main"
2. Remove AI model from AI Agent:
sourceNodeId: "model-id", targetNodeId: "agent-id", connectionType: "ai_languageModel"
3. Remove specific connection when multiple exist:
sourceNodeId: "node-1", targetNodeId: "node-2", connectionType: "main", sourceOutputIndex: 1, targetInputIndex: 0
NOTES:
- Both nodes must exist in the workflow
- The specific connection must exist or an error will be returned
- Use this when restructuring workflows or replacing connections`,
schema: removeConnectionSchema,
},
);
return {
tool: dynamicTool,
...REMOVE_CONNECTION_TOOL,
};
}

View File

@ -3,6 +3,8 @@ import type { Logger } from '@n8n/backend-common';
import { mock } from 'jest-mock-extended';
import type { INodeTypeDescription } from 'n8n-workflow';
import { createRemoveConnectionTool, REMOVE_CONNECTION_TOOL } from '@/tools/remove-connection.tool';
import { createNodeType, nodeTypes } from '../../../test/test-utils';
import { createAddNodeTool, getAddNodeToolBase } from '../add-node.tool';
import { getBuilderTools, getBuilderToolsForDisplay } from '../builder-tools';
@ -93,6 +95,17 @@ jest.mock('../update-node-parameters.tool', () => ({
}),
}));
jest.mock('../remove-connection.tool', () => ({
REMOVE_CONNECTION_TOOL: {
name: 'removeConnectionTool',
description: 'Remove a connection between two nodes',
},
createRemoveConnectionTool: jest.fn().mockReturnValue({
name: 'removeConnectionTool',
tool: { name: 'removeConnectionTool' },
}),
}));
describe('builder-tools', () => {
let mockLogger: Logger;
let mockLlmComplexTask: BaseChatModel;
@ -114,10 +127,11 @@ describe('builder-tools', () => {
instanceUrl: 'https://test.n8n.io',
});
expect(tools).toHaveLength(7);
expect(tools).toHaveLength(8);
expect(createNodeSearchTool).toHaveBeenCalledWith(parsedNodeTypes);
expect(createNodeDetailsTool).toHaveBeenCalledWith(parsedNodeTypes);
expect(createAddNodeTool).toHaveBeenCalledWith(parsedNodeTypes);
expect(createRemoveConnectionTool).toHaveBeenCalled();
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, mockLogger);
expect(createRemoveNodeTool).toHaveBeenCalledWith(mockLogger);
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
@ -135,7 +149,7 @@ describe('builder-tools', () => {
llmComplexTask: mockLlmComplexTask,
});
expect(tools).toHaveLength(7);
expect(tools).toHaveLength(8);
expect(createConnectNodesTool).toHaveBeenCalledWith(parsedNodeTypes, undefined);
expect(createRemoveNodeTool).toHaveBeenCalledWith(undefined);
expect(createUpdateNodeParametersTool).toHaveBeenCalledWith(
@ -170,13 +184,14 @@ describe('builder-tools', () => {
nodeTypes: parsedNodeTypes,
});
expect(tools).toHaveLength(7);
expect(tools).toHaveLength(8);
expect(tools[0]).toBe(NODE_SEARCH_TOOL);
expect(tools[1]).toBe(NODE_DETAILS_TOOL);
expect(tools[3]).toBe(CONNECT_NODES_TOOL);
expect(tools[4]).toBe(REMOVE_NODE_TOOL);
expect(tools[5]).toBe(UPDATING_NODE_PARAMETER_TOOL);
expect(tools[6]).toBe(GET_NODE_PARAMETER_TOOL);
expect(tools[4]).toBe(REMOVE_CONNECTION_TOOL);
expect(tools[5]).toBe(REMOVE_NODE_TOOL);
expect(tools[6]).toBe(UPDATING_NODE_PARAMETER_TOOL);
expect(tools[7]).toBe(GET_NODE_PARAMETER_TOOL);
expect(getAddNodeToolBase).toHaveBeenCalledWith(parsedNodeTypes);
});
@ -185,7 +200,7 @@ describe('builder-tools', () => {
nodeTypes: [],
});
expect(tools).toHaveLength(7);
expect(tools).toHaveLength(8);
expect(getAddNodeToolBase).toHaveBeenCalledWith([]);
});

View File

@ -0,0 +1,536 @@
import { getCurrentTaskInput } from '@langchain/langgraph';
import {
createNode,
createWorkflow,
parseToolResult,
extractProgressMessages,
findProgressMessage,
createToolConfigWithWriter,
setupWorkflowState,
expectToolSuccess,
expectToolError,
expectWorkflowOperation,
type ParsedToolContent,
} from '../../../test/test-utils';
import { createRemoveConnectionTool } from '../remove-connection.tool';
// Mock LangGraph dependencies
jest.mock('@langchain/langgraph', () => ({
getCurrentTaskInput: jest.fn(),
Command: jest.fn().mockImplementation((params: Record<string, unknown>) => ({
content: JSON.stringify(params),
})),
}));
describe('RemoveConnectionTool', () => {
let removeConnectionTool: ReturnType<typeof createRemoveConnectionTool>['tool'];
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
typeof getCurrentTaskInput
>;
beforeEach(() => {
jest.clearAllMocks();
removeConnectionTool = createRemoveConnectionTool().tool;
});
afterEach(() => {
jest.clearAllMocks();
});
describe('invoke', () => {
it('should remove a main connection between two nodes', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
// Add existing connection
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'HTTP Request',
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-1');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectWorkflowOperation(content, 'removeConnection');
const operation = content.update.workflowOperations?.[0];
expect(operation).toMatchObject({
type: 'removeConnection',
sourceNode: 'Code',
targetNode: 'HTTP Request',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
});
expectToolSuccess(content, 'Successfully removed connection: Code → HTTP Request (main)');
// Check progress messages
const progressCalls = extractProgressMessages(mockConfig.writer);
expect(progressCalls.length).toBeGreaterThanOrEqual(3);
const startMessage = findProgressMessage(progressCalls, 'running', 'input');
expect(startMessage).toBeDefined();
const completeMessage = findProgressMessage(progressCalls, 'completed');
expect(completeMessage).toBeDefined();
});
it('should remove AI connection (ai_languageModel) between model and agent', async () => {
const existingWorkflow = createWorkflow([
createNode({
id: 'model1',
name: 'OpenAI Chat Model',
type: '@n8n/n8n-nodes-langchain.lmChatOpenAi',
}),
createNode({ id: 'agent1', name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }),
]);
// Add existing AI connection
existingWorkflow.connections = {
'OpenAI Chat Model': {
ai_languageModel: [
[
{
node: 'AI Agent',
type: 'ai_languageModel',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-2');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'model1',
targetNodeId: 'agent1',
connectionType: 'ai_languageModel',
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectWorkflowOperation(content, 'removeConnection');
const operation = content.update.workflowOperations?.[0];
expect(operation).toMatchObject({
type: 'removeConnection',
sourceNode: 'OpenAI Chat Model',
targetNode: 'AI Agent',
connectionType: 'ai_languageModel',
});
expectToolSuccess(
content,
'Successfully removed connection: OpenAI Chat Model → AI Agent (ai_languageModel)',
);
});
it('should remove connection at specific output/input indices', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'If', type: 'n8n-nodes-base.if' }),
createNode({ id: 'node2', name: 'Set', type: 'n8n-nodes-base.set' }),
]);
// Add connection at output index 1 (false branch)
existingWorkflow.connections = {
If: {
main: [
[], // Output 0 (true branch) - empty
[
{
node: 'Set',
type: 'main',
index: 0,
},
], // Output 1 (false branch)
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-3');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
connectionType: 'main',
sourceOutputIndex: 1,
targetInputIndex: 0,
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectWorkflowOperation(content, 'removeConnection');
const operation = content.update.workflowOperations?.[0];
expect(operation).toMatchObject({
sourceOutputIndex: 1,
targetInputIndex: 0,
});
expectToolSuccess(content, /Output index: 1, Input index: 0/);
});
it('should handle removing one connection when multiple exist', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'Set 1', type: 'n8n-nodes-base.set' }),
createNode({ id: 'node3', name: 'Set 2', type: 'n8n-nodes-base.set' }),
]);
// Add multiple connections from same source
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'Set 1',
type: 'main',
index: 0,
},
{
node: 'Set 2',
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-4');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2', // Remove only connection to Set 1
connectionType: 'main',
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectWorkflowOperation(content, 'removeConnection');
expectToolSuccess(content, 'Successfully removed connection: Code → Set 1 (main)');
const operation = content.update.workflowOperations?.[0];
expect(operation).toMatchObject({
type: 'removeConnection',
sourceNode: 'Code',
targetNode: 'Set 1',
connectionType: 'main',
});
});
it('should return error when source node not found', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-5');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'nonexistent',
targetNodeId: 'node2',
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectToolError(content, /not found/);
// Check error was reported
const progressCalls = extractProgressMessages(mockConfig.writer);
const errorMessage = findProgressMessage(progressCalls, 'error');
expect(errorMessage).toBeDefined();
});
it('should return error when target node not found', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
]);
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-6');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'nonexistent',
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectToolError(content, /not found/);
});
it('should return error when source node has no connections', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
// No connections at all
existingWorkflow.connections = {};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-7');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectToolError(content, /has no outgoing connections/);
});
it('should return error when connection type does not exist', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
// Only main connections exist
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'HTTP Request',
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-8');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
connectionType: 'ai_languageModel', // Wrong type
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectToolError(content, /has no connections of type "ai_languageModel"/);
});
it('should return error when output index does not exist', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'HTTP Request',
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-9');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
connectionType: 'main',
sourceOutputIndex: 5, // Out of range
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectToolError(content, /has no connections at output index 5/);
});
it('should return error when specific connection does not exist', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
createNode({ id: 'node3', name: 'Set', type: 'n8n-nodes-base.set' }),
]);
// Connection exists, but to a different node
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'Set', // Connected to Set, not HTTP Request
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-10');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2', // Trying to remove connection to HTTP Request
connectionType: 'main',
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectToolError(content, /Connection not found/);
});
it('should default connectionType to "main" when not specified', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'HTTP Request',
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-11');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
// connectionType omitted - should default to 'main'
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectWorkflowOperation(content, 'removeConnection');
const operation = content.update.workflowOperations?.[0];
expect(operation?.connectionType).toBe('main');
});
it('should default indices to 0 when not specified', async () => {
const existingWorkflow = createWorkflow([
createNode({ id: 'node1', name: 'Code', type: 'n8n-nodes-base.code' }),
createNode({ id: 'node2', name: 'HTTP Request', type: 'n8n-nodes-base.httpRequest' }),
]);
existingWorkflow.connections = {
Code: {
main: [
[
{
node: 'HTTP Request',
type: 'main',
index: 0,
},
],
],
},
};
setupWorkflowState(mockGetCurrentTaskInput, existingWorkflow);
const mockConfig = createToolConfigWithWriter('remove_connection', 'test-call-12');
const result = await removeConnectionTool.invoke(
{
sourceNodeId: 'node1',
targetNodeId: 'node2',
// indices omitted - should default to 0
},
mockConfig,
);
const content = parseToolResult<ParsedToolContent>(result);
expectWorkflowOperation(content, 'removeConnection');
const operation = content.update.workflowOperations?.[0];
expect(operation?.sourceOutputIndex).toBe(0);
expect(operation?.targetInputIndex).toBe(0);
});
});
});

View File

@ -132,3 +132,15 @@ export interface NodeSearchOutput {
export interface GetNodeParameterOutput {
message: string; // This is only to report success or error, without actual value (we don't need to send it to the frontend)
}
/**
* Output type for remove connection tool
*/
export interface RemoveConnectionOutput {
sourceNode: string;
targetNode: string;
connectionType: string;
sourceOutputIndex: number;
targetInputIndex: number;
message: string;
}

View File

@ -15,4 +15,12 @@ export type WorkflowOperation =
| { type: 'updateNode'; nodeId: string; updates: Partial<INode> }
| { type: 'setConnections'; connections: IConnections }
| { type: 'mergeConnections'; connections: IConnections }
| {
type: 'removeConnection';
sourceNode: string;
targetNode: string;
connectionType: string;
sourceOutputIndex: number;
targetInputIndex: number;
}
| { type: 'setName'; name: string };

View File

@ -3,10 +3,292 @@ import type { INode, IConnections } from 'n8n-workflow';
import type { SimpleWorkflow, WorkflowOperation } from '../types/workflow';
import type { WorkflowState } from '../workflow-state';
/**
* Type for operation handler functions
*/
type OperationHandler = (workflow: SimpleWorkflow, operation: WorkflowOperation) => SimpleWorkflow;
/**
* Handle 'clear' operation - reset workflow to empty state
*/
function applyClearOperation(
_workflow: SimpleWorkflow,
_operation: WorkflowOperation,
): SimpleWorkflow {
return { nodes: [], connections: {}, name: '' };
}
/**
* Handle 'removeNode' operation - remove nodes and their connections
*/
function applyRemoveNodeOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'removeNode') return workflow;
const nodesToRemove = new Set(operation.nodeIds);
// Filter out removed nodes
const nodes = workflow.nodes.filter((node) => !nodesToRemove.has(node.id));
// Clean up connections
const cleanedConnections: IConnections = {};
// Copy connections, excluding those from/to removed nodes
for (const [sourceId, nodeConnections] of Object.entries(workflow.connections)) {
if (!nodesToRemove.has(sourceId)) {
cleanedConnections[sourceId] = {};
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
if (Array.isArray(outputs)) {
cleanedConnections[sourceId][connectionType] = outputs.map((outputConnections) => {
if (Array.isArray(outputConnections)) {
return outputConnections.filter((conn) => !nodesToRemove.has(conn.node));
}
return outputConnections;
});
}
}
}
}
return {
...workflow,
nodes,
connections: cleanedConnections,
};
}
/**
* Handle 'addNodes' operation - add or update nodes in workflow
*/
function applyAddNodesOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'addNodes') return workflow;
// Create a map for quick lookup
const nodeMap = new Map<string, INode>();
workflow.nodes.forEach((node) => nodeMap.set(node.id, node));
// Add or update nodes
operation.nodes.forEach((node) => {
nodeMap.set(node.id, node);
});
return {
...workflow,
nodes: Array.from(nodeMap.values()),
};
}
/**
* Handle 'updateNode' operation - update specific node properties
*/
function applyUpdateNodeOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'updateNode') return workflow;
const nodes = workflow.nodes.map((node) => {
if (node.id === operation.nodeId) {
return { ...node, ...operation.updates };
}
return node;
});
return {
...workflow,
nodes,
};
}
/**
* Handle 'setConnections' operation - replace all connections
*/
function applySetConnectionsOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'setConnections') return workflow;
return {
...workflow,
connections: operation.connections,
};
}
/**
* Handle 'mergeConnections' operation - merge new connections with existing ones
*/
function applyMergeConnectionsOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'mergeConnections') return workflow;
const connections = { ...workflow.connections };
// Merge connections additively
for (const [sourceId, nodeConnections] of Object.entries(operation.connections)) {
if (!connections[sourceId]) {
connections[sourceId] = nodeConnections;
} else {
// Merge connections for this source node
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
if (!connections[sourceId][connectionType]) {
connections[sourceId][connectionType] = newOutputs;
} else {
// Merge arrays of connections
const existingOutputs = connections[sourceId][connectionType];
if (Array.isArray(newOutputs) && Array.isArray(existingOutputs)) {
// Merge each output index
for (let i = 0; i < Math.max(newOutputs.length, existingOutputs.length); i++) {
if (!newOutputs[i]) continue;
if (!existingOutputs[i]) {
existingOutputs[i] = newOutputs[i];
} else if (Array.isArray(newOutputs[i]) && Array.isArray(existingOutputs[i])) {
// Merge connections at this output index, avoiding duplicates
const existingSet = new Set(
existingOutputs[i]!.map((conn) =>
JSON.stringify({ node: conn.node, type: conn.type, index: conn.index }),
),
);
newOutputs[i]!.forEach((conn) => {
const connStr = JSON.stringify({
node: conn.node,
type: conn.type,
index: conn.index,
});
if (!existingSet.has(connStr)) {
existingOutputs[i]!.push(conn);
}
});
}
}
}
}
}
}
}
return {
...workflow,
connections,
};
}
/**
* Handle 'removeConnection' operation - remove specific connection between nodes
*/
function applyRemoveConnectionOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'removeConnection') return workflow;
const { sourceNode, targetNode, connectionType, sourceOutputIndex, targetInputIndex } = operation;
const connections = { ...workflow.connections };
// Check if source node has connections
if (!connections[sourceNode]) {
return workflow;
}
// Check if the connection type exists
const connectionTypeOutputs = connections[sourceNode][connectionType];
if (!connectionTypeOutputs || !Array.isArray(connectionTypeOutputs)) {
return workflow;
}
// Check if the output index exists
if (
sourceOutputIndex >= connectionTypeOutputs.length ||
!connectionTypeOutputs[sourceOutputIndex]
) {
return workflow;
}
const outputConnections = connectionTypeOutputs[sourceOutputIndex];
if (!Array.isArray(outputConnections)) {
return workflow;
}
// Filter out the specific connection
const filteredConnections = outputConnections.filter(
(conn) =>
!(
conn.node === targetNode &&
conn.type === connectionType &&
conn.index === targetInputIndex
),
);
// Update the connections array
connectionTypeOutputs[sourceOutputIndex] = filteredConnections;
// Clean up empty arrays and objects
if (filteredConnections.length === 0) {
// Check if all outputs of this type are empty
const hasAnyConnections = connectionTypeOutputs.some(
(outputs) => Array.isArray(outputs) && outputs.length > 0,
);
// If no connections remain for this type, remove the connection type
if (!hasAnyConnections) {
delete connections[sourceNode][connectionType];
// If no connection types remain, remove the source node entry
if (Object.keys(connections[sourceNode]).length === 0) {
delete connections[sourceNode];
}
}
}
return {
...workflow,
connections,
};
}
/**
* Handle 'setName' operation - update workflow name
*/
function applySetNameOperation(
workflow: SimpleWorkflow,
operation: WorkflowOperation,
): SimpleWorkflow {
if (operation.type !== 'setName') return workflow;
return {
...workflow,
name: operation.name,
};
}
/**
* Map of operation types to their handler functions
*/
const operationHandlers: Record<WorkflowOperation['type'], OperationHandler> = {
clear: applyClearOperation,
removeNode: applyRemoveNodeOperation,
addNodes: applyAddNodesOperation,
updateNode: applyUpdateNodeOperation,
setConnections: applySetConnectionsOperation,
mergeConnections: applyMergeConnectionsOperation,
removeConnection: applyRemoveConnectionOperation,
setName: applySetNameOperation,
};
/**
* Apply a list of operations to a workflow
*/
// eslint-disable-next-line complexity
export function applyOperations(
workflow: SimpleWorkflow,
operations: WorkflowOperation[],
@ -20,121 +302,8 @@ export function applyOperations(
// Apply each operation in sequence
for (const operation of operations) {
switch (operation.type) {
case 'clear':
result = { nodes: [], connections: {}, name: '' };
break;
case 'removeNode': {
const nodesToRemove = new Set(operation.nodeIds);
// Filter out removed nodes
result.nodes = result.nodes.filter((node) => !nodesToRemove.has(node.id));
// Clean up connections
const cleanedConnections: IConnections = {};
// Copy connections, excluding those from/to removed nodes
for (const [sourceId, nodeConnections] of Object.entries(result.connections)) {
if (!nodesToRemove.has(sourceId)) {
cleanedConnections[sourceId] = {};
for (const [connectionType, outputs] of Object.entries(nodeConnections)) {
if (Array.isArray(outputs)) {
cleanedConnections[sourceId][connectionType] = outputs.map((outputConnections) => {
if (Array.isArray(outputConnections)) {
return outputConnections.filter((conn) => !nodesToRemove.has(conn.node));
}
return outputConnections;
});
}
}
}
}
result.connections = cleanedConnections;
break;
}
case 'addNodes': {
// Create a map for quick lookup
const nodeMap = new Map<string, INode>();
result.nodes.forEach((node) => nodeMap.set(node.id, node));
// Add or update nodes
operation.nodes.forEach((node) => {
nodeMap.set(node.id, node);
});
result.nodes = Array.from(nodeMap.values());
break;
}
case 'updateNode': {
result.nodes = result.nodes.map((node) => {
if (node.id === operation.nodeId) {
return { ...node, ...operation.updates };
}
return node;
});
break;
}
case 'setConnections': {
// Replace connections entirely
result.connections = operation.connections;
break;
}
case 'mergeConnections': {
// Merge connections additively
for (const [sourceId, nodeConnections] of Object.entries(operation.connections)) {
if (!result.connections[sourceId]) {
result.connections[sourceId] = nodeConnections;
} else {
// Merge connections for this source node
for (const [connectionType, newOutputs] of Object.entries(nodeConnections)) {
if (!result.connections[sourceId][connectionType]) {
result.connections[sourceId][connectionType] = newOutputs;
} else {
// Merge arrays of connections
const existingOutputs = result.connections[sourceId][connectionType];
if (Array.isArray(newOutputs) && Array.isArray(existingOutputs)) {
// Merge each output index
for (let i = 0; i < Math.max(newOutputs.length, existingOutputs.length); i++) {
if (!newOutputs[i]) continue;
if (!existingOutputs[i]) {
existingOutputs[i] = newOutputs[i];
} else if (Array.isArray(newOutputs[i]) && Array.isArray(existingOutputs[i])) {
// Merge connections at this output index, avoiding duplicates
const existingSet = new Set(
existingOutputs[i]!.map((conn) =>
JSON.stringify({ node: conn.node, type: conn.type, index: conn.index }),
),
);
newOutputs[i]!.forEach((conn) => {
const connStr = JSON.stringify({
node: conn.node,
type: conn.type,
index: conn.index,
});
if (!existingSet.has(connStr)) {
existingOutputs[i]!.push(conn);
}
});
}
}
}
}
}
}
}
break;
}
}
const handler = operationHandlers[operation.type];
result = handler(result, operation);
}
return result;

View File

@ -310,6 +310,211 @@ describe('operations-processor', () => {
});
});
describe('removeConnection operation', () => {
it('should remove a specific connection between two nodes', () => {
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node2',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Connection should be removed and cleaned up entirely
expect(result.connections.node1).toBeUndefined();
// Other connections should remain
expect(result.connections.node2.main[0]).toEqual([
{ node: 'node3', type: 'main', index: 0 },
]);
});
it('should remove connection and clean up empty structures', () => {
// Workflow with only one connection from node1
const simpleWorkflow: SimpleWorkflow = {
name: 'Simple',
nodes: [node1, node2],
connections: {
node1: {
main: [[{ node: 'node2', type: 'main', index: 0 }]],
},
},
};
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node2',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(simpleWorkflow, operations);
// Empty structures are cleaned up completely
expect(result.connections.node1).toBeUndefined();
});
it('should remove one connection when multiple exist at same output', () => {
// Add multiple connections from node1 output 0
baseWorkflow.connections.node1.main = [
[
{ node: 'node2', type: 'main', index: 0 },
{ node: 'node3', type: 'main', index: 0 },
],
];
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node2',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Only node2 connection removed, node3 connection remains
expect(result.connections.node1.main[0]).toEqual([
{ node: 'node3', type: 'main', index: 0 },
]);
});
it('should remove connection at specific output index', () => {
// Setup multi-output connection
baseWorkflow.connections.node1.main = [
[{ node: 'node2', type: 'main', index: 0 }], // Output 0
[{ node: 'node3', type: 'main', index: 0 }], // Output 1
];
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node3',
connectionType: 'main',
sourceOutputIndex: 1,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Output 0 unchanged
expect(result.connections.node1.main[0]).toEqual([
{ node: 'node2', type: 'main', index: 0 },
]);
// Output 1 connection removed
expect(result.connections.node1.main[1]).toEqual([]);
});
it('should handle AI connection types', () => {
// Setup AI connection
baseWorkflow.connections.node1.ai_languageModel = [
[{ node: 'node2', type: 'ai_languageModel', index: 0 }],
];
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node2',
connectionType: 'ai_languageModel',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// AI connection type removed, but main connection remains
expect(result.connections.node1.ai_languageModel).toBeUndefined();
expect(result.connections.node1.main).toBeDefined();
});
it('should handle non-existent connection gracefully', () => {
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node3', // No direct connection
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Workflow unchanged
expect(result.connections).toEqual(baseWorkflow.connections);
});
it('should handle non-existent source node gracefully', () => {
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'non-existent',
targetNode: 'node2',
connectionType: 'main',
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Workflow unchanged
expect(result.connections).toEqual(baseWorkflow.connections);
});
it('should handle non-existent connection type gracefully', () => {
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node2',
connectionType: 'ai_tool', // Does not exist
sourceOutputIndex: 0,
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Workflow unchanged
expect(result.connections).toEqual(baseWorkflow.connections);
});
it('should handle out-of-range output index gracefully', () => {
const operations: WorkflowOperation[] = [
{
type: 'removeConnection',
sourceNode: 'node1',
targetNode: 'node2',
connectionType: 'main',
sourceOutputIndex: 99, // Out of range
targetInputIndex: 0,
},
];
const result = applyOperations(baseWorkflow, operations);
// Workflow unchanged
expect(result.connections).toEqual(baseWorkflow.connections);
});
});
describe('multiple operations', () => {
it('should apply operations in sequence', () => {
const newNode = createNode({ id: 'node4', name: 'Node 4' });