diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts index 3a2201dd71..59db8338f6 100644 --- a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts +++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.test.ts @@ -1355,4 +1355,203 @@ describe('AskAssistantBuild', () => { expect(chatInput).not.toHaveAttribute('disabled'); }); }); + + it('should handle multiple canvas generations correctly', async () => { + const originalWorkflow = { + nodes: [], + connections: {}, + }; + builderStore.getWorkflowSnapshot.mockReturnValue(JSON.stringify(originalWorkflow)); + + const intermediaryWorkflow = { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }; + + const finalWorkflow = { + nodes: [ + { + id: 'node1', + name: 'Start', + type: 'n8n-nodes-base.start', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + + { + id: 'node2', + name: 'HttpReuqest', + type: 'n8n-nodes-base.httpRequest', + position: [0, 0], + typeVersion: 1, + parameters: {}, + } as INodeUi, + ], + connections: {}, + }; + workflowsStore.$patch({ + workflow: originalWorkflow, + }); + + renderComponent(); + + builderStore.$patch({ streaming: true }); + await flushPromises(); + + // Trigger the watcher by updating workflowMessages + builderStore.workflowMessages = [ + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'workflow-updated' as const, + codeSnippet: JSON.stringify(intermediaryWorkflow), + }, + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'workflow-updated' as const, + codeSnippet: JSON.stringify(finalWorkflow), + }, + ]; + + const toolCallId1_1 = '1234'; + const toolCallId1_2 = '3333'; + const toolCallId2 = '4567'; + const toolCallId3 = '8901'; + + builderStore.toolMessages = [ + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'first-tool', + toolCallId: toolCallId1_1, + status: 'completed', + updates: [], + }, + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'first-tool', + toolCallId: toolCallId1_2, + status: 'completed', + updates: [], + }, + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'second-tool', + toolCallId: toolCallId2, + status: 'running', + updates: [], + }, + ]; + + builderStore.$patch({ streaming: false }); + await flushPromises(); + + expect(trackMock).toHaveBeenCalledOnce(); + expect(trackMock).toHaveBeenCalledWith('Workflow modified by builder', { + end_workflow_json: JSON.stringify(finalWorkflow), + session_id: 'app_session_id', + start_workflow_json: JSON.stringify(originalWorkflow), + // first-tool is added once, even though it completed twice + // second-tool is ignored because it's running + tools_called: ['first-tool'], + workflow_id: 'abc123', + }); + + trackMock.mockClear(); + + builderStore.$patch({ streaming: true }); + + await flushPromises(); + // second run after new messages + const updatedWorkflow2 = { + ...finalWorkflow, + nodes: [ + ...finalWorkflow.nodes, + { + id: 'node1', + name: 'Updated', + type: 'n8n-nodes-base.updated', + position: [0, 0], + typeVersion: 1, + parameters: {}, + }, + ], + }; + builderStore.workflowMessages = [ + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'workflow-updated' as const, + codeSnippet: JSON.stringify(updatedWorkflow2), + }, + ]; + + builderStore.toolMessages = [ + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'first-tool', + toolCallId: toolCallId1_1, + status: 'completed', + updates: [], + }, + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'first-tool', + toolCallId: toolCallId1_2, + status: 'completed', + updates: [], + }, + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'second-tool', + toolCallId: toolCallId2, + status: 'completed', + updates: [], + }, + { + id: faker.string.uuid(), + role: 'assistant' as const, + type: 'tool' as const, + toolName: 'third-tool', + toolCallId: toolCallId3, + status: 'completed', + updates: [], + }, + ]; + + builderStore.$patch({ streaming: false }); + await flushPromises(); + + expect(trackMock).toHaveBeenCalledOnce(); + expect(trackMock).toHaveBeenCalledWith('Workflow modified by builder', { + end_workflow_json: JSON.stringify(updatedWorkflow2), + session_id: 'app_session_id', + start_workflow_json: JSON.stringify(originalWorkflow), + // first-tool is ignored, because it was tracked in first run (same tool call id) + tools_called: ['second-tool', 'third-tool'], + workflow_id: 'abc123', + }); + }); }); diff --git a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue index 0c418a837c..c43f3e448c 100644 --- a/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue +++ b/packages/frontend/editor-ui/src/components/AskAssistant/Agent/AskAssistantBuild.vue @@ -33,6 +33,7 @@ const processedWorkflowUpdates = ref(new Set()); const trackedTools = ref(new Set()); const planStatus = ref<'pending' | 'approved' | 'rejected'>(); const assistantChatRef = ref | null>(null); +const workflowUpdated = ref<{ start: string; end: string } | undefined>(); const user = computed(() => ({ firstName: usersStore.currentUser?.firstName ?? '', @@ -60,6 +61,7 @@ function onNewWorkflow() { builderStore.resetBuilderChat(); processedWorkflowUpdates.value.clear(); trackedTools.value.clear(); + workflowUpdated.value = undefined; } function onFeedback(feedback: RatingFeedback) { @@ -113,6 +115,33 @@ function shouldShowPlanControls(message: NodesPlanMessageType) { return planMessageIndex === builderStore.chatMessages.length - 1; } +function dedupeToolNames(toolNames: string[]): string[] { + return [...new Set(toolNames)]; +} + +function trackWorkflowModifications() { + if (workflowUpdated.value) { + // Track tool usage for telemetry + const newToolMessages = builderStore.toolMessages.filter( + (toolMsg) => + toolMsg.status !== 'running' && + toolMsg.toolCallId && + !trackedTools.value.has(toolMsg.toolCallId), + ); + + newToolMessages.forEach((toolMsg) => trackedTools.value.add(toolMsg.toolCallId ?? '')); + telemetry.track('Workflow modified by builder', { + tools_called: dedupeToolNames(newToolMessages.map((toolMsg) => toolMsg.toolName)), + session_id: builderStore.trackingSessionId, + start_workflow_json: workflowUpdated.value.start, + end_workflow_json: workflowUpdated.value.end, + workflow_id: workflowsStore.workflowId, + }); + + workflowUpdated.value = undefined; + } +} + // Watch for workflow updates and apply them watch( () => builderStore.workflowMessages, @@ -125,7 +154,8 @@ watch( if (msg.id && isWorkflowUpdatedMessage(msg)) { processedWorkflowUpdates.value.add(msg.id); - const currentWorkflowJson = builderStore.getWorkflowSnapshot(); + const originalWorkflowJson = + workflowUpdated.value?.start ?? builderStore.getWorkflowSnapshot(); const result = builderStore.applyWorkflowUpdate(msg.codeSnippet); if (result.success) { @@ -135,25 +165,13 @@ watch( tidyUp: true, nodesIdsToTidyUp: result.newNodeIds, regenerateIds: false, + trackEvents: false, }); - // Track tool usage for telemetry - const newToolMessages = builderStore.toolMessages.filter( - (toolMsg) => - toolMsg.status !== 'running' && - toolMsg.toolCallId && - !trackedTools.value.has(toolMsg.toolCallId), - ); - - newToolMessages.forEach((toolMsg) => trackedTools.value.add(toolMsg.toolCallId ?? '')); - - telemetry.track('Workflow modified by builder', { - tools_called: newToolMessages.map((toolMsg) => toolMsg.toolName), - session_id: builderStore.trackingSessionId, - start_workflow_json: currentWorkflowJson, - end_workflow_json: msg.codeSnippet, - workflow_id: workflowsStore.workflowId, - }); + workflowUpdated.value = { + start: originalWorkflowJson, + end: msg.codeSnippet, + }; } } }); @@ -171,10 +189,14 @@ watch( // we want to save the workflow watch( () => builderStore.streaming, - async () => { + async (isStreaming) => { + if (!isStreaming) { + trackWorkflowModifications(); + } + if ( builderStore.initialGeneration && - !builderStore.streaming && + !isStreaming && workflowsStore.workflow.nodes.length > 0 ) { // Check if the generation completed successfully (no error or cancellation) diff --git a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue index a73994a363..4d2d5153a2 100644 --- a/packages/frontend/editor-ui/src/components/canvas/Canvas.vue +++ b/packages/frontend/editor-ui/src/components/canvas/Canvas.vue @@ -1,6 +1,6 @@