diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/builder-tools.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/builder-tools.ts index 0220fee903f..120270caa67 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/builder-tools.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/builder-tools.ts @@ -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, diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/state.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/state.ts index 5fe25c96b59..9a371ff4a8a 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/state.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/state.ts @@ -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 { + return { + workflowOperations: [ + { + type: 'removeConnection', + sourceNode, + targetNode, + connectionType, + sourceOutputIndex, + targetInputIndex, + }, + ], + }; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-connection.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-connection.tool.ts new file mode 100644 index 00000000000..f0e96c2684c --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-connection.tool.ts @@ -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, + }; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/builder-tools.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/builder-tools.test.ts index 54f0ed73c12..e9446ceb0eb 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/builder-tools.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/builder-tools.test.ts @@ -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([]); }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-connection.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-connection.tool.test.ts new file mode 100644 index 00000000000..fcaf01736fa --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-connection.tool.test.ts @@ -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) => ({ + content: JSON.stringify(params), + })), +})); + +describe('RemoveConnectionTool', () => { + let removeConnectionTool: ReturnType['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(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(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(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(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(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(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(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(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(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(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(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(result); + + expectWorkflowOperation(content, 'removeConnection'); + const operation = content.update.workflowOperations?.[0]; + expect(operation?.sourceOutputIndex).toBe(0); + expect(operation?.targetInputIndex).toBe(0); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/types/tools.ts b/packages/@n8n/ai-workflow-builder.ee/src/types/tools.ts index 13c8d93d2b9..df825746584 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/tools.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/tools.ts @@ -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; +} diff --git a/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts b/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts index 635e92bb812..c554953d3c0 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/workflow.ts @@ -15,4 +15,12 @@ export type WorkflowOperation = | { type: 'updateNode'; nodeId: string; updates: Partial } | { type: 'setConnections'; connections: IConnections } | { type: 'mergeConnections'; connections: IConnections } + | { + type: 'removeConnection'; + sourceNode: string; + targetNode: string; + connectionType: string; + sourceOutputIndex: number; + targetInputIndex: number; + } | { type: 'setName'; name: string }; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts index 10470392b3a..d5a1a18a26d 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/operations-processor.ts @@ -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(); + 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 = { + 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(); - 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; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts index 4feca4305fb..7684676c030 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/operations-processor.test.ts @@ -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' });