diff --git a/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts index 766c959ad0..8e385582ce 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/test/workflow-builder-agent.test.ts @@ -19,22 +19,24 @@ import { } from '@/workflow-builder-agent'; jest.mock('@/tools/add-node.tool', () => ({ - createAddNodeTool: jest.fn().mockReturnValue({ name: 'add_node' }), + createAddNodeTool: jest.fn().mockReturnValue({ tool: { name: 'add_node' } }), })); jest.mock('@/tools/connect-nodes.tool', () => ({ - createConnectNodesTool: jest.fn().mockReturnValue({ name: 'connect_nodes' }), + createConnectNodesTool: jest.fn().mockReturnValue({ tool: { name: 'connect_nodes' } }), })); jest.mock('@/tools/node-details.tool', () => ({ - createNodeDetailsTool: jest.fn().mockReturnValue({ name: 'node_details' }), + createNodeDetailsTool: jest.fn().mockReturnValue({ tool: { name: 'node_details' } }), })); jest.mock('@/tools/node-search.tool', () => ({ - createNodeSearchTool: jest.fn().mockReturnValue({ name: 'node_search' }), + createNodeSearchTool: jest.fn().mockReturnValue({ tool: { name: 'node_search' } }), })); jest.mock('@/tools/remove-node.tool', () => ({ - createRemoveNodeTool: jest.fn().mockReturnValue({ name: 'remove_node' }), + createRemoveNodeTool: jest.fn().mockReturnValue({ tool: { name: 'remove_node' } }), })); jest.mock('@/tools/update-node-parameters.tool', () => ({ - createUpdateNodeParametersTool: jest.fn().mockReturnValue({ name: 'update_node_parameters' }), + createUpdateNodeParametersTool: jest + .fn() + .mockReturnValue({ tool: { name: 'update_node_parameters' } }), })); jest.mock('@/tools/prompts/main-agent.prompt', () => ({ mainAgentPrompt: { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/add-node.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/add-node.tool.ts index 056544f1b5..999f9a6e8d 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/add-node.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/add-node.tool.ts @@ -13,6 +13,8 @@ import { findNodeType } from './helpers/validation'; import type { AddedNode } from '../types/nodes'; import type { AddNodeOutput, ToolError } from '../types/tools'; +const DISPLAY_TITLE = 'Adding node'; + /** * Schema for node creation input */ @@ -64,13 +66,32 @@ function buildResponseMessage(addedNode: AddedNode, nodeTypes: INodeTypeDescript return `Successfully added "${addedNode.name}" (${addedNode.displayName ?? addedNode.type})${nodeTypeInfo} with ID ${addedNode.id}`; } +function getCustomNodeTitle( + input: Record, + nodeTypes: INodeTypeDescription[], +): string { + if ('nodeType' in input && typeof input['nodeType'] === 'string') { + const nodeType = nodeTypes.find((type) => type.name === input.nodeType); + if (nodeType) { + return `Adding ${nodeType.displayName} node`; + } + } + + return DISPLAY_TITLE; +} + /** * Factory function to create the add node tool */ export function createAddNodeTool(nodeTypes: INodeTypeDescription[]) { - return tool( + const dynamicTool = tool( async (input, config) => { - const reporter = createProgressReporter(config, 'add_nodes'); + const reporter = createProgressReporter( + config, + 'add_nodes', + DISPLAY_TITLE, + getCustomNodeTitle(input, nodeTypes), + ); try { // Validate input using Zod schema @@ -194,4 +215,10 @@ Think through the connectionParametersReasoning FIRST, then set connectionParame schema: nodeCreationSchema, }, ); + + return { + tool: dynamicTool, + displayTitle: DISPLAY_TITLE, + getCustomDisplayTitle: (input: Record) => getCustomNodeTitle(input, nodeTypes), + }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts index cd51783d4c..f4cfe81019 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/connect-nodes.tool.ts @@ -49,10 +49,12 @@ export const nodeConnectionSchema = z.object({ * Factory function to create the connect nodes tool */ export function createConnectNodesTool(nodeTypes: INodeTypeDescription[], logger?: Logger) { - return tool( + const DISPLAY_TITLE = 'Connecting nodes'; + + const dynamicTool = tool( // eslint-disable-next-line complexity (input, config) => { - const reporter = createProgressReporter(config, 'connect_nodes'); + const reporter = createProgressReporter(config, 'connect_nodes', DISPLAY_TITLE); try { // Validate input using Zod schema @@ -316,4 +318,9 @@ CONNECTION EXAMPLES: schema: nodeConnectionSchema, }, ); + + return { + tool: dynamicTool, + displayTitle: DISPLAY_TITLE, + }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/progress.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/progress.ts index 475342a219..f4867a17f6 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/progress.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/progress.ts @@ -10,22 +10,36 @@ import type { /** * Create a progress reporter for a tool execution + * + * @param config + * @param toolName + * @param displayTitle the general tool action name, for example "Searching for nodes" + * @param customTitle custom title per tool call, for example "Searching for OpenAI" */ export function createProgressReporter( config: ToolRunnableConfig & LangGraphRunnableConfig, toolName: TToolName, + displayTitle: string, + customTitle?: string, ): ProgressReporter { const toolCallId = config.toolCall?.id; + let customDisplayTitle = customTitle; + const emit = (message: ToolProgressMessage): void => { config.writer?.(message); }; - const start = (input: T): void => { + const start = (input: T, options?: { customDisplayTitle: string }): void => { + if (options?.customDisplayTitle) { + customDisplayTitle = options.customDisplayTitle; + } emit({ type: 'tool', toolName, toolCallId, + displayTitle, + customDisplayTitle, status: 'running', updates: [ { @@ -41,6 +55,8 @@ export function createProgressReporter( type: 'tool', toolName, toolCallId, + displayTitle, + customDisplayTitle, status: 'running', updates: [ { @@ -56,6 +72,8 @@ export function createProgressReporter( type: 'tool', toolName, toolCallId, + displayTitle, + customDisplayTitle, status: 'completed', updates: [ { @@ -71,6 +89,8 @@ export function createProgressReporter( type: 'tool', toolName, toolCallId, + displayTitle, + customDisplayTitle, status: 'error', updates: [ { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/test/progress.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/test/progress.test.ts new file mode 100644 index 0000000000..72c58739b4 --- /dev/null +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/helpers/test/progress.test.ts @@ -0,0 +1,436 @@ +import type { ToolRunnableConfig } from '@langchain/core/tools'; +import type { LangGraphRunnableConfig } from '@langchain/langgraph'; + +import type { ToolError } from '../../../types/tools'; +import { + createProgressReporter, + reportStart, + reportProgress, + reportComplete, + reportError, + createBatchProgressReporter, +} from '../progress'; + +describe('progress helpers', () => { + let mockWriter: jest.MockedFunction<(chunk: unknown) => void>; + let mockConfig: ToolRunnableConfig & LangGraphRunnableConfig; + + beforeEach(() => { + mockWriter = jest.fn(); + mockConfig = { + writer: mockWriter, + toolCall: { + id: 'test-tool-call-id', + name: 'test-tool', + args: {}, + }, + }; + }); + + describe('createProgressReporter', () => { + it('should create a progress reporter with all methods', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + + expect(reporter).toHaveProperty('start'); + expect(reporter).toHaveProperty('progress'); + expect(reporter).toHaveProperty('complete'); + expect(reporter).toHaveProperty('error'); + expect(reporter).toHaveProperty('createBatchReporter'); + expect(typeof reporter.start).toBe('function'); + expect(typeof reporter.progress).toBe('function'); + expect(typeof reporter.complete).toBe('function'); + expect(typeof reporter.error).toBe('function'); + expect(typeof reporter.createBatchReporter).toBe('function'); + }); + + it('should emit start message with input data', () => { + const reporter = createProgressReporter( + mockConfig, + 'add_node', + 'Adding Node', + 'Adding Code Node', + ); + const input = { nodeType: 'code', name: 'Test Node' }; + + reporter.start(input); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'add_node', + toolCallId: 'test-tool-call-id', + displayTitle: 'Adding Node', + customDisplayTitle: 'Adding Code Node', + status: 'running', + updates: [ + { + type: 'input', + data: input, + }, + ], + }); + }); + + it('should emit progress message with string message', () => { + const reporter = createProgressReporter(mockConfig, 'connect_nodes', 'Connecting Nodes'); + + reporter.progress('Connecting node A to node B'); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'connect_nodes', + toolCallId: 'test-tool-call-id', + displayTitle: 'Connecting Nodes', + customDisplayTitle: undefined, + status: 'running', + updates: [ + { + type: 'progress', + data: { message: 'Connecting node A to node B' }, + }, + ], + }); + }); + + it('should emit progress message with custom data', () => { + const reporter = createProgressReporter(mockConfig, 'search_nodes', 'Searching Nodes'); + const customData = { found: 5, query: 'http' }; + + reporter.progress('Found nodes', customData); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'search_nodes', + toolCallId: 'test-tool-call-id', + displayTitle: 'Searching Nodes', + customDisplayTitle: undefined, + status: 'running', + updates: [ + { + type: 'progress', + data: customData, + }, + ], + }); + }); + + it('should emit complete message with output data', () => { + const reporter = createProgressReporter(mockConfig, 'remove_node', 'Removing Node'); + const output = { nodeId: 'node123', success: true }; + + reporter.complete(output); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'remove_node', + toolCallId: 'test-tool-call-id', + displayTitle: 'Removing Node', + customDisplayTitle: undefined, + status: 'completed', + updates: [ + { + type: 'output', + data: output, + }, + ], + }); + }); + + it('should emit error message with error details', () => { + const reporter = createProgressReporter(mockConfig, 'update_node', 'Updating Node'); + const error: ToolError = { + message: 'Node not found', + code: 'NODE_NOT_FOUND', + details: { nodeId: 'missing-node' }, + }; + + reporter.error(error); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'update_node', + toolCallId: 'test-tool-call-id', + displayTitle: 'Updating Node', + customDisplayTitle: undefined, + status: 'error', + updates: [ + { + type: 'error', + data: { + message: 'Node not found', + code: 'NODE_NOT_FOUND', + details: { nodeId: 'missing-node' }, + }, + }, + ], + }); + }); + + it('should handle missing writer gracefully', () => { + const configWithoutWriter = { ...mockConfig, writer: undefined }; + const reporter = createProgressReporter(configWithoutWriter, 'test_tool', 'Test Tool'); + + expect(() => reporter.start({ test: 'data' })).not.toThrow(); + expect(() => reporter.progress('test message')).not.toThrow(); + expect(() => reporter.complete({ result: 'success' })).not.toThrow(); + expect(() => reporter.error({ message: 'test error' })).not.toThrow(); + }); + + it('should handle missing toolCallId', () => { + const configWithoutToolCallId = { ...mockConfig, toolCall: undefined }; + const reporter = createProgressReporter(configWithoutToolCallId, 'test_tool', 'Test Tool'); + + reporter.start({ test: 'data' }); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + toolCallId: undefined, + }), + ); + }); + }); + + describe('batch reporter', () => { + it('should create batch reporter with correct interface', () => { + const reporter = createProgressReporter(mockConfig, 'batch_tool', 'Batch Tool'); + const batchReporter = reporter.createBatchReporter('Processing items'); + + expect(batchReporter).toHaveProperty('init'); + expect(batchReporter).toHaveProperty('next'); + expect(batchReporter).toHaveProperty('complete'); + expect(typeof batchReporter.init).toBe('function'); + expect(typeof batchReporter.next).toBe('function'); + expect(typeof batchReporter.complete).toBe('function'); + }); + + it('should track batch progress correctly', () => { + const reporter = createProgressReporter(mockConfig, 'batch_tool', 'Batch Tool'); + const batchReporter = reporter.createBatchReporter('Processing nodes'); + + batchReporter.init(3); + batchReporter.next('First node'); + batchReporter.next('Second node'); + batchReporter.next('Third node'); + batchReporter.complete(); + + expect(mockWriter).toHaveBeenCalledTimes(4); + expect(mockWriter).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + updates: [ + { + type: 'progress', + data: { message: 'Processing nodes: Processing item 1 of 3: First node' }, + }, + ], + }), + ); + expect(mockWriter).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + updates: [ + { + type: 'progress', + data: { message: 'Processing nodes: Processing item 2 of 3: Second node' }, + }, + ], + }), + ); + expect(mockWriter).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + updates: [ + { + type: 'progress', + data: { message: 'Processing nodes: Processing item 3 of 3: Third node' }, + }, + ], + }), + ); + expect(mockWriter).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + updates: [ + { type: 'progress', data: { message: 'Processing nodes: Completed all 3 items' } }, + ], + }), + ); + }); + + it('should reset counter when init is called again', () => { + const reporter = createProgressReporter(mockConfig, 'batch_tool', 'Batch Tool'); + const batchReporter = reporter.createBatchReporter('Testing reset'); + + batchReporter.init(2); + batchReporter.next('Item 1'); + batchReporter.init(1); // Reset + batchReporter.next('New item'); + + expect(mockWriter).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + updates: [ + { + type: 'progress', + data: { message: 'Testing reset: Processing item 1 of 2: Item 1' }, + }, + ], + }), + ); + expect(mockWriter).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + updates: [ + { + type: 'progress', + data: { message: 'Testing reset: Processing item 1 of 1: New item' }, + }, + ], + }), + ); + }); + }); + + describe('helper functions', () => { + it('should call reporter.start through reportStart', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + const input = { test: 'input' }; + + reportStart(reporter, input); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + updates: [{ type: 'input', data: input }], + }), + ); + }); + + it('should call reporter.progress through reportProgress', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + + reportProgress(reporter, 'Test message'); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + updates: [{ type: 'progress', data: { message: 'Test message' } }], + }), + ); + }); + + it('should call reporter.progress with custom data through reportProgress', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + const customData = { step: 2, total: 5 }; + + reportProgress(reporter, 'Processing step', customData); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'running', + updates: [{ type: 'progress', data: customData }], + }), + ); + }); + + it('should call reporter.complete through reportComplete', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + const output = { result: 'success' }; + + reportComplete(reporter, output); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'completed', + updates: [{ type: 'output', data: output }], + }), + ); + }); + + it('should call reporter.error through reportError', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + const error: ToolError = { message: 'Test error', code: 'TEST_ERROR' }; + + reportError(reporter, error); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + status: 'error', + updates: [ + { + type: 'error', + data: { message: 'Test error', code: 'TEST_ERROR', details: undefined }, + }, + ], + }), + ); + }); + + it('should create batch reporter through createBatchProgressReporter', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + const batchReporter = createBatchProgressReporter(reporter, 'Batch operation'); + + batchReporter.init(1); + batchReporter.next('Test item'); + + expect(mockWriter).toHaveBeenCalledWith( + expect.objectContaining({ + updates: [ + { + type: 'progress', + data: { message: 'Batch operation: Processing item 1 of 1: Test item' }, + }, + ], + }), + ); + }); + + it('should update customDisplayTitle when provided in start options', () => { + const reporter = createProgressReporter(mockConfig, 'test_tool', 'Test Tool'); + const input = { test: 'data' }; + + reporter.start(input, { customDisplayTitle: 'Custom Title from Options' }); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'test_tool', + toolCallId: 'test-tool-call-id', + displayTitle: 'Test Tool', + customDisplayTitle: 'Custom Title from Options', + status: 'running', + updates: [ + { + type: 'input', + data: input, + }, + ], + }); + }); + + it('should preserve initial custom title when start is called without options', () => { + const reporter = createProgressReporter( + mockConfig, + 'test_tool', + 'Test Tool', + 'Initial Custom Title', + ); + const input = { test: 'data' }; + + reporter.start(input); + + expect(mockWriter).toHaveBeenCalledWith({ + type: 'tool', + toolName: 'test_tool', + toolCallId: 'test-tool-call-id', + displayTitle: 'Test Tool', + customDisplayTitle: 'Initial Custom Title', + status: 'running', + updates: [ + { + type: 'input', + data: input, + }, + ], + }); + }); + }); +}); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/node-details.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/node-details.tool.ts index d8a52940be..82afdedb94 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/node-details.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/node-details.tool.ts @@ -126,9 +126,11 @@ function extractNodeDetails(nodeType: INodeTypeDescription): NodeDetails { * Factory function to create the node details tool */ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) { - return tool( + const DISPLAY_TITLE = 'Getting node details'; + + const dynamicTool = tool( (input: unknown, config) => { - const reporter = createProgressReporter(config, 'get_node_details'); + const reporter = createProgressReporter(config, 'get_node_details', DISPLAY_TITLE); try { // Validate input using Zod schema @@ -194,4 +196,9 @@ export function createNodeDetailsTool(nodeTypes: INodeTypeDescription[]) { schema: nodeDetailsSchema, }, ); + + return { + tool: dynamicTool, + displayTitle: DISPLAY_TITLE, + }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/node-search.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/node-search.tool.ts index c4a94f68be..b3bcf2e631 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/node-search.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/node-search.tool.ts @@ -112,9 +112,11 @@ function buildResponseMessage( * Factory function to create the node search tool */ export function createNodeSearchTool(nodeTypes: INodeTypeDescription[]) { - return tool( - (input: unknown, config) => { - const reporter = createProgressReporter(config, 'search_nodes'); + const DISPLAY_TITLE = 'Searching nodes'; + + const dynamicTool = tool( + (input, config) => { + const reporter = createProgressReporter(config, 'search_nodes', DISPLAY_TITLE); try { // Validate input using Zod schema @@ -210,4 +212,9 @@ You can search for multiple different criteria at once by providing an array of schema: nodeSearchSchema, }, ); + + return { + tool: dynamicTool, + displayTitle: DISPLAY_TITLE, + }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-node.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-node.tool.ts index 16adbba40e..e498f35c7c 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-node.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/remove-node.tool.ts @@ -73,9 +73,11 @@ function buildResponseMessage( * Factory function to create the remove node tool */ export function createRemoveNodeTool(_logger?: Logger) { - return tool( + const DISPLAY_TITLE = 'Removing node'; + + const dynamicTool = tool( (input, config) => { - const reporter = createProgressReporter(config, 'remove_node'); + const reporter = createProgressReporter(config, 'remove_node', DISPLAY_TITLE); try { // Validate input using Zod schema @@ -152,4 +154,9 @@ export function createRemoveNodeTool(_logger?: Logger) { schema: removeNodeSchema, }, ); + + return { + tool: dynamicTool, + displayTitle: DISPLAY_TITLE, + }; } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/add-node.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/add-node.tool.test.ts index 519a1e8418..ace112b38e 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/add-node.tool.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/add-node.tool.test.ts @@ -38,7 +38,7 @@ jest.mock('crypto', () => ({ describe('AddNodeTool', () => { let nodeTypesList: INodeTypeDescription[]; - let addNodeTool: ReturnType; + let addNodeTool: ReturnType['tool']; const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction< typeof getCurrentTaskInput >; @@ -47,7 +47,7 @@ describe('AddNodeTool', () => { jest.clearAllMocks(); nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.agent]; - addNodeTool = createAddNodeTool(nodeTypesList); + addNodeTool = createAddNodeTool(nodeTypesList).tool; }); afterEach(() => { @@ -298,4 +298,23 @@ describe('AddNodeTool', () => { expect(addedNode?.position?.[1]).toBeGreaterThan(100); }); }); + + describe('getCustomDisplayTitle', () => { + it('should return node display name when nodeType exists', () => { + const tool = createAddNodeTool(nodeTypesList); + const result = tool.getCustomDisplayTitle?.({ + nodeType: 'n8n-nodes-base.code', + name: 'My Code', + }); + + expect(result).toBe('Adding Code node'); + }); + + it('should return default title when nodeType not found or missing', () => { + const tool = createAddNodeTool(nodeTypesList); + + expect(tool.getCustomDisplayTitle?.({ nodeType: 'unknown.node' })).toBe('Adding node'); + expect(tool.getCustomDisplayTitle?.({ name: 'Some Node' })).toBe('Adding node'); + }); + }); }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/connect-nodes.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/connect-nodes.tool.test.ts index 7b7b4c2033..2d246c6e11 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/connect-nodes.tool.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/connect-nodes.tool.test.ts @@ -30,7 +30,7 @@ jest.mock('@langchain/langgraph', () => ({ describe('ConnectNodesTool', () => { let nodeTypesList: INodeTypeDescription[]; - let connectNodesTool: ReturnType; + let connectNodesTool: ReturnType['tool']; const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction< typeof getCurrentTaskInput >; @@ -39,7 +39,7 @@ describe('ConnectNodesTool', () => { jest.clearAllMocks(); nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.agent]; - connectNodesTool = createConnectNodesTool(nodeTypesList); + connectNodesTool = createConnectNodesTool(nodeTypesList).tool; }); afterEach(() => { @@ -112,7 +112,7 @@ describe('ConnectNodesTool', () => { // Update node types list nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, agentNodeType, toolNodeType]; - connectNodesTool = createConnectNodesTool(nodeTypesList); + connectNodesTool = createConnectNodesTool(nodeTypesList).tool; const existingWorkflow = createWorkflow([ createNode({ id: 'agent1', name: 'AI Agent', type: '@n8n/n8n-nodes-langchain.agent' }), @@ -176,7 +176,7 @@ describe('ConnectNodesTool', () => { // Replace the agent node type in the list nodeTypesList = nodeTypesList.filter((nt) => nt.name !== '@n8n/n8n-nodes-langchain.agent'); nodeTypesList.push(agentNodeType, languageModelNodeType); - connectNodesTool = createConnectNodesTool(nodeTypesList); + connectNodesTool = createConnectNodesTool(nodeTypesList).tool; const existingWorkflow = createWorkflow([ createNode({ @@ -374,7 +374,7 @@ describe('ConnectNodesTool', () => { }); nodeTypesList.push(multiOutputNode); - connectNodesTool = createConnectNodesTool(nodeTypesList); + connectNodesTool = createConnectNodesTool(nodeTypesList).tool; const existingWorkflow = createWorkflow([ createNode({ id: 'multi1', name: 'Multi Output', type: 'test.multiOutput' }), diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-details.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-details.tool.test.ts index ed0900d46f..e3d410832b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-details.tool.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-details.tool.test.ts @@ -27,7 +27,7 @@ jest.mock('@langchain/langgraph', () => ({ describe('NodeDetailsTool', () => { let nodeTypesList: INodeTypeDescription[]; - let nodeDetailsTool: ReturnType; + let nodeDetailsTool: ReturnType['tool']; beforeEach(() => { jest.clearAllMocks(); @@ -43,7 +43,7 @@ describe('NodeDetailsTool', () => { nodeTypes.mergeNode, nodeTypes.vectorStoreNode, ]; - nodeDetailsTool = createNodeDetailsTool(nodeTypesList); + nodeDetailsTool = createNodeDetailsTool(nodeTypesList).tool; }); afterEach(() => { @@ -296,7 +296,7 @@ describe('NodeDetailsTool', () => { }); const testNodeTypes = [...nodeTypesList, nodeWithManyProps]; - const testTool = createNodeDetailsTool(testNodeTypes); + const testTool = createNodeDetailsTool(testNodeTypes).tool; const mockConfig = createToolConfig('get_node_details', 'test-call-11'); @@ -373,7 +373,7 @@ describe('NodeDetailsTool', () => { }); const testNodeTypes = [...nodeTypesList, complexNode]; - const testTool = createNodeDetailsTool(testNodeTypes); + const testTool = createNodeDetailsTool(testNodeTypes).tool; const mockConfig = createToolConfig('get_node_details', 'test-call-13'); @@ -436,7 +436,7 @@ describe('NodeDetailsTool', () => { }); const testNodeTypes = [...nodeTypesList, noOutputNode]; - const testTool = createNodeDetailsTool(testNodeTypes); + const testTool = createNodeDetailsTool(testNodeTypes).tool; const mockConfig = createToolConfig('get_node_details', 'test-call-15'); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-search.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-search.tool.test.ts index 9a5cb18021..fd9d755a31 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-search.tool.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/node-search.tool.test.ts @@ -24,7 +24,7 @@ jest.mock('@langchain/langgraph', () => ({ describe('NodeSearchTool', () => { let nodeTypesList: INodeTypeDescription[]; - let nodeSearchTool: ReturnType; + let nodeSearchTool: ReturnType['tool']; beforeEach(() => { jest.clearAllMocks(); @@ -75,7 +75,7 @@ describe('NodeSearchTool', () => { // Expression-based node nodeTypes.vectorStoreNode, ]; - nodeSearchTool = createNodeSearchTool(nodeTypesList); + nodeSearchTool = createNodeSearchTool(nodeTypesList).tool; }); afterEach(() => { @@ -222,7 +222,7 @@ describe('NodeSearchTool', () => { { queries: [ { - // @ts-expect-error Testing invalid input + // @ts-expect-error testing invalid query type queryType: 'invalid', query: 'test', }, @@ -342,7 +342,7 @@ describe('NodeSearchTool', () => { }), ); const testNodeTypes = [...nodeTypesList, ...manyHttpNodes]; - const testTool = createNodeSearchTool(testNodeTypes); + const testTool = createNodeSearchTool(testNodeTypes).tool; const mockConfig = createToolConfig('search_nodes', 'test-call-13'); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-node.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-node.tool.test.ts index 8ebb75c8d6..9eb3ad1ce2 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-node.tool.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/remove-node.tool.test.ts @@ -26,14 +26,14 @@ jest.mock('@langchain/langgraph', () => ({ })); describe('RemoveNodeTool', () => { - let removeNodeTool: ReturnType; + let removeNodeTool: ReturnType['tool']; const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction< typeof getCurrentTaskInput >; beforeEach(() => { jest.clearAllMocks(); - removeNodeTool = createRemoveNodeTool(); + removeNodeTool = createRemoveNodeTool().tool; }); afterEach(() => { diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/update-node-parameters.tool.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/update-node-parameters.tool.test.ts index 401723a64b..0827db1a74 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/test/update-node-parameters.tool.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/test/update-node-parameters.tool.test.ts @@ -36,7 +36,7 @@ jest.mock('../../../src/chains/parameter-updater', () => ({ describe('UpdateNodeParametersTool', () => { let nodeTypesList: INodeTypeDescription[]; - let updateNodeParametersTool: ReturnType; + let updateNodeParametersTool: ReturnType['tool']; const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction< typeof getCurrentTaskInput >; @@ -59,7 +59,7 @@ describe('UpdateNodeParametersTool', () => { parameterUpdaterModule.createParameterUpdaterChain.mockReturnValue(mockChain); nodeTypesList = [nodeTypes.code, nodeTypes.httpRequest, nodeTypes.webhook, nodeTypes.setNode]; - updateNodeParametersTool = createUpdateNodeParametersTool(nodeTypesList, mockLLM); + updateNodeParametersTool = createUpdateNodeParametersTool(nodeTypesList, mockLLM).tool; }); afterEach(() => { @@ -468,7 +468,7 @@ describe('UpdateNodeParametersTool', () => { }; // Create tool with custom node type - const customTool = createUpdateNodeParametersTool([customNodeType], mockLLM); + const customTool = createUpdateNodeParametersTool([customNodeType], mockLLM).tool; const existingWorkflow = createWorkflow([ createNode({ diff --git a/packages/@n8n/ai-workflow-builder.ee/src/tools/update-node-parameters.tool.ts b/packages/@n8n/ai-workflow-builder.ee/src/tools/update-node-parameters.tool.ts index f64b4ad2bf..a82f2e4735 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/tools/update-node-parameters.tool.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/tools/update-node-parameters.tool.ts @@ -22,6 +22,8 @@ import { } from './utils/parameter-update.utils'; import type { UpdateNodeParametersOutput } from '../types/tools'; +const DISPLAY_TITLE = 'Updating node parameters'; + /** * Schema for update node parameters input */ @@ -111,6 +113,17 @@ async function processParameterUpdates( return fixExpressionPrefixes(newParameters.parameters) as INodeParameters; } +function getCustomNodeTitle(input: Record, nodes?: INode[]): string { + if ('nodeId' in input && typeof input['nodeId'] === 'string') { + const targetNode = nodes?.find((node) => node.id === input.nodeId); + if (targetNode) { + return `Updating "${targetNode.name}" node parameters`; + } + } + + return DISPLAY_TITLE; +} + /** * Factory function to create the update node parameters tool */ @@ -120,22 +133,24 @@ export function createUpdateNodeParametersTool( logger?: Logger, instanceUrl?: string, ) { - return tool( + const dynamicTool = tool( async (input, config) => { - const reporter = createProgressReporter(config, 'update_node_parameters'); + const reporter = createProgressReporter(config, 'update_node_parameters', DISPLAY_TITLE); try { // Validate input using Zod schema const validatedInput = updateNodeParametersSchema.parse(input); const { nodeId, changes } = validatedInput; - // Report tool start - reporter.start(validatedInput); - // Get current state const state = getWorkflowState(); const workflow = getCurrentWorkflow(state); + // Report tool start + reporter.start(validatedInput, { + customDisplayTitle: getCustomNodeTitle(input, workflow.nodes), + }); + // Find the node const node = validateNodeExists(nodeId, workflow.nodes); if (!node) { @@ -233,4 +248,9 @@ export function createUpdateNodeParametersTool( schema: updateNodeParametersSchema, }, ); + + return { + tool: dynamicTool, + displayTitle: DISPLAY_TITLE, + }; } 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 c0d3be34db..9675ba140b 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/types/tools.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/types/tools.ts @@ -26,6 +26,8 @@ export interface ToolProgressMessage { toolCallId?: string; status: 'running' | 'completed' | 'error'; updates: ProgressUpdate[]; + displayTitle?: string; // Name of tool action in UI, for example "Adding nodes" + customDisplayTitle?: string; // Custom name for tool action in UI, for example "Adding Gmail node" } /** @@ -41,7 +43,7 @@ export interface ToolError { * Progress reporter interface for tools */ export interface ProgressReporter { - start: (input: T) => void; + start: (input: T, options?: { customDisplayTitle: string }) => void; progress: (message: string, data?: Record) => void; complete: (output: T) => void; error: (error: ToolError) => void; diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts index b76381c1b7..c7e9b694ff 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/stream-processor.ts @@ -1,4 +1,6 @@ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages'; +import type { ToolCall } from '@langchain/core/messages/tool'; +import type { DynamicStructuredTool } from '@langchain/core/tools'; import type { AgentMessageChunk, @@ -7,6 +9,12 @@ import type { StreamOutput, } from '../types/streaming'; +export interface BuilderTool { + tool: DynamicStructuredTool; + displayTitle: string; + getCustomDisplayTitle?: (values: Record) => string; +} + /** * Tools which should trigger canvas updates */ @@ -129,78 +137,135 @@ export async function* createStreamProcessor( } } +/** + * Format a HumanMessage into the expected output format + */ +function formatHumanMessage(msg: HumanMessage): Record { + return { + role: 'user', + type: 'message', + text: msg.content, + }; +} + +/** + * Process array content from AIMessage and return formatted text messages + */ +function processArrayContent(content: unknown[]): Array> { + const textMessages = content.filter( + (c): c is { type: string; text: string } => + typeof c === 'object' && c !== null && 'type' in c && c.type === 'text' && 'text' in c, + ); + + return textMessages.map((textMessage) => ({ + role: 'assistant', + type: 'message', + text: textMessage.text, + })); +} + +/** + * Process AIMessage content and return formatted messages + */ +function processAIMessageContent(msg: AIMessage): Array> { + if (!msg.content) { + return []; + } + + if (Array.isArray(msg.content)) { + return processArrayContent(msg.content); + } + + return [ + { + role: 'assistant', + type: 'message', + text: msg.content, + }, + ]; +} + +/** + * Create a formatted tool call message + */ +function createToolCallMessage( + toolCall: ToolCall, + builderTool?: BuilderTool, +): Record { + return { + id: toolCall.id, + toolCallId: toolCall.id, + role: 'assistant', + type: 'tool', + toolName: toolCall.name, + displayTitle: builderTool?.displayTitle, + customDisplayTitle: toolCall.args && builderTool?.getCustomDisplayTitle?.(toolCall.args), + status: 'completed', + updates: [ + { + type: 'input', + data: toolCall.args || {}, + }, + ], + }; +} + +/** + * Process tool calls from AIMessage and return formatted tool messages + */ +function processToolCalls( + toolCalls: ToolCall[], + builderTools?: BuilderTool[], +): Array> { + return toolCalls.map((toolCall) => { + const builderTool = builderTools?.find((bt) => bt.tool.name === toolCall.name); + return createToolCallMessage(toolCall, builderTool); + }); +} + +/** + * Process a ToolMessage and add its output to the corresponding tool call + */ +function processToolMessage( + msg: ToolMessage, + formattedMessages: Array>, +): void { + const toolCallId = msg.tool_call_id; + + // Find the tool message by ID (search backwards for efficiency) + for (let i = formattedMessages.length - 1; i >= 0; i--) { + const m = formattedMessages[i]; + if (m.type === 'tool' && m.id === toolCallId) { + // Add output to updates array + m.updates ??= []; + (m.updates as Array>).push({ + type: 'output', + data: typeof msg.content === 'string' ? { result: msg.content } : msg.content, + }); + break; + } + } +} + export function formatMessages( messages: Array, + builderTools?: BuilderTool[], ): Array> { const formattedMessages: Array> = []; for (const msg of messages) { if (msg instanceof HumanMessage) { - formattedMessages.push({ - role: 'user', - type: 'message', - text: msg.content, - }); + formattedMessages.push(formatHumanMessage(msg)); } else if (msg instanceof AIMessage) { - // Add the AI message content if it exists - if (msg.content) { - if (Array.isArray(msg.content)) { - // Handle array content (multi-part messages) - const textMessages = msg.content.filter((c) => c.type === 'text'); + // Add AI message content + formattedMessages.push(...processAIMessageContent(msg)); - textMessages.forEach((textMessage) => { - if (textMessage.type !== 'text') { - return; - } - formattedMessages.push({ - role: 'assistant', - type: 'message', - text: textMessage.text, - }); - }); - } else { - formattedMessages.push({ - role: 'assistant', - type: 'message', - text: msg.content, - }); - } - } - // Handle tool calls in AI messages - if (msg.tool_calls && msg.tool_calls.length > 0) { - // Add tool messages for each tool call - for (const toolCall of msg.tool_calls) { - formattedMessages.push({ - id: toolCall.id, - toolCallId: toolCall.id, - role: 'assistant', - type: 'tool', - toolName: toolCall.name, - status: 'completed', - updates: [ - { - type: 'input', - data: toolCall.args || {}, - }, - ], - }); - } + // Add tool calls if present + if (msg.tool_calls?.length) { + formattedMessages.push(...processToolCalls(msg.tool_calls, builderTools)); } } else if (msg instanceof ToolMessage) { - // Find the tool message by ID and add the output - const toolCallId = msg.tool_call_id; - for (let i = formattedMessages.length - 1; i >= 0; i--) { - const m = formattedMessages[i]; - if (m.type === 'tool' && m.id === toolCallId) { - // Add output to updates array - m.updates ??= []; - (m.updates as Array>).push({ - type: 'output', - data: typeof msg.content === 'string' ? { result: msg.content } : msg.content, - }); - break; - } - } + processToolMessage(msg, formattedMessages); } } diff --git a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts index 012d0e4083..ceedc7b896 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/utils/test/stream-processor.test.ts @@ -1,4 +1,5 @@ import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages'; +import type { DynamicStructuredTool } from '@langchain/core/tools'; import type { AgentMessageChunk, @@ -471,5 +472,430 @@ describe('stream-processor', () => { data: {}, }); }); + + it('should handle AIMessage with array content (multi-part messages)', () => { + const message = new AIMessage(''); + // Manually set the content to array format since LangChain constructor might not accept arrays directly + message.content = [ + { type: 'text', text: 'First part' }, + { type: 'text', text: 'Second part' }, + { type: 'image', url: 'http://example.com/image.png' }, + ]; + + const messages = [message]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + role: 'assistant', + type: 'message', + text: 'First part', + }); + expect(result[1]).toEqual({ + role: 'assistant', + type: 'message', + text: 'Second part', + }); + }); + + it('should handle AIMessage with array content containing no text', () => { + const message = new AIMessage(''); + message.content = [ + { type: 'image', url: 'http://example.com/image.png' }, + { type: 'video', url: 'http://example.com/video.mp4' }, + ]; + + const messages = [message]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(0); + }); + + it('should handle AIMessage with empty array content', () => { + const message = new AIMessage(''); + message.content = []; + + const messages = [message]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(0); + }); + + it('should handle AIMessage with empty string content', () => { + const messages = [new AIMessage('')]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(0); + }); + + it('should handle AIMessage with null content', () => { + const message = new AIMessage(''); + // Test the function's robustness by simulating a corrupted message + Object.defineProperty(message, 'content', { value: null, writable: true }); + + const messages = [message]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(0); + }); + + it('should use builder tool display titles', () => { + const builderTools = [ + { + tool: { name: 'add_nodes' } as DynamicStructuredTool, + displayTitle: 'Add Node', + }, + { + tool: { name: 'connect_nodes' } as DynamicStructuredTool, + displayTitle: 'Connect Nodes', + }, + ]; + + const aiMessage = new AIMessage(''); + aiMessage.tool_calls = [ + { + id: 'call-1', + name: 'add_nodes', + args: { nodeType: 'n8n-nodes-base.code' }, + type: 'tool_call', + }, + ]; + + const messages = [aiMessage]; + + const result = formatMessages(messages, builderTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'call-1', + toolCallId: 'call-1', + role: 'assistant', + type: 'tool', + toolName: 'add_nodes', + displayTitle: 'Add Node', + status: 'completed', + updates: [ + { + type: 'input', + data: { nodeType: 'n8n-nodes-base.code' }, + }, + ], + }); + }); + + it('should use custom display titles from builder tools', () => { + const builderTools = [ + { + tool: { name: 'add_nodes' } as DynamicStructuredTool, + displayTitle: 'Add Node', + getCustomDisplayTitle: (values: Record) => + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Add ${values.nodeType} Node`, + }, + ]; + + const aiMessage = new AIMessage(''); + aiMessage.tool_calls = [ + { + id: 'call-1', + name: 'add_nodes', + args: { nodeType: 'Code' }, + type: 'tool_call', + }, + ]; + + const messages = [aiMessage]; + + const result = formatMessages(messages, builderTools); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + id: 'call-1', + toolCallId: 'call-1', + role: 'assistant', + type: 'tool', + toolName: 'add_nodes', + displayTitle: 'Add Node', + customDisplayTitle: 'Add Code Node', + status: 'completed', + updates: [ + { + type: 'input', + data: { nodeType: 'Code' }, + }, + ], + }); + }); + + it('should handle custom display title when args is null/undefined', () => { + const builderTools = [ + { + tool: { name: 'clear_workflow' } as DynamicStructuredTool, + displayTitle: 'Clear Workflow', + getCustomDisplayTitle: (values: Record) => + `Custom: ${Object.keys(values).length} args`, + }, + ]; + + const aiMessage = new AIMessage(''); + const toolCall = { + id: 'call-1', + name: 'clear_workflow', + args: {} as Record, + type: 'tool_call' as const, + }; + // Simulate a corrupted tool call with null args + Object.defineProperty(toolCall, 'args', { value: null, writable: true }); + aiMessage.tool_calls = [toolCall]; + + const messages = [aiMessage]; + + const result = formatMessages(messages, builderTools); + + expect(result[0].customDisplayTitle).toBeNull(); + }); + + it('should handle tool call with undefined args', () => { + const aiMessage = new AIMessage(''); + const toolCall = { + id: 'call-1', + name: 'clear_workflow', + args: {} as Record, + type: 'tool_call' as const, + }; + // Simulate a corrupted tool call with undefined args + Object.defineProperty(toolCall, 'args', { value: undefined, writable: true }); + aiMessage.tool_calls = [toolCall]; + + const messages = [aiMessage]; + + const result = formatMessages(messages); + + // @ts-expect-error Lnagchain types are not propagated + expect(result[0].updates?.[0]).toEqual({ + type: 'input', + data: {}, + }); + }); + + it('should handle ToolMessage with no matching tool call', () => { + const toolMessage = new ToolMessage({ + content: 'Orphaned tool result', + tool_call_id: 'non-existent-call', + }); + + const messages = [toolMessage]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(0); + }); + + it('should handle multiple ToolMessages for the same tool call', () => { + const aiMessage = new AIMessage(''); + aiMessage.tool_calls = [ + { + id: 'call-1', + name: 'add_nodes', + args: { nodeType: 'n8n-nodes-base.code' }, + type: 'tool_call', + }, + ]; + + const toolMessage1 = new ToolMessage({ + content: 'First result', + tool_call_id: 'call-1', + }); + + const toolMessage2 = new ToolMessage({ + content: 'Second result', + tool_call_id: 'call-1', + }); + + const messages = [aiMessage, toolMessage1, toolMessage2]; + + const result = formatMessages(messages); + + expect(result).toHaveLength(1); + expect(result[0].updates).toHaveLength(3); + // @ts-expect-error Lnagchain types are not propagated + expect(result[0].updates?.[1]).toEqual({ + type: 'output', + data: { result: 'First result' }, + }); + // @ts-expect-error Lnagchain types are not propagated + expect(result[0].updates?.[2]).toEqual({ + type: 'output', + data: { result: 'Second result' }, + }); + }); + + it('should handle ToolMessage appearing before corresponding AIMessage tool call', () => { + const toolMessage = new ToolMessage({ + content: 'Tool result', + tool_call_id: 'call-1', + }); + + const aiMessage = new AIMessage(''); + aiMessage.tool_calls = [ + { + id: 'call-1', + name: 'add_nodes', + args: { nodeType: 'n8n-nodes-base.code' }, + type: 'tool_call', + }, + ]; + + const messages = [toolMessage, aiMessage]; + + const result = formatMessages(messages); + + // When ToolMessage comes before AIMessage, the ToolMessage cannot find the tool call to attach to + // so it gets ignored, and only the tool call from AIMessage is processed + expect(result).toHaveLength(1); + expect(result[0].updates).toHaveLength(1); // Only the input, no output since ToolMessage came before + // @ts-expect-error Lnagchain types are not propagated + expect(result[0].updates?.[0]).toEqual({ + type: 'input', + data: { nodeType: 'n8n-nodes-base.code' }, + }); + }); + + it('should handle empty messages array', () => { + const result = formatMessages([]); + + expect(result).toHaveLength(0); + }); + + it('should handle messages with unknown message type', () => { + // Create an object that doesn't match any of the expected message types + const unknownMessage = { + content: 'Unknown message type', + type: 'unknown', + }; + + const result = formatMessages([ + unknownMessage as unknown as AIMessage | HumanMessage | ToolMessage, + ]); + + expect(result).toHaveLength(0); + }); + + it('should preserve initialization of updates array when undefined', () => { + const aiMessage = new AIMessage(''); + aiMessage.tool_calls = [ + { + id: 'call-1', + name: 'add_nodes', + args: { nodeType: 'n8n-nodes-base.code' }, + type: 'tool_call', + }, + ]; + + const toolMessage = new ToolMessage({ + content: 'Tool result', + tool_call_id: 'call-1', + }); + + const messages = [aiMessage, toolMessage]; + + const result = formatMessages(messages); + + expect(result[0].updates).toBeDefined(); + expect(Array.isArray(result[0].updates)).toBe(true); + expect(result[0].updates).toHaveLength(2); + }); + + it('should handle complex scenario with multiple message types and builder tools', () => { + const builderTools = [ + { + tool: { name: 'add_nodes' } as DynamicStructuredTool, + displayTitle: 'Add Node', + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + getCustomDisplayTitle: (values: Record) => `Add ${values.nodeType} Node`, + }, + { + tool: { name: 'connect_nodes' } as DynamicStructuredTool, + displayTitle: 'Connect Nodes', + }, + ]; + + const humanMessage = new HumanMessage('Please create a workflow'); + + const aiMessage1 = new AIMessage(''); + aiMessage1.content = [ + { type: 'text', text: 'I will help you create a workflow.' }, + { type: 'text', text: 'Let me add some nodes.' }, + ]; + + const aiMessage2 = new AIMessage(''); + aiMessage2.tool_calls = [ + { + id: 'call-1', + name: 'add_nodes', + args: { nodeType: 'Code' }, + type: 'tool_call', + }, + { + id: 'call-2', + name: 'connect_nodes', + args: { source: 'node1', target: 'node2' }, + type: 'tool_call', + }, + ]; + const toolMessage1 = new ToolMessage({ + content: 'Node added successfully', + tool_call_id: 'call-1', + }); + const toolMessage2 = new ToolMessage({ + // @ts-expect-error Lnagchain types are not propagated + content: { success: true, connectionId: 'conn-1' }, + tool_call_id: 'call-2', + }); + + const messages = [humanMessage, aiMessage1, aiMessage2, toolMessage1, toolMessage2]; + + const result = formatMessages(messages, builderTools); + + expect(result).toHaveLength(5); // 1 user + 2 text messages + 2 tool calls + + expect(result[0]).toEqual({ + role: 'user', + type: 'message', + text: 'Please create a workflow', + }); + + expect(result[1]).toEqual({ + role: 'assistant', + type: 'message', + text: 'I will help you create a workflow.', + }); + + expect(result[2]).toEqual({ + role: 'assistant', + type: 'message', + text: 'Let me add some nodes.', + }); + + expect(result[3].toolName).toBe('add_nodes'); + expect(result[3].displayTitle).toBe('Add Node'); + expect(result[3].customDisplayTitle).toBe('Add Code Node'); + expect(result[3].updates).toHaveLength(2); + + expect(result[4].toolName).toBe('connect_nodes'); + expect(result[4].displayTitle).toBe('Connect Nodes'); + expect(result[4].customDisplayTitle).toBeUndefined(); + expect(result[4].updates).toHaveLength(2); + // @ts-expect-error Lnagchain types are not propagated + expect(result[4].updates?.[1]).toEqual({ + type: 'output', + data: { success: true, connectionId: 'conn-1' }, + }); + }); }); }); diff --git a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts index c3511aec88..6c8a93cab4 100644 --- a/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts +++ b/packages/@n8n/ai-workflow-builder.ee/src/workflow-builder-agent.ts @@ -13,10 +13,9 @@ import { type NodeExecutionSchema, } from 'n8n-workflow'; -import { workflowNameChain } from '@/chains/workflow-name'; -import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from '@/constants'; - import { conversationCompactChain } from './chains/conversation-compact'; +import { workflowNameChain } from './chains/workflow-name'; +import { DEFAULT_AUTO_COMPACT_THRESHOLD_TOKENS, MAX_AI_BUILDER_PROMPT_LENGTH } from './constants'; import { LLMServiceError, ValidationError } from './errors'; import { createAddNodeTool } from './tools/add-node.tool'; import { createConnectNodesTool } from './tools/connect-nodes.tool'; @@ -27,7 +26,7 @@ import { createRemoveNodeTool } from './tools/remove-node.tool'; import { createUpdateNodeParametersTool } from './tools/update-node-parameters.tool'; import type { SimpleWorkflow } from './types/workflow'; import { processOperations } from './utils/operations-processor'; -import { createStreamProcessor, formatMessages } from './utils/stream-processor'; +import { createStreamProcessor, formatMessages, type BuilderTool } from './utils/stream-processor'; import { extractLastTokenUsage } from './utils/token-usage'; import { executeToolsInParallel } from './utils/tool-executor'; import { WorkflowState } from './workflow-state'; @@ -74,8 +73,8 @@ export class WorkflowBuilderAgent { this.instanceUrl = config.instanceUrl; } - private createWorkflow() { - const tools = [ + private getBuilderTools(): BuilderTool[] { + return [ createNodeSearchTool(this.parsedNodeTypes), createNodeDetailsTool(this.parsedNodeTypes), createAddNodeTool(this.parsedNodeTypes), @@ -88,6 +87,13 @@ export class WorkflowBuilderAgent { this.instanceUrl, ), ]; + } + + private createWorkflow() { + const builderTools = this.getBuilderTools(); + + // Extract just the tools for LLM binding + const tools = builderTools.map((bt) => bt.tool); // Create a map for quick tool lookup const toolMap = new Map(tools.map((tool) => [tool.name, tool])); @@ -485,7 +491,7 @@ export class WorkflowBuilderAgent { sessions.push({ sessionId: threadId, - messages: formatMessages(messages), + messages: formatMessages(messages, this.getBuilderTools()), lastUpdated: checkpoint.checkpoint.ts, }); } diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts index fd8fd8c622..551eb8a5f9 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.stories.ts @@ -635,6 +635,110 @@ ToolMessageError.args = { ]), }; +const SEARCH_FILES_TOOL_CALL_COMPLETED: ChatUI.AssistantMessage = { + id: '128', + type: 'tool', + role: 'assistant', + toolName: 'search_files', + toolCallId: 'call_456', + status: 'completed', + displayTitle: 'Searching files', + customDisplayTitle: 'Searching for Reddit node', + updates: [ + { + type: 'input', + data: { + pattern: '*.vue', + directory: '/src', + }, + timestamp: new Date().toISOString(), + }, + { + type: 'progress', + data: { message: 'Searching for Vue files...' }, + timestamp: new Date().toISOString(), + }, + { + type: 'output', + data: { + files: ['/src/components/Button.vue', '/src/components/Modal.vue', '/src/views/Home.vue'], + count: 3, + }, + timestamp: new Date().toISOString(), + }, + ], + read: false, +}; + +const SEARCH_FILES_TOOL_CALL_COMPLETED_2: ChatUI.AssistantMessage = { + ...SEARCH_FILES_TOOL_CALL_COMPLETED, + displayTitle: 'Searching nodes', + customDisplayTitle: 'Searching for Spotify node', +}; + +const SEARCH_FILES_TOOL_CALL_RUNNING: ChatUI.AssistantMessage = { + ...SEARCH_FILES_TOOL_CALL_COMPLETED, + status: 'running', + customDisplayTitle: 'Searching for Open AI nodes', +}; + +const SEARCH_FILES_TOOL_CALL_RUNNING_2: ChatUI.AssistantMessage = { + ...SEARCH_FILES_TOOL_CALL_COMPLETED, + status: 'running', + customDisplayTitle: 'Searching for Slack node', +}; + +const SEARCH_FILES_TOOL_CALL_ERROR: ChatUI.AssistantMessage = { + ...SEARCH_FILES_TOOL_CALL_COMPLETED, + status: 'error', + customDisplayTitle: 'Searching for Power node', +}; + +const SEARCH_FILES_TOOL_CALL_ERROR_2: ChatUI.AssistantMessage = { + ...SEARCH_FILES_TOOL_CALL_COMPLETED, + status: 'error', + customDisplayTitle: 'Searching for n8n node', +}; + +function getMessage(content: string): ChatUI.AssistantMessage { + return { + id: '130', + type: 'text', + role: 'user', + content, + read: true, + }; +} + +export const ToolMessageMultiple = Template.bind({}); +ToolMessageMultiple.args = { + user: { + firstName: 'Max', + lastName: 'Test', + }, + messages: getMessages([ + getMessage('Collapse multiple consecutive completed tool calls into one'), + SEARCH_FILES_TOOL_CALL_COMPLETED, + SEARCH_FILES_TOOL_CALL_COMPLETED_2, + getMessage('Collapse multiple consecutive completed and running tool calls into one'), + SEARCH_FILES_TOOL_CALL_COMPLETED, + SEARCH_FILES_TOOL_CALL_RUNNING, + SEARCH_FILES_TOOL_CALL_RUNNING_2, + getMessage('Collapse multiple consecutive error and running tool calls into running'), + SEARCH_FILES_TOOL_CALL_ERROR, + SEARCH_FILES_TOOL_CALL_RUNNING, + getMessage('Collapse multiple consecutive error and completed tool calls into completed'), + SEARCH_FILES_TOOL_CALL_ERROR, + SEARCH_FILES_TOOL_CALL_COMPLETED, + getMessage('Collapse multiple consecutive running tool calls into one running'), + SEARCH_FILES_TOOL_CALL_RUNNING, + SEARCH_FILES_TOOL_CALL_RUNNING_2, + getMessage('Collapse multiple consecutive error tool calls into one error'), + SEARCH_FILES_TOOL_CALL_ERROR, + SEARCH_FILES_TOOL_CALL_ERROR_2, + ]), +}; + export const MixedMessagesWithTools = Template.bind({}); MixedMessagesWithTools.args = { user: { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts index b6d034b3df..05b52fcf72 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.test.ts @@ -1,8 +1,11 @@ import { render } from '@testing-library/vue'; +import { vi } from 'vitest'; import { n8nHtml } from '@n8n/design-system/directives'; import AskAssistantChat from './AskAssistantChat.vue'; +import type { Props as MessageWrapperProps } from './messages/MessageWrapper.vue'; +import type { ChatUI } from '../../types/assistant'; const stubs = ['n8n-avatar', 'n8n-button', 'n8n-icon', 'n8n-icon-button']; @@ -255,4 +258,484 @@ describe('AskAssistantChat', () => { const textarea = wrapper.queryByTestId('chat-input'); expect(textarea).toHaveAttribute('maxLength', '100'); }); + + describe('collapseToolMessages', () => { + const MessageWrapperMock = vi.fn(() => ({ + template: '
', + })); + const stubsWithMessageWrapper = { + ...Object.fromEntries(stubs.map((stub) => [stub, true])), + MessageWrapper: MessageWrapperMock, + }; + + const createToolMessage = ( + overrides: Partial = {}, + ): ChatUI.ToolMessage & { id: string } => ({ + id: '1', + role: 'assistant', + type: 'tool', + toolName: 'search', + status: 'completed', + displayTitle: 'Search Results', + updates: [{ type: 'output', data: { result: 'Found items' } }], + ...overrides, + }); + + const renderWithMessages = (messages: ChatUI.AssistantMessage[], extraProps = {}) => { + MessageWrapperMock.mockClear(); + return render(AskAssistantChat, { + global: { stubs: stubsWithMessageWrapper }, + props: { + user: { firstName: 'Kobi', lastName: 'Dog' }, + messages, + ...extraProps, + }, + }); + }; + + const renderWithDirectives = (messages: ChatUI.AssistantMessage[], extraProps = {}) => { + MessageWrapperMock.mockClear(); + return render(AskAssistantChat, { + global: { + directives: { n8nHtml }, + stubs: stubsWithMessageWrapper, + }, + props: { + user: { firstName: 'Kobi', lastName: 'Dog' }, + messages, + ...extraProps, + }, + }); + }; + + const getMessageWrapperProps = (callIndex = 0): MessageWrapperProps => { + const mockCall = MessageWrapperMock.mock.calls[callIndex]; + expect(mockCall).toBeDefined(); + return (mockCall as unknown as [props: MessageWrapperProps])[0]; + }; + + const expectMessageWrapperCalledTimes = (times: number) => { + expect(MessageWrapperMock).toHaveBeenCalledTimes(times); + }; + + const expectToolMessage = ( + props: MessageWrapperProps, + expectedProps: Partial, + ) => { + expect(props.message).toEqual(expect.objectContaining(expectedProps)); + }; + + it('should not collapse single tool message', () => { + const message = createToolMessage({ + id: '1', + displayTitle: 'Search Results', + updates: [{ type: 'output', data: { result: 'Found 10 items' } }], + }); + + renderWithMessages([message]); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + expectToolMessage(props, { + ...message, + read: true, + }); + }); + + it('should collapse consecutive tool messages with same toolName', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'running', + displayTitle: 'Searching...', + updates: [{ type: 'progress', data: { status: 'Initializing search' } }], + }), + createToolMessage({ + id: '2', + status: 'running', + displayTitle: 'Still searching...', + customDisplayTitle: 'Custom Search Title', + updates: [{ type: 'progress', data: { status: 'Processing results' } }], + }), + createToolMessage({ + id: '3', + status: 'completed', + displayTitle: 'Search Complete', + updates: [{ type: 'output', data: { result: 'Found 10 items' } }], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + expectToolMessage(props, { + id: '3', + role: 'assistant', + type: 'tool', + toolName: 'search', + status: 'running', + displayTitle: 'Still searching...', + customDisplayTitle: 'Custom Search Title', + updates: [ + { type: 'progress', data: { status: 'Initializing search' } }, + { type: 'progress', data: { status: 'Processing results' } }, + { type: 'output', data: { result: 'Found 10 items' } }, + ], + read: true, + }); + }); + + it('should not collapse tool messages with different toolNames', () => { + const messages = [ + createToolMessage({ + id: '1', + toolName: 'search', + displayTitle: 'Search Results', + updates: [{ type: 'output', data: { result: 'Found 10 items' } }], + }), + createToolMessage({ + id: '2', + toolName: 'fetch', + displayTitle: 'Data Fetched', + updates: [{ type: 'output', data: { result: 'Data retrieved' } }], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(2); + + const firstProps = getMessageWrapperProps(0); + expectToolMessage(firstProps, { + id: '1', + toolName: 'search', + status: 'completed', + displayTitle: 'Search Results', + }); + + const secondProps = getMessageWrapperProps(1); + expectToolMessage(secondProps, { + id: '2', + toolName: 'fetch', + status: 'completed', + displayTitle: 'Data Fetched', + }); + }); + + it('should collapse completed and error statuses', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'completed', + displayTitle: 'Search Complete', + updates: [{ type: 'output', data: { result: 'Found some items' } }], + }), + createToolMessage({ + id: '2', + status: 'error', + displayTitle: 'Search error', + customDisplayTitle: 'Custom Running Title', + updates: [{ type: 'progress', data: { status: 'Processing more results' } }], + }), + createToolMessage({ + id: '3', + status: 'completed', + displayTitle: 'Final Search Complete', + updates: [{ type: 'output', data: { result: 'All done' } }], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + expectToolMessage(props, { + id: '3', + status: 'error', + displayTitle: 'Search error', + customDisplayTitle: undefined, + updates: [ + { type: 'output', data: { result: 'Found some items' } }, + { type: 'progress', data: { status: 'Processing more results' } }, + { type: 'output', data: { result: 'All done' } }, + ], + }); + }); + + it('should collapse running, completed and error statuses into running', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'running', + displayTitle: 'Search Running', + customDisplayTitle: 'Custom Search Title', + updates: [{ type: 'output', data: { result: 'Found some items' } }], + }), + createToolMessage({ + id: '2', + status: 'error', + displayTitle: 'Search error', + customDisplayTitle: 'Custom Error Title', + updates: [{ type: 'progress', data: { status: 'Processing more results' } }], + }), + createToolMessage({ + id: '3', + status: 'completed', + displayTitle: 'Final Search Complete', + updates: [{ type: 'output', data: { result: 'All done' } }], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + expectToolMessage(props, { + id: '3', + role: 'assistant', + type: 'tool', + toolName: 'search', + status: 'running', + displayTitle: 'Search Running', + customDisplayTitle: 'Custom Search Title', + updates: [ + { type: 'output', data: { result: 'Found some items' } }, + { type: 'progress', data: { status: 'Processing more results' } }, + { type: 'output', data: { result: 'All done' } }, + ], + read: true, + }); + }); + + it('should preserve running status when collapsing messages with running status', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'completed', + displayTitle: 'Search Complete', + updates: [{ type: 'output', data: { result: 'Found some items' } }], + }), + createToolMessage({ + id: '2', + status: 'running', + displayTitle: 'Still searching...', + customDisplayTitle: 'Custom Running Title', + updates: [{ type: 'progress', data: { status: 'Processing more results' } }], + }), + createToolMessage({ + id: '3', + status: 'completed', + displayTitle: 'Final Search Complete', + updates: [{ type: 'output', data: { result: 'All done' } }], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + expectToolMessage(props, { + id: '3', + status: 'running', + displayTitle: 'Still searching...', + customDisplayTitle: 'Custom Running Title', + updates: [ + { type: 'output', data: { result: 'Found some items' } }, + { type: 'progress', data: { status: 'Processing more results' } }, + { type: 'output', data: { result: 'All done' } }, + ], + }); + }); + + it('should combine all updates from collapsed messages', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'running', + displayTitle: 'Searching...', + updates: [ + { type: 'progress', data: { status: 'Starting search' } }, + { type: 'input', data: { query: 'test query' } }, + ], + }), + createToolMessage({ + id: '2', + status: 'completed', + displayTitle: 'Search Complete', + updates: [ + { type: 'progress', data: { status: 'Processing results' } }, + { type: 'output', data: { result: 'Found 10 items' } }, + ], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + const toolMessage = props.message as ChatUI.ToolMessage; + expect(toolMessage.status).toEqual('running'); + expect(toolMessage.updates).toEqual([ + { type: 'progress', data: { status: 'Starting search' } }, + { type: 'input', data: { query: 'test query' } }, + { type: 'progress', data: { status: 'Processing results' } }, + { type: 'output', data: { result: 'Found 10 items' } }, + ]); + }); + + it('should not collapse tool messages separated by non-tool messages', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'completed', + displayTitle: 'First Search', + updates: [{ type: 'output', data: { result: 'First result' } }], + }), + { + id: '2', + role: 'assistant' as const, + type: 'text' as const, + content: 'Here are the search results', + }, + createToolMessage({ + id: '3', + status: 'completed', + displayTitle: 'Second Search', + updates: [{ type: 'output', data: { result: 'Second result' } }], + }), + ]; + + renderWithDirectives(messages); + + expectMessageWrapperCalledTimes(3); + + const firstProps = getMessageWrapperProps(0); + expectToolMessage(firstProps, { + id: '1', + type: 'tool', + toolName: 'search', + displayTitle: 'First Search', + }); + + const secondProps = getMessageWrapperProps(1); + expect(secondProps.message).toEqual( + expect.objectContaining({ + id: '2', + type: 'text', + content: 'Here are the search results', + }), + ); + + const thirdProps = getMessageWrapperProps(2); + expectToolMessage(thirdProps, { + id: '3', + type: 'tool', + toolName: 'search', + displayTitle: 'Second Search', + }); + }); + + it('should handle customDisplayTitle correctly for running status', () => { + const messages = [ + createToolMessage({ + id: '1', + status: 'completed', + displayTitle: 'Search Complete', + customDisplayTitle: 'Should be ignored for completed', + updates: [{ type: 'output', data: { result: 'Found items' } }], + }), + createToolMessage({ + id: '2', + status: 'running', + displayTitle: 'Searching...', + customDisplayTitle: 'Custom Running Title', + updates: [{ type: 'progress', data: { status: 'In progress' } }], + }), + ]; + + renderWithMessages(messages); + + expectMessageWrapperCalledTimes(1); + const props = getMessageWrapperProps(); + + expectToolMessage(props, { + status: 'running', + displayTitle: 'Searching...', + customDisplayTitle: 'Custom Running Title', + }); + }); + + it('should handle mixed message types correctly', () => { + const messages = [ + { + id: '1', + role: 'user' as const, + type: 'text' as const, + content: 'Please search for something', + }, + createToolMessage({ + id: '2', + status: 'running', + displayTitle: 'Searching...', + updates: [{ type: 'progress', data: { status: 'Starting' } }], + }), + createToolMessage({ + id: '3', + status: 'completed', + displayTitle: 'Search Complete', + updates: [{ type: 'output', data: { result: 'Found results' } }], + }), + { + id: '4', + role: 'assistant' as const, + type: 'text' as const, + content: 'Here are your search results', + }, + ]; + + renderWithDirectives(messages); + + expectMessageWrapperCalledTimes(3); + + const firstProps = getMessageWrapperProps(0); + expect(firstProps.message).toEqual( + expect.objectContaining({ + id: '1', + role: 'user', + type: 'text', + content: 'Please search for something', + }), + ); + + const secondProps = getMessageWrapperProps(1); + expectToolMessage(secondProps, { + id: '3', + role: 'assistant', + type: 'tool', + toolName: 'search', + status: 'running', + updates: [ + { type: 'progress', data: { status: 'Starting' } }, + { type: 'output', data: { result: 'Found results' } }, + ], + }); + + const thirdProps = getMessageWrapperProps(2); + expect(thirdProps.message).toEqual( + expect.objectContaining({ + id: '4', + role: 'assistant', + type: 'text', + content: 'Here are your search results', + }), + ); + }); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue index f2666f33a4..8554eccf5e 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/AskAssistantChat.vue @@ -4,6 +4,7 @@ import { computed, nextTick, ref, watch } from 'vue'; import MessageWrapper from './messages/MessageWrapper.vue'; import { useI18n } from '../../composables/useI18n'; import type { ChatUI, RatingFeedback } from '../../types/assistant'; +import { isToolMessage } from '../../types/assistant'; import AssistantIcon from '../AskAssistantIcon/AssistantIcon.vue'; import AssistantLoadingMessage from '../AskAssistantLoadingMessage/AssistantLoadingMessage.vue'; import AssistantText from '../AskAssistantText/AssistantText.vue'; @@ -54,13 +55,88 @@ const props = withDefaults(defineProps(), { scrollOnNewMessage: false, }); -// Ensure all messages have required id and read properties -const normalizedMessages = computed(() => { - return props.messages.map((msg, index) => ({ +function normalizeMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { + return messages.map((msg, index) => ({ ...msg, id: msg.id || `msg-${index}`, read: msg.read ?? true, })); +} + +function collapseToolMessages(messages: ChatUI.AssistantMessage[]): ChatUI.AssistantMessage[] { + const result: ChatUI.AssistantMessage[] = []; + let i = 0; + + while (i < messages.length) { + const currentMsg = messages[i]; + + // If it's not a tool message, add it as-is and continue + if (!isToolMessage(currentMsg)) { + result.push(currentMsg); + i++; + continue; + } + + // Collect consecutive tool messages with the same toolName + const toolMessagesGroup = [currentMsg]; + let j = i + 1; + + while (j < messages.length) { + const nextMsg = messages[j]; + if (isToolMessage(nextMsg) && nextMsg.toolName === currentMsg.toolName) { + toolMessagesGroup.push(nextMsg); + j++; + } else { + break; + } + } + + // If we have multiple tool messages with the same toolName, collapse them + if (toolMessagesGroup.length > 1) { + // Determine the status to show based on priority rules + const lastMessage = toolMessagesGroup[toolMessagesGroup.length - 1]; + let titleSource = lastMessage; + + // Check if we have running messages - if so, show the last running one and use its titles + const runningMessages = toolMessagesGroup.filter((msg) => msg.status === 'running'); + const errorMessage = toolMessagesGroup.find((msg) => msg.status === 'error'); + if (runningMessages.length > 0) { + const lastRunning = runningMessages[runningMessages.length - 1]; + titleSource = lastRunning; + } else if (errorMessage) { + titleSource = errorMessage; + } + + // Combine all updates from all tool messages + const combinedUpdates = toolMessagesGroup.flatMap((msg) => msg.updates || []); + + // Create collapsed message with title logic based on final status + const collapsedMessage: ChatUI.ToolMessage = { + ...lastMessage, + status: titleSource.status, + updates: combinedUpdates, + displayTitle: titleSource.displayTitle, + // Only set customDisplayTitle if status is running (for example "Adding X node") + customDisplayTitle: + titleSource.status === 'running' ? titleSource.customDisplayTitle : undefined, + }; + + result.push(collapsedMessage); + } else { + // Single tool message, add as-is + result.push(currentMsg); + } + + i = j; + } + + return result; +} + +// Ensure all messages have required id and read properties, and collapse tool messages +const normalizedMessages = computed(() => { + const normalized = normalizeMessages(props.messages); + return collapseToolMessages(normalized); }); const textInputValue = ref(''); diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/MessageWrapper.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/MessageWrapper.vue index 64e8e460a6..f41f0c645e 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/MessageWrapper.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/MessageWrapper.vue @@ -11,7 +11,7 @@ import TextMessage from './TextMessage.vue'; import ToolMessage from './ToolMessage.vue'; import type { ChatUI, RatingFeedback } from '../../../types/assistant'; -interface Props { +export interface Props { message: ChatUI.AssistantMessage; isFirstOfRole: boolean; user?: { diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.test.ts b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.test.ts new file mode 100644 index 0000000000..dcbed33b38 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.test.ts @@ -0,0 +1,245 @@ +import { mount } from '@vue/test-utils'; +import { createPinia, setActivePinia } from 'pinia'; + +import type { Props } from './ToolMessage.vue'; +import ToolMessage from './ToolMessage.vue'; +import type { ChatUI } from '../../../types/assistant'; + +// Mock i18n to return keys instead of translated text +vi.mock('@n8n/design-system/composables/useI18n', () => ({ + useI18n: () => ({ + t: (key: string) => key, + }), +})); + +// Common mount options to reduce duplication +const createMountOptions = (props: Props) => ({ + props, + global: { + stubs: { + BaseMessage: { + template: '
', + }, + N8nIcon: true, + }, + }, +}); + +// Helper function to mount ToolMessage with common options +const mountToolMessage = (props: Props) => mount(ToolMessage, createMountOptions(props)); + +beforeEach(() => { + setActivePinia(createPinia()); +}); + +describe('ToolMessage', () => { + const baseMessage: ChatUI.ToolMessage & { id: string; read: boolean } = { + id: 'test-tool-message', + role: 'assistant', + type: 'tool', + toolName: 'search_files', + status: 'running', + updates: [], + read: false, + }; + + const user = { + firstName: 'John', + lastName: 'Doe', + }; + + describe('rendering', () => { + it('should render correctly with basic props', () => { + const wrapper = mountToolMessage({ + message: baseMessage, + isFirstOfRole: true, + user, + }); + + expect(wrapper.find('.toolMessage').exists()).toBe(true); + expect(wrapper.find('.header').exists()).toBe(true); + expect(wrapper.find('.titleRow').exists()).toBe(true); + expect(wrapper.find('.status').exists()).toBe(true); + }); + + it('should render with custom display title', () => { + const messageWithCustomTitle = { + ...baseMessage, + customDisplayTitle: 'Custom Tool Name', + }; + + const wrapper = mountToolMessage({ + message: messageWithCustomTitle, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Custom Tool Name'); + }); + + it('should render with display title', () => { + const messageWithDisplayTitle = { + ...baseMessage, + displayTitle: 'Display Tool Name', + }; + + const wrapper = mountToolMessage({ + message: messageWithDisplayTitle, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Display Tool Name'); + }); + + it('should render tool name in title case when no custom titles', () => { + const messageWithSnakeCase = { + ...baseMessage, + toolName: 'search_file_contents', + }; + + const wrapper = mountToolMessage({ + message: messageWithSnakeCase, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Search File Contents'); + }); + }); + + describe('status handling', () => { + it('should render running status with spinner icon', () => { + const runningMessage = { + ...baseMessage, + status: 'running' as const, + }; + + const wrapper = mountToolMessage({ + message: runningMessage, + isFirstOfRole: true, + }); + + expect(wrapper.html()).toContain('icon="spinner"'); + expect(wrapper.html()).toContain('spin'); + }); + + it('should render completed status with check icon', () => { + const completedMessage = { + ...baseMessage, + status: 'completed' as const, + }; + + const wrapper = mountToolMessage({ + message: completedMessage, + isFirstOfRole: true, + }); + + expect(wrapper.html()).toContain('icon="circle-check"'); + }); + + it('should render error status with error icon', () => { + const errorMessage = { + ...baseMessage, + status: 'error' as const, + }; + + const wrapper = mountToolMessage({ + message: errorMessage, + isFirstOfRole: true, + }); + + expect(wrapper.html()).toContain('icon="triangle-alert"'); + }); + }); + + describe('tooltip behavior', () => { + it('should enable tooltip for running status', () => { + const runningMessage = { + ...baseMessage, + status: 'running' as const, + }; + + const wrapper = mountToolMessage({ + message: runningMessage, + isFirstOfRole: true, + }); + + // Check that tooltip is enabled by looking for the actual tooltip attributes + expect(wrapper.html()).toContain('icon="spinner"'); + }); + + it('should disable tooltip for non-running status', () => { + const completedMessage = { + ...baseMessage, + status: 'completed' as const, + }; + + const wrapper = mountToolMessage({ + message: completedMessage, + isFirstOfRole: true, + }); + + // Check that the completed icon is rendered instead of spinner + expect(wrapper.html()).toContain('icon="circle-check"'); + }); + }); + + describe('toolDisplayName', () => { + it('should prioritize customDisplayTitle', () => { + const message = { + ...baseMessage, + customDisplayTitle: 'Custom Title', + displayTitle: 'Display Title', + toolName: 'tool_name', + }; + + const wrapper = mountToolMessage({ + message, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Custom Title'); + }); + + it('should use displayTitle when customDisplayTitle is not available', () => { + const message = { + ...baseMessage, + displayTitle: 'Display Title', + toolName: 'tool_name', + }; + + const wrapper = mountToolMessage({ + message, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Display Title'); + }); + + it('should convert snake_case toolName to Title Case', () => { + const message = { + ...baseMessage, + toolName: 'convert_snake_case_to_title', + }; + + const wrapper = mountToolMessage({ + message, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Convert Snake Case To Title'); + }); + + it('should handle single word toolName', () => { + const message = { + ...baseMessage, + toolName: 'search', + }; + + const wrapper = mountToolMessage({ + message, + isFirstOfRole: true, + }); + + expect(wrapper.text()).toContain('Search'); + }); + }); +}); diff --git a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.vue b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.vue index a6a1d7d369..71c6fb6df0 100644 --- a/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.vue +++ b/packages/frontend/@n8n/design-system/src/components/AskAssistantChat/messages/ToolMessage.vue @@ -1,14 +1,15 @@