diff --git a/cypress/e2e/45-ai-assistant.cy.ts b/cypress/e2e/45-ai-assistant.cy.ts
new file mode 100644
index 0000000000..4431007df0
--- /dev/null
+++ b/cypress/e2e/45-ai-assistant.cy.ts
@@ -0,0 +1,247 @@
+import { NDV, WorkflowPage } from '../pages';
+import { AIAssistant } from '../pages/features/ai-assistant';
+
+const wf = new WorkflowPage();
+const ndv = new NDV();
+const aiAssistant = new AIAssistant();
+
+describe('AI Assistant::disabled', () => {
+ beforeEach(() => {
+ aiAssistant.actions.disableAssistant();
+ wf.actions.visit();
+ });
+
+ it('does not show assistant button if feature is disabled', () => {
+ aiAssistant.getters.askAssistantFloatingButton().should('not.exist');
+ });
+});
+
+describe('AI Assistant::enabled', () => {
+ beforeEach(() => {
+ aiAssistant.actions.enableAssistant();
+ wf.actions.visit();
+ });
+
+ after(() => {
+ aiAssistant.actions.disableAssistant();
+ });
+
+ it('renders placeholder UI', () => {
+ aiAssistant.getters.askAssistantFloatingButton().should('be.visible');
+ aiAssistant.getters.askAssistantFloatingButton().click();
+ aiAssistant.getters.askAssistantChat().should('be.visible');
+ aiAssistant.getters.placeholderMessage().should('be.visible');
+ aiAssistant.getters.chatInputWrapper().should('not.exist');
+ aiAssistant.getters.closeChatButton().should('be.visible');
+ aiAssistant.getters.closeChatButton().click();
+ aiAssistant.getters.askAssistantChat().should('not.exist');
+ });
+
+ it('should resize assistant chat up', () => {
+ aiAssistant.getters.askAssistantFloatingButton().click();
+ aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
+ aiAssistant.getters.askAssistantChat().then((element) => {
+ const { width, left } = element[0].getBoundingClientRect();
+ cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left - 10, 0], {
+ abs: true,
+ clickToFinish: true,
+ });
+ aiAssistant.getters.askAssistantChat().then((newElement) => {
+ const newWidth = newElement[0].getBoundingClientRect().width;
+ expect(newWidth).to.be.greaterThan(width);
+ });
+ });
+ });
+
+ it('should resize assistant chat down', () => {
+ aiAssistant.getters.askAssistantFloatingButton().click();
+ aiAssistant.getters.askAssistantSidebarResizer().should('be.visible');
+ aiAssistant.getters.askAssistantChat().then((element) => {
+ const { width, left } = element[0].getBoundingClientRect();
+ cy.drag(aiAssistant.getters.askAssistantSidebarResizer(), [left + 10, 0], {
+ abs: true,
+ clickToFinish: true,
+ });
+ aiAssistant.getters.askAssistantChat().then((newElement) => {
+ const newWidth = newElement[0].getBoundingClientRect().width;
+ expect(newWidth).to.be.lessThan(width);
+ });
+ });
+ });
+
+ it('should start chat session from node error view', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesAll().should('have.length', 1);
+ aiAssistant.getters
+ .chatMessagesAll()
+ .eq(0)
+ .should('contain.text', 'Hey, this is an assistant message');
+ aiAssistant.getters.nodeErrorViewAssistantButton().should('be.disabled');
+ });
+
+ it('should render chat input correctly', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ // Send button should be disabled when input is empty
+ aiAssistant.getters.sendMessageButton().should('be.disabled');
+ aiAssistant.getters.chatInput().type('Yo ');
+ aiAssistant.getters.sendMessageButton().should('not.be.disabled');
+ aiAssistant.getters.chatInput().then((element) => {
+ const { height } = element[0].getBoundingClientRect();
+ // Shift + Enter should add a new line
+ aiAssistant.getters.chatInput().type('Hello{shift+enter}there');
+ aiAssistant.getters.chatInput().then((newElement) => {
+ const newHeight = newElement[0].getBoundingClientRect().height;
+ // Chat input should grow as user adds new lines
+ expect(newHeight).to.be.greaterThan(height);
+ aiAssistant.getters.sendMessageButton().click();
+ cy.wait('@chatRequest');
+ // New lines should be rendered as
in the chat
+ aiAssistant.getters.chatMessagesUser().should('have.length', 1);
+ aiAssistant.getters.chatMessagesUser().eq(0).find('br').should('have.length', 1);
+ // Chat input should be cleared now
+ aiAssistant.getters.chatInput().should('have.value', '');
+ });
+ });
+ });
+
+ it('should render and handle quick replies', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/quick_reply_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.quickReplies().should('have.length', 2);
+ aiAssistant.getters.quickReplies().eq(0).click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesUser().should('have.length', 1);
+ aiAssistant.getters.chatMessagesUser().eq(0).should('contain.text', "Sure, let's do it");
+ });
+
+ it('should send message to assistant when node is executed', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Edit Fields');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesAssistant().should('have.length', 1);
+ // Executing the same node should sende a new message to the assistant automatically
+ ndv.getters.nodeExecuteButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesAssistant().should('have.length', 2);
+ });
+
+ it('should warn before starting a new session', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/simple_message_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Edit Fields');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.closeChatButton().click();
+ ndv.getters.backToCanvas().click();
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ // Since we already have an active session, a warning should be shown
+ aiAssistant.getters.newAssistantSessionModal().should('be.visible');
+ aiAssistant.getters
+ .newAssistantSessionModal()
+ .find('button')
+ .contains('Start new session')
+ .click();
+ cy.wait('@chatRequest');
+ // New session should start with initial assistant message
+ aiAssistant.getters.chatMessagesAll().should('have.length', 1);
+ });
+
+ it('should apply code diff to code node', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/code_diff_suggestion_response.json',
+ }).as('chatRequest');
+ cy.intercept('POST', '/rest/ai-assistant/chat/apply-suggestion', {
+ statusCode: 200,
+ fixture: 'aiAssistant/apply_code_diff_response.json',
+ }).as('applySuggestion');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Code');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click({ force: true });
+ cy.wait('@chatRequest');
+ // Should have two assistant messages
+ aiAssistant.getters.chatMessagesAll().should('have.length', 2);
+ aiAssistant.getters.codeDiffs().should('have.length', 1);
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
+ aiAssistant.getters.applyCodeDiffButtons().first().click();
+ cy.wait('@applySuggestion');
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 0);
+ aiAssistant.getters.undoReplaceCodeButtons().should('have.length', 1);
+ aiAssistant.getters.codeReplacedMessage().should('be.visible');
+ ndv.getters
+ .parameterInput('jsCode')
+ .get('.cm-content')
+ .should('contain.text', 'item.json.myNewField = 1');
+ // Clicking undo should revert the code back but not call the assistant
+ aiAssistant.getters.undoReplaceCodeButtons().first().click();
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
+ aiAssistant.getters.codeReplacedMessage().should('not.exist');
+ cy.get('@applySuggestion.all').then((interceptions) => {
+ expect(interceptions).to.have.length(1);
+ });
+ ndv.getters
+ .parameterInput('jsCode')
+ .get('.cm-content')
+ .should('contain.text', 'item.json.myNewField = 1aaa');
+ // Replacing the code again should also not call the assistant
+ cy.get('@applySuggestion.all').then((interceptions) => {
+ expect(interceptions).to.have.length(1);
+ });
+ aiAssistant.getters.applyCodeDiffButtons().should('have.length', 1);
+ aiAssistant.getters.applyCodeDiffButtons().first().click();
+ ndv.getters
+ .parameterInput('jsCode')
+ .get('.cm-content')
+ .should('contain.text', 'item.json.myNewField = 1');
+ });
+
+ it('should end chat session when `end_session` event is received', () => {
+ cy.intercept('POST', '/rest/ai-assistant/chat', {
+ statusCode: 200,
+ fixture: 'aiAssistant/end_session_response.json',
+ }).as('chatRequest');
+ cy.createFixtureWorkflow('aiAssistant/test_workflow.json');
+ wf.actions.openNode('Stop and Error');
+ ndv.getters.nodeExecuteButton().click();
+ aiAssistant.getters.nodeErrorViewAssistantButton().click();
+ cy.wait('@chatRequest');
+ aiAssistant.getters.chatMessagesSystem().should('have.length', 1);
+ aiAssistant.getters.chatMessagesSystem().first().should('contain.text', 'session has ended');
+ });
+});
diff --git a/cypress/fixtures/aiAssistant/apply_code_diff_response.json b/cypress/fixtures/aiAssistant/apply_code_diff_response.json
new file mode 100644
index 0000000000..8d7ada0b40
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/apply_code_diff_response.json
@@ -0,0 +1,8 @@
+{
+ "data": {
+ "sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-emTezIGat7bQsDdtIlbti",
+ "parameters": {
+ "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1;\n}\n\nreturn $input.all();"
+ }
+ }
+}
diff --git a/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json b/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json
new file mode 100644
index 0000000000..8ee5d647fd
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/code_diff_suggestion_response.json
@@ -0,0 +1,23 @@
+{
+ "sessionId": "1",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "message",
+ "text": "Hi there! Here is my top solution to fix the error in your **Code** node 👇"
+ },
+ {
+ "type": "code-diff",
+ "description": "Fix the syntax error by changing '1asd' to a valid value. In this case, it seems like '1' was intended.",
+ "suggestionId": "1",
+ "codeDiff": "@@ -2,2 +2,2 @@\n item.json.myNewField = 1asd;\n+ item.json.myNewField = 1;\n",
+ "role": "assistant",
+ "quickReplies": [
+ {
+ "text": "Give me another solution",
+ "type": "new-suggestion"
+ }
+ ]
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/end_session_response.json b/cypress/fixtures/aiAssistant/end_session_response.json
new file mode 100644
index 0000000000..c53574d93a
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/end_session_response.json
@@ -0,0 +1,16 @@
+{
+ "sessionId": "f9130bd7-c078-4862-a38a-369b27b0ff20-e96eb9f7-d581-4684-b6a9-fd3dfe9fe1fb-XCldJLlusGrEVku5I9cYT",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "agent-suggestion",
+ "title": "Glad to Help",
+ "text": "I'm glad I could help. If you have any more questions or need further assistance with your n8n workflows, feel free to ask!"
+ },
+ {
+ "role": "assistant",
+ "type": "event",
+ "eventName": "end-session"
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/quick_reply_message_response.json b/cypress/fixtures/aiAssistant/quick_reply_message_response.json
new file mode 100644
index 0000000000..a3c1b958c4
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/quick_reply_message_response.json
@@ -0,0 +1,20 @@
+{
+ "sessionId": "1",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "message",
+ "text": "Hey, this is an assistant message",
+ "quickReplies": [
+ {
+ "text": "Sure, let's do it",
+ "type": "yes"
+ },
+ {
+ "text": "Nah, doesn't sound good",
+ "type": "no"
+ }
+ ]
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/simple_message_response.json b/cypress/fixtures/aiAssistant/simple_message_response.json
new file mode 100644
index 0000000000..11299b91f9
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/simple_message_response.json
@@ -0,0 +1,10 @@
+{
+ "sessionId": "1",
+ "messages": [
+ {
+ "role": "assistant",
+ "type": "message",
+ "text": "Hey, this is an assistant message"
+ }
+ ]
+}
diff --git a/cypress/fixtures/aiAssistant/test_workflow.json b/cypress/fixtures/aiAssistant/test_workflow.json
new file mode 100644
index 0000000000..da930ea489
--- /dev/null
+++ b/cypress/fixtures/aiAssistant/test_workflow.json
@@ -0,0 +1,88 @@
+{
+ "nodes": [
+ {
+ "parameters": {},
+ "id": "ebfced75-2ce1-4c41-a971-6c3b83522c4d",
+ "name": "When clicking ‘Test workflow’",
+ "type": "n8n-nodes-base.manualTrigger",
+ "typeVersion": 1,
+ "position": [
+ 360,
+ 220
+ ]
+ },
+ {
+ "parameters": {
+ "errorMessage": "This is an error message"
+ },
+ "id": "f2e60459-401a-49d5-acfc-7b2b31cfdcf7",
+ "name": "Stop and Error",
+ "type": "n8n-nodes-base.stopAndError",
+ "typeVersion": 1,
+ "position": [
+ 1020,
+ 220
+ ]
+ },
+ {
+ "parameters": {
+ "jsCode": "// Loop over input items and add a new field called 'myNewField' to the JSON of each one\nfor (const item of $input.all()) {\n item.json.myNewField = 1aaa;\n}\n\nreturn $input.all();"
+ },
+ "id": "b54d4db9-b257-41a8-862f-26d293115bad",
+ "name": "Code",
+ "type": "n8n-nodes-base.code",
+ "typeVersion": 2,
+ "position": [
+ 840,
+ 320
+ ]
+ },
+ {
+ "parameters": {
+ "assignments": {
+ "assignments": [
+ {
+ "id": "053ada73-f7db-4e6a-8cc8-85756cc6ca4e",
+ "name": "age",
+ "value": "={{ 32sad }}",
+ "type": "number"
+ }
+ ]
+ },
+ "options": {}
+ },
+ "id": "5fd89612-a871-4679-b7b0-d659e09c6a0e",
+ "name": "Edit Fields",
+ "type": "n8n-nodes-base.set",
+ "typeVersion": 3.4,
+ "position": [
+ 600,
+ 100
+ ]
+ }
+ ],
+ "connections": {
+ "When clicking ‘Test workflow’": {
+ "main": [
+ [
+ {
+ "node": "Stop and Error",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Code",
+ "type": "main",
+ "index": 0
+ },
+ {
+ "node": "Edit Fields",
+ "type": "main",
+ "index": 0
+ }
+ ]
+ ]
+ }
+ },
+ "pinData": {}
+}
diff --git a/cypress/pages/features/ai-assistant.ts b/cypress/pages/features/ai-assistant.ts
new file mode 100644
index 0000000000..abca07fbbe
--- /dev/null
+++ b/cypress/pages/features/ai-assistant.ts
@@ -0,0 +1,49 @@
+import { overrideFeatureFlag } from '../../composables/featureFlags';
+import { BasePage } from '../base';
+
+const AI_ASSISTANT_FEATURE = {
+ name: 'aiAssistant',
+ experimentName: '021_ai_debug_helper',
+ enabledFor: 'variant',
+ disabledFor: 'control',
+};
+
+export class AIAssistant extends BasePage {
+ url = '/workflows/new';
+
+ getters = {
+ askAssistantFloatingButton: () => cy.getByTestId('ask-assistant-floating-button'),
+ askAssistantSidebar: () => cy.getByTestId('ask-assistant-sidebar'),
+ askAssistantSidebarResizer: () =>
+ this.getters.askAssistantSidebar().find('[class^=_resizer][data-dir=left]').first(),
+ askAssistantChat: () => cy.getByTestId('ask-assistant-chat'),
+ placeholderMessage: () => cy.getByTestId('placeholder-message'),
+ closeChatButton: () => cy.getByTestId('close-chat-button'),
+ chatInputWrapper: () => cy.getByTestId('chat-input-wrapper'),
+ chatInput: () => cy.getByTestId('chat-input'),
+ sendMessageButton: () => cy.getByTestId('send-message-button'),
+ chatMessagesAll: () => cy.get('[data-test-id^=chat-message]'),
+ chatMessagesAssistant: () => cy.getByTestId('chat-message-assistant'),
+ chatMessagesUser: () => cy.getByTestId('chat-message-user'),
+ chatMessagesSystem: () => cy.getByTestId('chat-message-system'),
+ quickReplies: () => cy.getByTestId('quick-replies').find('button'),
+ newAssistantSessionModal: () => cy.getByTestId('new-assistant-session-modal'),
+ codeDiffs: () => cy.getByTestId('code-diff-suggestion'),
+ applyCodeDiffButtons: () => cy.getByTestId('replace-code-button'),
+ undoReplaceCodeButtons: () => cy.getByTestId('undo-replace-button'),
+ codeReplacedMessage: () => cy.getByTestId('code-replaced-message'),
+ nodeErrorViewAssistantButton: () =>
+ cy.getByTestId('node-error-view-ask-assistant-button').find('button').first(),
+ };
+
+ actions = {
+ enableAssistant(): void {
+ overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.enabledFor);
+ cy.enableFeature(AI_ASSISTANT_FEATURE.name);
+ },
+ disableAssistant(): void {
+ overrideFeatureFlag(AI_ASSISTANT_FEATURE.experimentName, AI_ASSISTANT_FEATURE.disabledFor);
+ cy.disableFeature(AI_ASSISTANT_FEATURE.name);
+ },
+ };
+}
diff --git a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
index 30bdf81351..ff7ba2147b 100644
--- a/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
+++ b/packages/design-system/src/components/AskAssistantChat/AskAssistantChat.vue
@@ -102,13 +102,20 @@ function growInput() {
@@ -232,6 +247,7 @@ function growInput() {