mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 00:37:10 +02:00
feat: Add remove_connection tool to workflow builder (no-changelog) (#20439)
This commit is contained in:
parent
f68656d6ab
commit
3a78d8cd22
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user