feat: Simplify builder tool calls (no-changelog) (#18798)

This commit is contained in:
Mutasem Aldmour
2025-08-28 10:14:03 +02:00
committed by GitHub
parent ff56c95605
commit d244b99484
36 changed files with 2190 additions and 391 deletions

View File

@@ -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: {

View File

@@ -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<string, unknown>,
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<string, unknown>) => getCustomNodeTitle(input, nodeTypes),
};
}

View File

@@ -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,
};
}

View File

@@ -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<TToolName extends string = string>(
config: ToolRunnableConfig & LangGraphRunnableConfig,
toolName: TToolName,
displayTitle: string,
customTitle?: string,
): ProgressReporter {
const toolCallId = config.toolCall?.id;
let customDisplayTitle = customTitle;
const emit = (message: ToolProgressMessage<TToolName>): void => {
config.writer?.(message);
};
const start = <T>(input: T): void => {
const start = <T>(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<TToolName extends string = string>(
type: 'tool',
toolName,
toolCallId,
displayTitle,
customDisplayTitle,
status: 'running',
updates: [
{
@@ -56,6 +72,8 @@ export function createProgressReporter<TToolName extends string = string>(
type: 'tool',
toolName,
toolCallId,
displayTitle,
customDisplayTitle,
status: 'completed',
updates: [
{
@@ -71,6 +89,8 @@ export function createProgressReporter<TToolName extends string = string>(
type: 'tool',
toolName,
toolCallId,
displayTitle,
customDisplayTitle,
status: 'error',
updates: [
{

View File

@@ -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,
},
],
});
});
});
});

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -38,7 +38,7 @@ jest.mock('crypto', () => ({
describe('AddNodeTool', () => {
let nodeTypesList: INodeTypeDescription[];
let addNodeTool: ReturnType<typeof createAddNodeTool>;
let addNodeTool: ReturnType<typeof createAddNodeTool>['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');
});
});
});

View File

@@ -30,7 +30,7 @@ jest.mock('@langchain/langgraph', () => ({
describe('ConnectNodesTool', () => {
let nodeTypesList: INodeTypeDescription[];
let connectNodesTool: ReturnType<typeof createConnectNodesTool>;
let connectNodesTool: ReturnType<typeof createConnectNodesTool>['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' }),

View File

@@ -27,7 +27,7 @@ jest.mock('@langchain/langgraph', () => ({
describe('NodeDetailsTool', () => {
let nodeTypesList: INodeTypeDescription[];
let nodeDetailsTool: ReturnType<typeof createNodeDetailsTool>;
let nodeDetailsTool: ReturnType<typeof createNodeDetailsTool>['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');

View File

@@ -24,7 +24,7 @@ jest.mock('@langchain/langgraph', () => ({
describe('NodeSearchTool', () => {
let nodeTypesList: INodeTypeDescription[];
let nodeSearchTool: ReturnType<typeof createNodeSearchTool>;
let nodeSearchTool: ReturnType<typeof createNodeSearchTool>['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');

View File

@@ -26,14 +26,14 @@ jest.mock('@langchain/langgraph', () => ({
}));
describe('RemoveNodeTool', () => {
let removeNodeTool: ReturnType<typeof createRemoveNodeTool>;
let removeNodeTool: ReturnType<typeof createRemoveNodeTool>['tool'];
const mockGetCurrentTaskInput = getCurrentTaskInput as jest.MockedFunction<
typeof getCurrentTaskInput
>;
beforeEach(() => {
jest.clearAllMocks();
removeNodeTool = createRemoveNodeTool();
removeNodeTool = createRemoveNodeTool().tool;
});
afterEach(() => {

View File

@@ -36,7 +36,7 @@ jest.mock('../../../src/chains/parameter-updater', () => ({
describe('UpdateNodeParametersTool', () => {
let nodeTypesList: INodeTypeDescription[];
let updateNodeParametersTool: ReturnType<typeof createUpdateNodeParametersTool>;
let updateNodeParametersTool: ReturnType<typeof createUpdateNodeParametersTool>['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({

View File

@@ -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<string, unknown>, 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,
};
}

View File

@@ -26,6 +26,8 @@ export interface ToolProgressMessage<TToolName extends string = string> {
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: <T>(input: T) => void;
start: <T>(input: T, options?: { customDisplayTitle: string }) => void;
progress: (message: string, data?: Record<string, unknown>) => void;
complete: <T>(output: T) => void;
error: (error: ToolError) => void;

View File

@@ -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, unknown>) => 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<string, unknown> {
return {
role: 'user',
type: 'message',
text: msg.content,
};
}
/**
* Process array content from AIMessage and return formatted text messages
*/
function processArrayContent(content: unknown[]): Array<Record<string, unknown>> {
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<Record<string, unknown>> {
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<string, unknown> {
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<Record<string, unknown>> {
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<Record<string, unknown>>,
): 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<Record<string, unknown>>).push({
type: 'output',
data: typeof msg.content === 'string' ? { result: msg.content } : msg.content,
});
break;
}
}
}
export function formatMessages(
messages: Array<AIMessage | HumanMessage | ToolMessage>,
builderTools?: BuilderTool[],
): Array<Record<string, unknown>> {
const formattedMessages: Array<Record<string, unknown>> = [];
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<Record<string, unknown>>).push({
type: 'output',
data: typeof msg.content === 'string' ? { result: msg.content } : msg.content,
});
break;
}
}
processToolMessage(msg, formattedMessages);
}
}

View File

@@ -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<string, unknown>) =>
// 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<string, unknown>) =>
`Custom: ${Object.keys(values).length} args`,
},
];
const aiMessage = new AIMessage('');
const toolCall = {
id: 'call-1',
name: 'clear_workflow',
args: {} as Record<string, unknown>,
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<string, unknown>,
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<string, unknown>) => `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' },
});
});
});
});

View File

@@ -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,
});
}

View File

@@ -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: {

View File

@@ -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: '<div data-testid="message-wrapper-mock"></div>',
}));
const stubsWithMessageWrapper = {
...Object.fromEntries(stubs.map((stub) => [stub, true])),
MessageWrapper: MessageWrapperMock,
};
const createToolMessage = (
overrides: Partial<ChatUI.ToolMessage & { id: string }> = {},
): 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<ChatUI.ToolMessage & { id: string; read?: boolean }>,
) => {
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',
}),
);
});
});
});

View File

@@ -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<Props>(), {
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<string>('');

View File

@@ -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?: {

View File

@@ -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: '<div><slot /></div>',
},
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');
});
});
});

View File

@@ -1,14 +1,15 @@
<script setup lang="ts">
import { ref, computed } from 'vue';
import { computed } from 'vue';
import { useI18n } from '@n8n/design-system/composables/useI18n';
import BaseMessage from './BaseMessage.vue';
import type { ChatUI } from '../../../types/assistant';
import N8nIcon from '../../N8nIcon';
import N8nText from '../../N8nText';
import N8nTooltip from '../../N8nTooltip';
interface Props {
export interface Props {
message: ChatUI.ToolMessage & { id: string; read: boolean };
isFirstOfRole: boolean;
showProgressLogs?: boolean;
@@ -21,40 +22,22 @@ interface Props {
const props = defineProps<Props>();
const { t } = useI18n();
const expanded = ref(false);
const toolDisplayName = computed(() => {
// Convert tool names from snake_case to Title Case
return props.message.toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
});
const latestInput = computed(() => {
const inputUpdate = props.message.updates?.find((u) => u.type === 'input');
return inputUpdate?.data;
});
const latestOutput = computed(() => {
const outputUpdate = props.message.updates.find((u) => u.type === 'output');
return outputUpdate?.data;
});
const latestError = computed(() => {
const errorUpdate = props.message.updates.find((u) => u.type === 'error');
return errorUpdate?.data;
});
const progressMessages = computed(() => {
return (props.message.updates ?? []).filter((u) => u.type === 'progress').map((u) => u.data);
return (
props.message.customDisplayTitle ??
props.message.displayTitle ??
props.message.toolName
.split('_')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
);
});
const statusMessage = computed(() => {
switch (props.message.status) {
case 'running':
return t('assistantChat.builder.toolRunning');
case 'completed':
return t('assistantChat.builder.toolCompleted');
case 'error':
return t('assistantChat.builder.toolError');
default:
@@ -67,40 +50,20 @@ const statusColor = computed(() => {
case 'completed':
return 'success';
case 'error':
return 'danger';
return 'warning';
default:
return 'secondary';
}
});
function formatJSON(data: Record<string, unknown> | string): string {
if (!data) return '';
try {
return JSON.stringify(data, null, 2);
} catch {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
return String(data);
}
}
function toggleExpanded() {
expanded.value = !expanded.value;
}
</script>
<template>
<BaseMessage :message="message" :is-first-of-role="isFirstOfRole" :user="user">
<div :class="$style.toolMessage">
<div :class="$style.header" @click="toggleExpanded">
<div :class="$style.header">
<div :class="$style.titleRow">
<N8nIcon
:icon="expanded ? 'chevron-down' : 'chevron-right'"
size="small"
:class="$style.expandIcon"
/>
<span :class="$style.toolName">{{ toolDisplayName }}</span>
<div :class="$style.status">
<N8nTooltip placement="left" :disabled="message.status !== 'running'">
<N8nTooltip placement="top" :disabled="!statusMessage">
<template #content>
<span :class="$style.statusText">
{{ statusMessage }}
@@ -111,49 +74,24 @@ function toggleExpanded() {
icon="spinner"
spin
:color="statusColor"
size="large"
/>
<N8nIcon
v-else-if="message.status === 'error'"
icon="status-error"
icon="triangle-alert"
:color="statusColor"
size="large"
/>
<N8nIcon v-else icon="status-completed" :color="statusColor" />
<N8nIcon v-else icon="circle-check" :color="statusColor" size="large" />
</N8nTooltip>
</div>
</div>
</div>
<div v-if="expanded" :class="$style.content">
<!-- Progress messages -->
<div v-if="progressMessages.length > 0 && showProgressLogs" :class="$style.section">
<div :class="$style.sectionTitle">Progress</div>
<div
v-for="(progress, index) in progressMessages"
:key="index"
:class="$style.progressItem"
<N8nText
size="small"
bold
:color="message.status === 'running' ? 'text-light' : 'text-dark'"
:class="{ [$style.running]: message.status === 'running' }"
>{{ toolDisplayName }}</N8nText
>
{{ progress }}
</div>
</div>
<!-- Input -->
<div v-if="latestInput" :class="$style.section">
<div :class="$style.sectionTitle">Input</div>
<pre :class="$style.jsonContent">{{ formatJSON(latestInput) }}</pre>
</div>
<!-- Output -->
<div v-if="latestOutput" :class="$style.section">
<div :class="$style.sectionTitle">Output</div>
<pre :class="$style.jsonContent">{{ formatJSON(latestOutput) }}</pre>
</div>
<!-- Error -->
<div v-if="latestError" :class="$style.section">
<div :class="$style.sectionTitle">Error</div>
<div :class="$style.errorContent">
{{ latestError.message || latestError }}
</div>
</div>
</div>
</div>
@@ -161,45 +99,30 @@ function toggleExpanded() {
</template>
<style lang="scss" module>
@use '../../../css/mixins/animations';
.toolMessage {
width: 100%;
}
.header {
cursor: pointer;
padding: var(--spacing-xs);
border-radius: var(--border-radius-base);
background-color: var(--color-background-light);
&:hover {
background-color: var(--color-background-base);
}
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing-xs);
}
.expandIcon {
flex-shrink: 0;
}
.toolName {
font-weight: var(--font-weight-bold);
flex: 1;
}
.status {
display: flex;
align-items: center;
gap: var(--spacing-3xs);
}
.running {
@include animations.shimmer;
}
.statusText {
font-size: var(--font-size-2xs);
text-transform: capitalize;
&.status-running {
color: var(--execution-card-text-waiting);
@@ -215,20 +138,10 @@ function toggleExpanded() {
}
.content {
margin-top: var(--spacing-xs);
padding: var(--spacing-xs);
background-color: var(--color-background-xlight);
padding: 0 var(--spacing-xs) var(--spacing-xs) var(--spacing-xs);
border-radius: var(--border-radius-base);
}
.section {
margin-bottom: var(--spacing-s);
&:last-child {
margin-bottom: 0;
}
}
.sectionTitle {
font-weight: var(--font-weight-bold);
font-size: var(--font-size-2xs);
@@ -236,39 +149,6 @@ function toggleExpanded() {
margin-bottom: var(--spacing-3xs);
}
.progressItem {
font-size: var(--font-size-2xs);
color: var(--color-text-base);
margin-bottom: var(--spacing-3xs);
}
.jsonContent {
font-family: var(--font-family-monospace);
font-size: var(--font-size-3xs);
background-color: var(--color-background-base);
padding: var(--spacing-xs);
border-radius: var(--border-radius-base);
overflow-x: auto;
margin: 0;
max-height: 300px;
overflow-y: auto;
@supports not (selector(::-webkit-scrollbar)) {
scrollbar-width: thin;
}
@supports selector(::-webkit-scrollbar) {
&::-webkit-scrollbar {
width: var(--spacing-2xs);
height: var(--spacing-2xs);
}
&::-webkit-scrollbar-thumb {
border-radius: var(--spacing-xs);
background: var(--color-foreground-dark);
border: var(--spacing-5xs) solid white;
}
}
}
.errorContent {
color: var(--color-danger);
font-size: var(--font-size-2xs);

View File

@@ -8,6 +8,11 @@ describe('AssistantLoadingMessage', () => {
props: {
message: 'Thinking...',
},
global: {
stubs: {
N8nText: true,
},
},
});
expect(container).toMatchSnapshot();
});

View File

@@ -22,6 +22,11 @@ Default.args = {
message: 'Searching n8n documentation for the best possible answer...',
};
export const Thinking = Template.bind({});
Thinking.args = {
message: 'Thinking...',
};
export const NarrowContainer = Template.bind({});
NarrowContainer.args = {
...Default.args,

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import AssistantAvatar from '../AskAssistantAvatar/AssistantAvatar.vue';
import N8nText from '../N8nText';
withDefaults(
defineProps<{
@@ -14,18 +14,19 @@ withDefaults(
<template>
<div :class="$style.container">
<div :class="$style.avatar">
<AssistantAvatar size="mini" />
</div>
<div :class="$style['message-container']">
<transition :name="animationType" mode="out-in">
<span v-if="message" :key="message" :class="$style.message">{{ message }}</span>
<N8nText v-if="message" :key="message" :class="$style.message" :shimmer="true">{{
message
}}</N8nText>
</transition>
</div>
</div>
</template>
<style module lang="scss">
@use '../../css/mixins/animations';
.container {
display: flex;
align-items: center;
@@ -33,12 +34,6 @@ withDefaults(
user-select: none;
}
.avatar {
height: var(--spacing-m);
animation: pulse 1.5s infinite;
position: relative;
}
.message-container {
display: inline-flex;
position: relative;
@@ -54,21 +49,8 @@ withDefaults(
font-size: var(--font-size-2xs);
color: var(--color-text-base);
text-align: left;
}
@keyframes pulse {
0% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(1.2);
opacity: 1;
}
100% {
transform: scale(1);
opacity: 0.7;
}
@include animations.shimmer;
}
</style>

View File

@@ -6,50 +6,6 @@ exports[`AssistantLoadingMessage > renders loading message correctly 1`] = `
class="container"
data-v-4e90e01e=""
>
<div
class="avatar"
data-v-4e90e01e=""
>
<div
class="container mini"
data-v-4e90e01e=""
>
<svg
fill="none"
height="8"
viewBox="0 0 24 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M19.9658 14.0171C19.9679 14.3549 19.8654 14.6851 19.6722 14.9622C19.479 15.2393 19.2046 15.4497 18.8869 15.5645L13.5109 17.5451L11.5303 22.9211C11.4137 23.2376 11.2028 23.5107 10.9261 23.7037C10.6494 23.8966 10.3202 24 9.9829 24C9.64559 24 9.3164 23.8966 9.0397 23.7037C8.76301 23.5107 8.55212 23.2376 8.43549 22.9211L6.45487 17.5451L1.07888 15.5645C0.762384 15.4479 0.489262 15.237 0.296347 14.9603C0.103431 14.6836 0 14.3544 0 14.0171C0 13.6798 0.103431 13.3506 0.296347 13.0739C0.489262 12.7972 0.762384 12.5863 1.07888 12.4697L6.45487 10.4891L8.43549 5.11309C8.55212 4.79659 8.76301 4.52347 9.0397 4.33055C9.3164 4.13764 9.64559 4.0342 9.9829 4.0342C10.3202 4.0342 10.6494 4.13764 10.9261 4.33055C11.2028 4.52347 11.4137 4.79659 11.5303 5.11309L13.5109 10.4891L18.8869 12.4697C19.2046 12.5845 19.479 12.7949 19.6722 13.072C19.8654 13.3491 19.9679 13.6793 19.9658 14.0171ZM14.1056 4.12268H15.7546V5.77175C15.7546 5.99043 15.8415 6.20015 15.9961 6.35478C16.1508 6.50941 16.3605 6.59628 16.5792 6.59628C16.7979 6.59628 17.0076 6.50941 17.1622 6.35478C17.3168 6.20015 17.4037 5.99043 17.4037 5.77175V4.12268H19.0528C19.2715 4.12268 19.4812 4.03581 19.6358 3.88118C19.7905 3.72655 19.8773 3.51682 19.8773 3.29814C19.8773 3.07946 19.7905 2.86974 19.6358 2.71511C19.4812 2.56048 19.2715 2.47361 19.0528 2.47361H17.4037V0.824535C17.4037 0.605855 17.3168 0.396131 17.1622 0.241501C17.0076 0.0868704 16.7979 0 16.5792 0C16.3605 0 16.1508 0.0868704 15.9961 0.241501C15.8415 0.396131 15.7546 0.605855 15.7546 0.824535V2.47361H14.1056C13.8869 2.47361 13.6772 2.56048 13.5225 2.71511C13.3679 2.86974 13.281 3.07946 13.281 3.29814C13.281 3.51682 13.3679 3.72655 13.5225 3.88118C13.6772 4.03581 13.8869 4.12268 14.1056 4.12268ZM23.1755 7.42082H22.3509V6.59628C22.3509 6.3776 22.2641 6.16788 22.1094 6.01325C21.9548 5.85862 21.7451 5.77175 21.5264 5.77175C21.3077 5.77175 21.098 5.85862 20.9434 6.01325C20.7887 6.16788 20.7019 6.3776 20.7019 6.59628V7.42082H19.8773C19.6586 7.42082 19.4489 7.50769 19.2943 7.66232C19.1397 7.81695 19.0528 8.02667 19.0528 8.24535C19.0528 8.46404 19.1397 8.67376 19.2943 8.82839C19.4489 8.98302 19.6586 9.06989 19.8773 9.06989H20.7019V9.89443C20.7019 10.1131 20.7887 10.3228 20.9434 10.4775C21.098 10.6321 21.3077 10.719 21.5264 10.719C21.7451 10.719 21.9548 10.6321 22.1094 10.4775C22.2641 10.3228 22.3509 10.1131 22.3509 9.89443V9.06989H23.1755C23.3941 9.06989 23.6039 8.98302 23.7585 8.82839C23.9131 8.67376 24 8.46404 24 8.24535C24 8.02667 23.9131 7.81695 23.7585 7.66232C23.6039 7.50769 23.3941 7.42082 23.1755 7.42082Z"
fill="white"
/>
<defs>
<lineargradient
gradientUnits="userSpaceOnUse"
id="paint0_linear_173_12825"
x1="-3.67094e-07"
x2="28.8315"
y1="-0.000120994"
y2="9.82667"
>
<stop
stop-color="var(--color-assistant-highlight-1)"
/>
<stop
offset="0.495"
stop-color="var(--color-assistant-highlight-2)"
/>
<stop
offset="1"
stop-color="var(--color-assistant-highlight-3)"
/>
</lineargradient>
</defs>
</svg>
</div>
</div>
<div
class="message-container"
data-v-4e90e01e=""
@@ -62,12 +18,15 @@ exports[`AssistantLoadingMessage > renders loading message correctly 1`] = `
name="slide-vertical"
persisted="false"
>
<span
<n8n-text-stub
bold="false"
class="message"
compact="false"
data-v-4e90e01e=""
>
Thinking...
</span>
shimmer="true"
size="medium"
tag="span"
/>
</transition-stub>
</div>
</div>

View File

@@ -0,0 +1,16 @@
@mixin shimmer {
background: linear-gradient(135deg, #fff, #5e5e5e, #fff);
background-clip: text;
color: transparent;
background-size: 200% 100%;
animation: shimmer 2.5s linear infinite;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}

View File

@@ -49,9 +49,8 @@ export default {
'assistantChat.builder.selectedNodes': 'Selected workflow nodes',
'assistantChat.builder.selectingNodes': 'Selecting nodes...',
'assistantChat.builder.generatedNodes': 'Generated workflow nodes',
'assistantChat.builder.toolRunning': 'Tool running',
'assistantChat.builder.toolCompleted': 'Tool completed',
'assistantChat.builder.toolError': 'Tool completed with error',
'assistantChat.builder.toolRunning': 'Tool still running',
'assistantChat.builder.toolError': 'Some tool calls have failed. Agent will retry these.',
'assistantChat.errorParsingMarkdown': 'Error parsing markdown content',
'assistantChat.aiAssistantLabel': 'AI Assistant',
'assistantChat.aiAssistantName': 'Assistant',

View File

@@ -77,6 +77,8 @@ export namespace ChatUI {
type: 'tool';
toolName: string;
toolCallId?: string;
displayTitle?: string; // tool display name like "Searching for node"
customDisplayTitle?: string; // tool call specific custom title like "Searching for OpenAI"
status: 'running' | 'completed' | 'error';
updates: Array<{
type: 'input' | 'output' | 'progress' | 'error';

View File

@@ -209,8 +209,6 @@
"aiAssistant.codeUpdated.message.body2": "node to see the changes",
"aiAssistant.thinkingSteps.analyzingError": "Analyzing the error...",
"aiAssistant.thinkingSteps.thinking": "Thinking...",
"aiAssistant.thinkingSteps.runningTools": "Running tools...",
"aiAssistant.thinkingSteps.processingResults": "Processing results...",
"aiAssistant.prompts.currentView.workflowList": "The user is currently looking at the list of workflows.",
"aiAssistant.prompts.currentView.credentialsList": "The user is currently looking at the list of credentials.",
"aiAssistant.prompts.currentView.executionsView": "The user is currently looking at the list of executions for the currently open workflow.",

View File

@@ -269,7 +269,7 @@ describe('useBuilderMessages', () => {
expect(result.messages[2].id).toBe('batch-id-2');
});
it('should show running tools message when tools are in progress', () => {
it('should show thinking message when tools are in progress', () => {
const currentMessages: ChatUI.AssistantMessage[] = [];
const newMessages: ChatRequest.MessageResponse[] = [
{
@@ -289,11 +289,11 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(1);
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
expect(result.thinkingMessage).toBe(undefined);
expect(result.shouldClearThinking).toBe(false);
});
it('should show processing message when tools are completed but no text response yet', () => {
it('should show thinking message when tools are completed but no text response yet', () => {
const currentMessages: ChatUI.AssistantMessage[] = [];
const newMessages: ChatRequest.MessageResponse[] = [
{
@@ -316,7 +316,7 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(1);
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
expect(result.shouldClearThinking).toBe(false);
});
@@ -389,11 +389,11 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(2);
// Should show "aiAssistant.thinkingSteps.runningTools" for the new running tool, not "aiAssistant.thinkingSteps.processingResults"
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Should not show thinking message as new tool is running
expect(result.thinkingMessage).toBe(undefined);
});
it('should show processing message when second tool completes', () => {
it('should show thinking message when second tool completes', () => {
// Both tools completed
const currentMessages: ChatUI.AssistantMessage[] = [
{
@@ -426,7 +426,7 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(2);
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
});
it('should keep showing running tools message when parallel tools complete one by one', () => {
@@ -473,8 +473,8 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(2);
// Should still show "aiAssistant.thinkingSteps.runningTools" because call-456 is still running
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Should not show thinking because call-456 is still running
expect(result.thinkingMessage).toBe(undefined);
// Verify first tool is now completed
const firstTool = result.messages.find(
@@ -489,7 +489,7 @@ describe('useBuilderMessages', () => {
expect(secondTool.status).toBe('running');
});
it('should show processing results when all parallel tools complete', () => {
it('should show thinking message when all parallel tools complete', () => {
// One tool already completed, one still running
const currentMessages: ChatUI.AssistantMessage[] = [
{
@@ -533,11 +533,11 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(2);
// Should now show "aiAssistant.thinkingSteps.processingResults" because all tools are completed
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
// Should now show thinking because all tools are completed but no text response yet
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
});
it('should keep processing message when workflow-updated arrives after tools complete', () => {
it('should keep thinking message when workflow-updated arrives after tools complete', () => {
// Tool completed
const currentMessages: ChatUI.AssistantMessage[] = [
{
@@ -568,13 +568,13 @@ describe('useBuilderMessages', () => {
);
expect(result.messages).toHaveLength(2);
// Should still show "aiAssistant.thinkingSteps.processingResults" because workflow-updated is not a text response
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
// Should still show thinking because workflow-updated is not a text response
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// Should NOT clear thinking for workflow updates
expect(result.shouldClearThinking).toBe(false);
});
it('should clear processing message only when text arrives after workflow-updated', () => {
it('should clear thinking message only when text arrives after workflow-updated', () => {
// Tool completed and workflow updated
const currentMessages: ChatUI.AssistantMessage[] = [
{
@@ -1263,7 +1263,7 @@ describe('useBuilderMessages', () => {
];
let result = builderMessages.processAssistantMessages(currentMessages, batch1, 'batch-1');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
expect(result.thinkingMessage).toBe(undefined);
currentMessages = result.messages;
// Second batch: tool completes
@@ -1279,7 +1279,7 @@ describe('useBuilderMessages', () => {
];
result = builderMessages.processAssistantMessages(currentMessages, batch2, 'batch-2');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
currentMessages = result.messages;
// Third batch: workflow updated
@@ -1292,7 +1292,7 @@ describe('useBuilderMessages', () => {
];
result = builderMessages.processAssistantMessages(currentMessages, batch3, 'batch-3');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.processingResults');
expect(result.thinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
currentMessages = result.messages;
// Fourth batch: final text response

View File

@@ -169,6 +169,8 @@ export function useBuilderMessages() {
type: 'tool',
toolName: msg.toolName,
toolCallId: msg.toolCallId,
displayTitle: msg.displayTitle,
customDisplayTitle: msg.customDisplayTitle,
status: msg.status,
updates: msg.updates || [],
read: false,
@@ -237,10 +239,8 @@ export function useBuilderMessages() {
function determineThinkingMessage(messages: ChatUI.AssistantMessage[]): string | undefined {
const { hasAnyRunningTools, isStillThinking } = getThinkingState(messages);
if (hasAnyRunningTools) {
return locale.baseText('aiAssistant.thinkingSteps.runningTools');
} else if (isStillThinking) {
return locale.baseText('aiAssistant.thinkingSteps.processingResults');
if (!hasAnyRunningTools && isStillThinking) {
return locale.baseText('aiAssistant.thinkingSteps.thinking');
}
return undefined;
@@ -364,6 +364,8 @@ export function useBuilderMessages() {
type: 'tool',
toolName: message.toolName,
toolCallId: message.toolCallId,
displayTitle: message.displayTitle,
customDisplayTitle: message.customDisplayTitle,
status: message.status,
updates: message.updates || [],
read: false,

View File

@@ -257,8 +257,8 @@ describe('AI Builder store', () => {
],
});
// Should show "aiAssistant.thinkingSteps.runningTools"
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Should show "aiAssistant.thinkingSteps.thinking"
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// Second tool starts (different toolCallId)
onMessageCallback({
@@ -274,8 +274,8 @@ describe('AI Builder store', () => {
],
});
// Still showing "aiAssistant.thinkingSteps.runningTools" with multiple tools
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Still showing "aiAssistant.thinkingSteps.thinking" with multiple tools
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// First tool completes
onMessageCallback({
@@ -291,8 +291,8 @@ describe('AI Builder store', () => {
],
});
// Still "aiAssistant.thinkingSteps.runningTools" because second tool is still running
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.runningTools');
// Still "aiAssistant.thinkingSteps.thinking" because second tool is still running
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// Second tool completes
onMessageCallback({
@@ -308,19 +308,15 @@ describe('AI Builder store', () => {
],
});
// Now should show "aiAssistant.thinkingSteps.processingResults" because all tools completed
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
// Now should show "aiAssistant.thinkingSteps.thinking" because all tools completed
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// Call onDone to stop streaming
onDoneCallback();
// Message should persist after streaming ends
expect(builderStore.streaming).toBe(false);
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
vi.useRealTimers();
});
@@ -360,18 +356,14 @@ describe('AI Builder store', () => {
builderStore.sendChatMessage({ text: 'Add a node' });
// Should show "aiAssistant.thinkingSteps.processingResults" when tool completes
// Should show "aiAssistant.thinkingSteps.thinking" when tool completes
await vi.waitFor(() =>
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
),
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking'),
);
// Should still show "aiAssistant.thinkingSteps.processingResults" after workflow-updated
// Should still show "aiAssistant.thinkingSteps.thinking" after workflow-updated
await vi.waitFor(() => expect(builderStore.chatMessages).toHaveLength(3)); // user + tool + workflow
expect(builderStore.assistantThinkingMessage).toBe(
'aiAssistant.thinkingSteps.processingResults',
);
expect(builderStore.assistantThinkingMessage).toBe('aiAssistant.thinkingSteps.thinking');
// Verify streaming has ended
expect(builderStore.streaming).toBe(false);