From 96268910d35112cb02363aef9ad184036d4b2dfd Mon Sep 17 00:00:00 2001 From: Anne Aguirre Date: Thu, 23 Jul 2026 12:31:07 +0000 Subject: [PATCH] feat(ai-builder): Add Fix with Assistant for failed agent preview tools - pass single execution context (#34616) Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> --- packages/@n8n/api-types/src/agent-sse.ts | 2 +- packages/@n8n/api-types/src/agents/types.ts | 2 + .../__tests__/agent-chat.controller.test.ts | 79 +++++- ...ent-execution-orchestrator.service.test.ts | 49 +++- .../execution-to-message-mapper.test.ts | 7 + .../modules/agents/agent-chat.controller.ts | 12 +- .../agent-execution-orchestrator.service.ts | 82 ++++-- .../utils/execution-to-message-mapper.ts | 13 +- .../N8nAiActivityStep/AiActivityStep.test.ts | 25 ++ .../N8nAiActivityStep/AiActivityStep.vue | 9 +- .../frontend/@n8n/i18n/src/locales/en.json | 2 + .../agents/__tests__/AgentBuilderView.test.ts | 21 ++ .../__tests__/AgentChatMessageList.test.ts | 47 +++- .../__tests__/AgentChatToolSteps.test.ts | 106 +++++++- .../__tests__/agentChatMessages.test.ts | 25 ++ .../__tests__/useAgentChatStream.test.ts | 19 ++ .../components/AgentChatMessageList.vue | 13 +- .../agents/components/AgentChatPanel.vue | 4 +- .../agents/components/AgentChatToolSteps.vue | 233 ++++++++++++------ .../components/AgentPreviewChatPage.vue | 4 +- .../agents/composables/useAgentChatStream.ts | 5 + .../agents/views/AgentBuilderView.vue | 3 +- .../useInstanceAiAgentPreviewHandoff.test.ts | 38 +++ .../__tests__/useInstanceAiHandoff.test.ts | 15 ++ .../useInstanceAiAgentPreviewHandoff.ts | 4 + .../composables/useInstanceAiHandoff.ts | 2 + .../__tests__/displayGroups.test.ts | 107 ++++++++ .../ai/shared/agentsChat/displayGroups.ts | 33 ++- .../ai/shared/agentsChat/messageMappers.ts | 1 + .../features/ai/shared/agentsChat/types.ts | 2 + 30 files changed, 841 insertions(+), 123 deletions(-) diff --git a/packages/@n8n/api-types/src/agent-sse.ts b/packages/@n8n/api-types/src/agent-sse.ts index cd9e6adfe15..323748bb0fe 100644 --- a/packages/@n8n/api-types/src/agent-sse.ts +++ b/packages/@n8n/api-types/src/agent-sse.ts @@ -105,4 +105,4 @@ export type AgentSseEvent = /** Backend-emitted ids of the missing config slots; only set when `errorCode` is `agent_misconfigured`. */ missing?: string[]; } - | { type: 'done'; sessionId?: string }; + | { type: 'done'; sessionId?: string; executionId?: string }; diff --git a/packages/@n8n/api-types/src/agents/types.ts b/packages/@n8n/api-types/src/agents/types.ts index fe862276b21..2e4caca7296 100644 --- a/packages/@n8n/api-types/src/agents/types.ts +++ b/packages/@n8n/api-types/src/agents/types.ts @@ -210,6 +210,8 @@ export interface AgentPersistedMessageDto { id: string; role: 'user' | 'assistant' | (string & {}); content: AgentPersistedMessageContentPart[]; + /** Agent-execution turn id when this message was produced from an execution transcript. */ + executionId?: string; } export const AGENT_BUILDER_DEFAULT_MODEL = 'claude-sonnet-4-6' as const; diff --git a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts index 20c7d1eb036..9f9448d4900 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-chat.controller.test.ts @@ -7,6 +7,7 @@ import { NotFoundError } from '@/errors/response-errors/not-found.error'; import { AgentChatController } from '../agent-chat.controller'; import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service'; import type { AgentExecutionService } from '../agent-execution.service'; +import type { FlushableResponse } from '../agent-sse-stream'; import type { AgentTestChatService } from '../agent-test-chat.service'; import type { AgentsService } from '../agents.service'; import type { AgentValidationService } from '../agent-validation.service'; @@ -21,18 +22,25 @@ function makeController() { mock>(); const agentExecutionOrchestratorService = mock(); const agentsBuilderService = mock(); + const agentValidationService = mock(); + const agentExecutionService = mock(); + agentValidationService.validateAgentIsRunnable.mockResolvedValue({ missing: [] }); + const controller = new AgentChatController( agentExecutionOrchestratorService, mock(), - mock(), + agentValidationService, agentsBuilderService, mock(), - mock(), + agentExecutionService, agentsService as unknown as AgentsService, ); return { controller, + agentExecutionOrchestratorService, + agentValidationService, + agentExecutionService, agentsService: { findById: agentsService.findById, getConversationHistory: agentExecutionOrchestratorService.getConversationHistory, @@ -114,3 +122,70 @@ describe('AgentChatController chat message history', () => { ).rejects.toThrow(NotFoundError); }); }); + +describe('AgentChatController SSE done payload', () => { + it('includes executionId on done when recorded', async () => { + const { controller, agentExecutionOrchestratorService, agentExecutionService } = + makeController(); + agentExecutionService.findThreadById.mockResolvedValue(null); + agentExecutionOrchestratorService.executeForChat.mockImplementation(async function* (config) { + config.onExecutionRecorded?.('exec-99'); + yield* []; + }); + + const writes: string[] = []; + const res = mock(); + res.write.mockImplementation((chunk: string) => { + writes.push(String(chunk)); + return true; + }); + + await controller.chat( + { params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never, + res, + 'agent-1', + { message: 'hi', sessionId: 'thread-1' } as never, + ); + + const events = writes + .filter((line) => line.startsWith('data: ')) + .map((line) => JSON.parse(line.slice(6).trim()) as { type: string }); + + expect(events).toContainEqual({ + type: 'done', + sessionId: 'thread-1', + executionId: 'exec-99', + }); + }); + + it('includes executionId on resume done when recorded', async () => { + const { controller, agentExecutionOrchestratorService } = makeController(); + agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* (config) { + config.onExecutionRecorded?.('exec-resume-1'); + yield* []; + }); + + const writes: string[] = []; + const res = mock(); + res.write.mockImplementation((chunk: string) => { + writes.push(String(chunk)); + return true; + }); + + await controller.chatResume( + { params: { projectId: 'project-1' }, user: { id: 'user-1' } } as never, + res, + 'agent-1', + { runId: 'run-1', toolCallId: 'tc-1', resumeData: { approved: true } } as never, + ); + + const events = writes + .filter((line) => line.startsWith('data: ')) + .map((line) => JSON.parse(line.slice(6).trim()) as { type: string }); + + expect(events).toContainEqual({ + type: 'done', + executionId: 'exec-resume-1', + }); + }); +}); diff --git a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts index b51f9a81e43..38458d42371 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-execution-orchestrator.service.test.ts @@ -174,6 +174,47 @@ describe('AgentExecutionOrchestratorService', () => { ); }); + it('awaits recordMessage and notifies onExecutionRecorded with the returned id', async () => { + const { service, executionService } = makeService(); + const runtime = makeRuntime([{ type: 'finish', finishReason: 'stop' }]); + const onExecutionRecorded = vi.fn(); + + await collect( + service.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + userId, + message: 'hello', + memory: { threadId: 'thread-1', resourceId: 'resource-1' }, + projectId, + onExecutionRecorded, + }), + ); + + expect(executionService.recordMessage).toHaveBeenCalled(); + expect(onExecutionRecorded).toHaveBeenCalledWith('execution-1'); + }); + + it('still records the message when onExecutionRecorded is omitted', async () => { + const { service, executionService } = makeService(); + const runtime = makeRuntime([{ type: 'finish', finishReason: 'stop' }]); + + await collect( + service.streamChatResponse({ + agentInstance: runtime.agent, + toolRegistry: runtime.toolRegistry, + agentId, + userId, + message: 'hello', + memory: { threadId: 'thread-1', resourceId: 'resource-1' }, + projectId, + }), + ); + + expect(executionService.recordMessage).toHaveBeenCalled(); + }); + it('executes in-app chat against the draft runtime', async () => { const { service, @@ -460,9 +501,15 @@ describe('AgentExecutionOrchestratorService', () => { await expect( service.getConversationHistory({ threadId: 'thread-1', projectId, agentId }), ).resolves.toEqual([ - { id: 'execution-1:user', role: 'user', content: [{ type: 'text', text: 'Hi' }] }, + { + id: 'execution-1:user', + executionId: 'execution-1', + role: 'user', + content: [{ type: 'text', text: 'Hi' }], + }, { id: 'execution-1:assistant', + executionId: 'execution-1', role: 'assistant', content: [{ type: 'text', text: 'Hello' }], }, diff --git a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts index a54a9f44468..744aac084e1 100644 --- a/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts +++ b/packages/cli/src/modules/agents/__tests__/execution-to-message-mapper.test.ts @@ -42,6 +42,7 @@ describe('execution-to-message-mapper', () => { id: 'execution-1:user', role: 'user', content: [{ type: 'text', text: 'Hello' }], + executionId: 'execution-1', }, { id: 'execution-1:assistant', @@ -60,6 +61,7 @@ describe('execution-to-message-mapper', () => { }, { type: 'text', text: 'Done.' }, ], + executionId: 'execution-1', }, ]); }); @@ -88,6 +90,7 @@ describe('execution-to-message-mapper', () => { id: 'execution-1:user', role: 'user', content: [{ type: 'text', text: 'Hello' }], + executionId: 'execution-1', }, { id: 'execution-1:assistant', @@ -104,6 +107,7 @@ describe('execution-to-message-mapper', () => { error: 'Tool failed', }, ], + executionId: 'execution-1', }, ]); }); @@ -185,6 +189,7 @@ describe('execution-to-message-mapper', () => { id: 'execution-suspended:user', role: 'user', content: [{ type: 'text', text: 'Show me an action' }], + executionId: 'execution-suspended', }, { id: 'execution-suspended:assistant', @@ -212,11 +217,13 @@ describe('execution-to-message-mapper', () => { output: { type: 'button', value: 'approve' }, }, ], + executionId: 'execution-suspended', }, { id: 'execution-resumed:assistant', role: 'assistant', content: [{ type: 'text', text: 'Approved.' }], + executionId: 'execution-resumed', }, ]); }); diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index 7eebf8c4204..efab0d247c1 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -86,6 +86,7 @@ export class AgentChatController { } try { + let executionId: string | undefined; const suspended = await pumpChunks( this.agentExecutionOrchestratorService.executeForChat({ agentId, @@ -96,11 +97,14 @@ export class AgentChatController { threadId, resourceId: draftChatMemoryResourceId(req.user.id), }, + onExecutionRecorded: (id) => { + executionId = id; + }, }), send, ); if (!suspended) { - send({ type: 'done', sessionId: threadId }); + send({ type: 'done', sessionId: threadId, ...(executionId ? { executionId } : {}) }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Chat failed'; @@ -123,6 +127,7 @@ export class AgentChatController { const { send } = initSseStream(res); try { + let executionId: string | undefined; const suspended = await pumpChunks( this.agentExecutionOrchestratorService.resumeForChat({ agentId, @@ -133,11 +138,14 @@ export class AgentChatController { user: req.user, usePublishedVersion: false, integrationType: N8N_CHAT_INTEGRATION_TYPE, + onExecutionRecorded: (id) => { + executionId = id; + }, }), send, ); if (!suspended) { - send({ type: 'done' }); + send({ type: 'done', ...(executionId ? { executionId } : {}) }); } } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Resume failed'; diff --git a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts index 43fed2de070..34a7fefa3b1 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -7,7 +7,9 @@ import { Service } from '@n8n/di'; import { UserError } from 'n8n-workflow'; import { ExternalHooks } from '@/external-hooks'; -import { AgentExecutionService } from './agent-execution.service'; +import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces'; +import { Telemetry } from '@/telemetry'; +import { AgentExecutionService, type RecordMessageParams } from './agent-execution.service'; import { AgentRunTracingService, modelIdFromSnapshot } from './agent-run-tracing.service'; import { AgentRuntimeCacheService } from './agent-runtime-cache.service'; import { ExecutionRecorder } from './execution-recorder'; @@ -18,9 +20,6 @@ import { createAgentExecutionCounter } from './utils/agent-execution-counter'; import { streamAgentChunks } from './utils/agent-stream'; import { executionsToMessagesDto } from './utils/execution-to-message-mapper'; -import type { AgentRunTelemetryType, IAgentConfigurationTelemetryProperties } from '@/interfaces'; -import { Telemetry } from '@/telemetry'; - export interface AgentMemoryScope { threadId: string; resourceId: string; @@ -39,6 +38,8 @@ export interface ExecuteForChatConfig { user: User; /** Memory scope — resourceId is the chat platform user (e.g. Slack / Telegram user ID). */ memory: AgentMemoryScope; + /** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */ + onExecutionRecorded?: (executionId: string) => void; } export interface ExecuteForChatPublishedConfig { @@ -78,6 +79,8 @@ export interface ResumeForChatConfig { * persisted tool call references a tool the rebuilt runtime doesn't know. */ integrationType?: string; + /** Fired after the resumed turn is persisted; used to attach `executionId` to SSE `done`. */ + onExecutionRecorded?: (executionId: string) => void; } export interface ExecuteForTaskPublishedConfig { @@ -129,6 +132,8 @@ export interface StreamChatResponseConfig { runType: AgentRunTelemetryType; configuration: IAgentConfigurationTelemetryProperties; }; + /** Fired after the turn is persisted; used to attach `executionId` to SSE `done`. */ + onExecutionRecorded?: (executionId: string) => void; } function getMaxIterationsChunks(): StreamChunk[] { @@ -197,6 +202,7 @@ export class AgentExecutionOrchestratorService { integrationType, user, usePublishedVersion = true, + onExecutionRecorded, } = config; const checkpointStatus = await this.n8nCheckpointStorage.getStatus(runId); @@ -275,8 +281,10 @@ export class AgentExecutionOrchestratorService { // or fail while streaming. Don't repeat the original user message — the // pre-suspension execution already has it. const messageRecord = recorder.getMessageRecord(); - void this.agentExecutionService - .recordMessage({ + await this.persistRecordedExecution({ + onExecutionRecorded, + failureMessage: 'Failed to record resumed agent execution', + params: { threadId, agentId, agentName: agentInstance.name, @@ -288,14 +296,8 @@ export class AgentExecutionOrchestratorService { runType, configuration: runtime.telemetryConfiguration, }, - }) - .catch((error) => { - this.logger.warn('Failed to record resumed agent execution', { - agentId, - threadId, - error: error instanceof Error ? error.message : String(error), - }); - }); + }, + }); } } @@ -303,7 +305,7 @@ export class AgentExecutionOrchestratorService { * Execute an agent for the in-app test chat and yield stream chunks. */ async *executeForChat(config: ExecuteForChatConfig): AsyncGenerator { - const { agentId, projectId, message, user, memory } = config; + const { agentId, projectId, message, user, memory, onExecutionRecorded } = config; // `user` is always set (see ExecuteForChatConfig) — this builds/reuses a // runtime scoped to this specific user's tool access. @@ -334,6 +336,7 @@ export class AgentExecutionOrchestratorService { runType: 'test', configuration: runtime.telemetryConfiguration, }, + onExecutionRecorded, }); } @@ -459,6 +462,7 @@ export class AgentExecutionOrchestratorService { taskId, taskVersionId, telemetry, + onExecutionRecorded, } = config; const { threadId, resourceId } = memory; @@ -505,8 +509,10 @@ export class AgentExecutionOrchestratorService { // Always record — even if suspended or failed, the pre-suspension/error // response text and tool calls are valuable. const messageRecord = recorder.getMessageRecord(); - void this.agentExecutionService - .recordMessage({ + await this.persistRecordedExecution({ + onExecutionRecorded, + failureMessage: 'Failed to record agent execution', + params: { threadId, agentId, agentName: agentInstance.name, @@ -518,14 +524,40 @@ export class AgentExecutionOrchestratorService { taskId, taskVersionId, telemetry, - }) - .catch((error) => { - this.logger.warn('Failed to record agent execution', { - agentId, - threadId, - error: error instanceof Error ? error.message : String(error), - }); - }); + }, + }); + } + } + + /** + * Persist a streamed turn. Awaits when `onExecutionRecorded` is set so SSE + * `done` can carry executionId; otherwise fire-and-forget. + */ + private async persistRecordedExecution(args: { + onExecutionRecorded?: (executionId: string) => void; + params: RecordMessageParams; + failureMessage: string; + }): Promise { + const { onExecutionRecorded, params, failureMessage } = args; + const persist = async () => { + const executionId = await this.agentExecutionService.recordMessage(params); + onExecutionRecorded?.(executionId); + }; + const logFailure = (error: unknown) => { + this.logger.warn(failureMessage, { + agentId: params.agentId, + threadId: params.threadId, + error: error instanceof Error ? error.message : String(error), + }); + }; + if (onExecutionRecorded) { + try { + await persist(); + } catch (error) { + logFailure(error); + } + } else { + void persist().catch(logFailure); } } } diff --git a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts index 4634ceb5b39..fc2ee2ba818 100644 --- a/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts +++ b/packages/cli/src/modules/agents/utils/execution-to-message-mapper.ts @@ -21,6 +21,7 @@ function textMessageDto( id: string, role: AgentPersistedMessageDto['role'], text: string | null, + executionId: string, ): AgentPersistedMessageDto | null { if (!text) return null; const contentPart = textPart(text); @@ -30,6 +31,7 @@ function textMessageDto( id, role, content: [contentPart], + executionId, }; } @@ -135,7 +137,15 @@ function assistantContentFromExecution( export function executionToMessagesDto(execution: ExecutionTranscript): AgentPersistedMessageDto[] { const messages: AgentPersistedMessageDto[] = []; - const userMessage = textMessageDto(`${execution.id}:user`, 'user', execution.userMessage); + // Message `id` stays `${execution.id}:role` for stable client keys. Turn + // scope for handoff/history is the explicit `executionId` field — do not + // make consumers parse it back out of `id`. + const userMessage = textMessageDto( + `${execution.id}:user`, + 'user', + execution.userMessage, + execution.id, + ); if (userMessage) messages.push(userMessage); const assistantContent = assistantContentFromExecution(execution); @@ -144,6 +154,7 @@ export function executionToMessagesDto(execution: ExecutionTranscript): AgentPer id: `${execution.id}:assistant`, role: 'assistant', content: assistantContent, + executionId: execution.id, }); } diff --git a/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.test.ts index 2f918cb12c0..b130712e476 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.test.ts @@ -46,6 +46,31 @@ describe('N8nAiActivityStep', () => { expect(getByText('Something went wrong')).toBeInTheDocument(); }); + it('should hide the inner error callout when hideErrorCallout is true', () => { + const { container } = render(AiActivityStep, { + props: { + label: 'Search nodes', + error: 'Something went wrong', + hideErrorCallout: true, + }, + global: { + ...global, + stubs: { + ...global.stubs, + N8nCallout: { + template: '
', + }, + N8nTooltip: { template: '
' }, + }, + }, + }); + + expect( + container.querySelector('[data-test-id="activity-error-callout"]'), + ).not.toBeInTheDocument(); + expect(container.querySelector('.n8n-icon')).toBeInTheDocument(); + }); + it('should render slot content', () => { const { getByText } = render(AiActivityStep, { props: { label: 'Search nodes' }, diff --git a/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.vue b/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.vue index 6be77b7513e..a8ba02daea3 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nAiActivityStep/AiActivityStep.vue @@ -21,12 +21,15 @@ const props = withDefaults( hasContent?: boolean; /** Wraps slot content in the standard activity data panel. */ wrapContent?: boolean; + /** Keep error icon/tooltip but skip the inner danger callout. */ + hideErrorCallout?: boolean; }>(), { loading: false, error: undefined, hasContent: true, wrapContent: false, + hideErrorCallout: false, }, ); @@ -71,7 +74,11 @@ const errorTooltip = computed(() => - + {{ props.error }} diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 0a854161583..e47ac0a8778 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -635,6 +635,7 @@ "chatHub.message.actions.readAloud": "Read aloud", "chatHub.message.actions.stopReading": "Stop reading", "agents.builder.preview.sendToAssistant": "Send to assistant", + "agents.builder.preview.fixWithAssistant": "Fix with Assistant", "chatHub.message.actions.edit": "Edit", "chatHub.message.actions.regenerate": "Regenerate", "chatHub.message.actions.executionId": "Execution ID", @@ -6962,6 +6963,7 @@ "agents.builder.chat.newChat.label": "New chat", "agents.chat.loadHistory.error": "Could not load chat history", "agents.chat.clearHistory.error": "Could not clear chat history", + "agents.chat.toolError.generic": "Something went wrong while running this tool", "agents.chat.clearHistory": "Clear chat history", "agents.chat.input.placeholder": "Type a message...", "agents.preview.episodicMemoryCallout.text": "Access memories from previous conversations.", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index e7a94aa08f8..9cbd684e84b 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -680,6 +680,27 @@ describe('AgentBuilderView — preview routing', () => { }); }); + it('forwards executionId from preview Fix with Assistant to instance AI handoff', async () => { + routeName = 'AgentPreviewView'; + routeQuery.continueSessionId = 'thread-1'; + + const wrapper = await renderView(); + const preview = wrapper.findComponent({ name: 'AgentPreviewChatPage' }); + + preview.vm.$emit('send-to-assistant', 'exec-turn-1'); + await flushPromises(); + + expect(sendPreviewSessionToInstanceAiMock).toHaveBeenCalledWith({ + projectId: 'p1', + agentId: 'a1', + threadId: 'thread-1', + agentName: 'Agent One', + agentIcon: 'bot', + sessionTitle: 'agents.builder.chat.newChat.label', + executionId: 'exec-turn-1', + }); + }); + it('blocks knowledge file uploads that would exceed the total size limit', async () => { getAgentMock.mockResolvedValue(makeAgentResponse({ activeVersionId: 'v1' })); listAgentFilesMock.mockResolvedValue([ diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts index c153bfb849f..88a1b9969e2 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatMessageList.test.ts @@ -32,7 +32,12 @@ vi.mock('@/features/ai/chatHub/components/ChatTypingIndicator.vue', () => ({ })); vi.mock('@/features/agents/components/AgentChatToolSteps.vue', () => ({ - default: { template: '
', props: ['toolCalls', 'projectId'] }, + default: { + name: 'AgentChatToolSteps', + template: '
`, }, N8nAiActivityStepGroup: { props: ['label', 'size', 'loading'], - template: '
', + template: '
', + }, + N8nButton: { + props: ['size', 'variant'], + emits: ['click'], + template: + '', + }, + N8nCallout: { + props: ['theme'], + template: '
', }, N8nIcon: { template: '', @@ -81,9 +92,12 @@ vi.mock('../composables/useSubAgentNames', () => ({ useSubAgentNames: () => ({ subAgentNameById: { value: new Map() } }), })); -function mountSteps(toolCalls: ToolCall[]) { +function mountSteps( + toolCalls: ToolCall[], + extra: { canFixWithAssistant?: boolean; executionId?: string } = {}, +) { return mount(AgentChatToolSteps, { - props: { toolCalls, projectId: 'project-1' }, + props: { toolCalls, projectId: 'project-1', ...extra }, }); } @@ -119,6 +133,90 @@ describe('AgentChatToolSteps', () => { expect(wrapper.find('button').exists()).toBe(false); }); + it('shows Fix with Assistant only for errored tools when handoff is enabled', async () => { + const errored: ToolCall = { + tool: 'search_nodes', + toolCallId: 'tc-err', + state: TOOL_CALL_STATE.ERROR, + output: 'Tool failed', + }; + + const withoutFix = mountSteps([errored]); + expect(withoutFix.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe( + false, + ); + + const withoutExecutionId = mountSteps([errored], { canFixWithAssistant: true }); + expect( + withoutExecutionId.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists(), + ).toBe(false); + + const doneTool = mountSteps( + [{ ...errored, state: TOOL_CALL_STATE.DONE, toolCallId: 'tc-ok' }], + { canFixWithAssistant: true, executionId: 'exec-1' }, + ); + expect(doneTool.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe( + false, + ); + + const withFix = mountSteps([errored], { + canFixWithAssistant: true, + executionId: 'exec-1', + }); + expect( + withFix.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists(), + ).toBe(true); + await withFix.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').trigger('click'); + expect(withFix.emitted('fixWithAssistant')?.length).toBeGreaterThanOrEqual(1); + }); + + it('shows a generic error when the failed tool output is empty', () => { + const wrapper = mountSteps([ + { + tool: 'search_nodes', + toolCallId: 'tc-err', + state: TOOL_CALL_STATE.ERROR, + output: {}, + }, + ]); + + expect(wrapper.find('[data-test-id="tool-step-error"]').text()).toBe( + 'agents.chat.toolError.generic', + ); + expect(wrapper.text()).not.toContain('{}'); + }); + + it('shows Fix with Assistant outside the tool group when a multi-tool turn has an error', () => { + const wrapper = mountSteps( + [ + { + tool: 'search_nodes', + toolCallId: 'tc-ok', + state: TOOL_CALL_STATE.DONE, + output: { nodes: ['Slack'] }, + }, + { + tool: 'search_nodes', + toolCallId: 'tc-err', + state: TOOL_CALL_STATE.ERROR, + output: 'Tool failed', + }, + ], + { canFixWithAssistant: true, executionId: 'exec-1' }, + ); + + expect(wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant"]').exists()).toBe(true); + + const group = wrapper.find('[data-test-id="n8n-ai-activity-step-group"]'); + expect(group.exists()).toBe(true); + expect(group.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists()).toBe( + false, + ); + expect( + wrapper.find('[data-test-id="agent-chat-tool-fix-with-assistant-callout"]').exists(), + ).toBe(true); + }); + it('shows incomplete task count in write_todos summary', async () => { const wrapper = mountSteps([ { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts index fa43c94c98e..29f56df3460 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/agentChatMessages.test.ts @@ -70,6 +70,31 @@ describe('rebuildInteractiveFromHistory', () => { }); describe('convertDbMessages — interactive turn synthesis', () => { + it('uses executionId from the persisted message dto', () => { + const chat = convertDbMessages([ + { + id: 'exec-1:assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + executionId: 'exec-1', + }, + ]); + + expect(chat[0].executionId).toBe('exec-1'); + }); + + it('does not invent executionId from the message id when the dto omits it', () => { + const chat = convertDbMessages([ + { + id: 'exec-1:assistant', + role: 'assistant', + content: [{ type: 'text', text: 'Hi' }], + }, + ]); + + expect(chat[0].executionId).toBeUndefined(); + }); + it('preserves multiple resolved n8n chat cards from one persisted assistant message', () => { const dbMessages: AgentPersistedMessageDto[] = [ { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts index cf3aa9b1c31..cdb73c703bd 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentChatStream.test.ts @@ -920,3 +920,22 @@ describe('useAgentChatStream — loadHistory', () => { expect(msg.status).toBe('awaitingUser'); }); }); + +describe('useAgentChatStream — done executionId', () => { + it('stamps executionId from done onto minted messages', async () => { + const events: AgentSseEvent[] = [ + { type: 'text-start', id: 't1' }, + { type: 'text-delta', id: 't1', delta: 'Hello' }, + { type: 'text-end', id: 't1' }, + { type: 'done', sessionId: 'thread-1', executionId: 'exec-live-1' }, + ]; + globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch; + + const hook = buildHook(); + await hook.sendMessage('hi'); + + const assistant = hook.messages.value.find((m) => m.role === 'assistant'); + expect(assistant?.content).toBe('Hello'); + expect(assistant?.executionId).toBe('exec-live-1'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue index d6d95c865f2..b2936a5be9c 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatMessageList.vue @@ -34,7 +34,7 @@ const props = defineProps<{ const emit = defineEmits<{ resume: [payload: { runId: string; toolCallId: string; resumeData: unknown }]; - sendToAssistant: []; + sendToAssistant: [executionId?: string]; }>(); const i18n = useI18n(); @@ -42,6 +42,11 @@ const canSendToAssistant = computed(() => Boolean(props.canSendToAssistant && props.agentId && props.sessionId), ); +function onFixWithAssistant(group: DisplayGroup) { + const executionId = group.kind === 'toolRun' ? group.executionId : group.message.executionId; + emit('sendToAssistant', executionId); +} + function onInteractiveSubmit(payload: InteractivePayload, resumeData: unknown) { // Cards without a runId are disabled at the card level (see InteractiveCard). // This guard is a defensive belt-and-braces for the type narrowing. @@ -421,6 +426,9 @@ onBeforeUnmount(() => { v-if="group.toolCalls.length" :tool-calls="group.toolCalls" :project-id="projectId" + :can-fix-with-assistant="canSendToAssistant" + :execution-id="group.executionId" + @fix-with-assistant="onFixWithAssistant(group)" />