From 3a37ec1dcccaa9906cc98974c2a4dc51d2c5eaff Mon Sep 17 00:00:00 2001 From: Benjamin Schroth <68321970+schrothbn@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:25:09 +0000 Subject: [PATCH] feat: Show when an n8n Chat agent is no longer available (no-changelog) (#40430) --- .../__tests__/agent-chat.controller.test.ts | 18 ++++--- ...ent-execution-orchestrator.service.test.ts | 4 +- ...ent-message-queue-consumer.service.test.ts | 24 +++++++++ .../agents/__tests__/agent-sse-stream.test.ts | 42 +++++++++++++--- .../modules/agents/agent-chat.controller.ts | 50 +++---------------- .../agent-execution-orchestrator.service.ts | 5 +- .../agent-message-queue-consumer.service.ts | 7 +-- .../agent-n8n-chat-unavailable.error.ts | 7 +++ .../src/modules/agents/agent-sse-stream.ts | 14 ++++++ .../agents/__tests__/AgentChatPanel.test.ts | 12 +++++ .../__tests__/useAgentChatStream.test.ts | 24 +++++++++ .../agents/components/AgentChatPanel.vue | 3 ++ .../agents/composables/useAgentChatStream.ts | 4 ++ .../agents/n8nChatPage/AgentN8nChatView.vue | 1 + .../__tests__/AgentN8nChatView.test.ts | 15 +++++- 15 files changed, 165 insertions(+), 65 deletions(-) create mode 100644 packages/cli/src/modules/agents/agent-n8n-chat-unavailable.error.ts 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 b47cbf6cafb..36df90dec46 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 @@ -25,6 +25,7 @@ import type { AgentMessageQueue } from '../entities/agent-message-queue.entity'; import type { AgentExecutionService } from '../agent-execution.service'; import { AgentTurnAlreadyRunningError } from '../agent-chat-execution.service'; import type { AgentChatExecutionService } from '../agent-chat-execution.service'; +import { AgentN8nChatUnavailableError } from '../agent-n8n-chat-unavailable.error'; import type { AgentValidationService } from '../agent-validation.service'; import type { AgentBackgroundJobService } from '../background/agent-background-job.service'; import type { AgentExecutionThread } from '../entities/agent-execution-thread.entity'; @@ -1124,7 +1125,7 @@ describe('AgentChatController production n8n Chat', () => { message: 'hello', } as never); expect(writes).toContain( - 'data: {"type":"error","message":"This agent is not available in n8n Chat.","errorCode":"agent_unavailable"}\n\n', + 'data: {"type":"error","message":"This agent is not available in n8n Chat","errorCode":"agent_unavailable"}\n\n', ); expect(agentExecutionOrchestratorService.executeForN8nChatPublished).not.toHaveBeenCalled(); }); @@ -1214,9 +1215,8 @@ describe('AgentChatController production n8n Chat', () => { expect(writes.some((line) => line.includes('Session not found'))).toBe(true); }); - it('checks publication and the production checkpoint scope before resuming', async () => { - const { controller, agentsService, agentExecutionOrchestratorService } = makeController(); - agentsService.isN8nChatPublished.mockResolvedValue(true); + it('passes the production checkpoint scope when resuming', async () => { + const { controller, agentExecutionOrchestratorService } = makeController(); agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* (config) { config.onExecutionStarted?.('exec-99', 'thread-1', ['message-1']); yield { type: 'text-delta', id: 'text-1', delta: 'Done' }; @@ -1249,9 +1249,12 @@ describe('AgentChatController production n8n Chat', () => { }); }); - it('rejects production resume when the channel is not published', async () => { - const { controller, agentsService, agentExecutionOrchestratorService } = makeController(); - agentsService.isN8nChatPublished.mockResolvedValue(false); + it('maps the agent becoming unavailable mid-resume to agent_unavailable', async () => { + const { controller, agentExecutionOrchestratorService } = makeController(); + // eslint-disable-next-line require-yield + agentExecutionOrchestratorService.resumeForChat.mockImplementation(async function* () { + throw new AgentN8nChatUnavailableError(); + }); const writes: string[] = []; await controller.productionChatResume(request as never, makeSseResponse(writes), 'agent-1', { runId: 'run-1', @@ -1259,7 +1262,6 @@ describe('AgentChatController production n8n Chat', () => { resumeData: { approved: true }, } as never); - expect(agentExecutionOrchestratorService.resumeForChat).not.toHaveBeenCalled(); expect(writes.some((line) => line.includes('agent_unavailable'))).toBe(true); }); 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 aa0b1612fa9..2bb406dd54e 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 @@ -78,6 +78,7 @@ import { readIntegrationMessageContext, } from '../integrations/integration-message-context'; import type { ToolRegistry } from '../tool-registry'; +import { AgentN8nChatUnavailableError } from '../agent-n8n-chat-unavailable.error'; const aiConfigMock = mock({ modelStreamIdleTimeoutMs: 90_000, @@ -763,7 +764,8 @@ describe('background approvals', () => { chatSurface: 'n8n-chat', }), ), - ).rejects.toThrow('This agent is not available in n8n Chat'); + ).rejects.toBeInstanceOf(AgentN8nChatUnavailableError); + expect(backgroundJobs.getApproval).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts b/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts index 56b1db5a655..68398374d57 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-message-queue-consumer.service.test.ts @@ -11,6 +11,7 @@ import type { AgentChatExecutionService } from '../agent-chat-execution.service' import type { AgentExecutionOrchestratorService } from '../agent-execution-orchestrator.service'; import type { AgentExecutionService } from '../agent-execution.service'; import { AgentMessageQueueConsumer } from '../agent-message-queue-consumer.service'; +import { AgentN8nChatUnavailableError } from '../agent-n8n-chat-unavailable.error'; import type { AgentMessageQueueService, ClaimedAgentMessage } from '../agent-message-queue.service'; import type { AgentQueuedPreviewStreamService } from '../agent-queued-preview-stream.service'; import type { AgentTestRunService } from '../agent-test-run.service'; @@ -258,6 +259,29 @@ describe('AgentMessageQueueConsumer', () => { expect(sender.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'done' })); }); + it('maps an n8n Chat stream failure to its errorCode via toChatErrorEvent', async () => { + const thrown = new AgentN8nChatUnavailableError(); + const item = claim('session'); + item.payload = { kind: 'n8n_chat', message: 'input', resourceId: 'n8n-chat:user' }; + item.item.payload = { kind: 'n8n_chat' }; + repository.findThreadIds.mockResolvedValue(['session']); + queue.claimNext.mockResolvedValueOnce(item).mockResolvedValue(null); + // eslint-disable-next-line require-yield + orchestrator.executeForN8nChatPublished.mockImplementation(async function* () { + throw thrown; + }); + consumer.start(); + await vi.waitFor(() => + expect(queue.settle).toHaveBeenCalledWith('session', item.admission.executionId), + ); + expect(queue.recordFailure).toHaveBeenCalledWith(item, thrown, expect.any(AbortSignal)); + expect(sender.send).toHaveBeenCalledWith({ + type: 'error', + message: thrown.message, + errorCode: 'agent_unavailable', + }); + }); + it.each([ 'missing owner', 'deleted owner', diff --git a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts index 63da96a0cb8..9d59de63f25 100644 --- a/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts +++ b/packages/cli/src/modules/agents/__tests__/agent-sse-stream.test.ts @@ -3,7 +3,14 @@ import type { AgentSseEvent } from '@n8n/api-types'; import { LoggerProxy } from 'n8n-workflow'; import { EventEmitter } from 'node:events'; -import { emitChunkEvents, initSseStream, type FlushableResponse } from '../agent-sse-stream'; +import { + emitChunkEvents, + initSseStream, + toChatErrorEvent, + type FlushableResponse, +} from '../agent-sse-stream'; +import { AgentN8nChatUnavailableError } from '../agent-n8n-chat-unavailable.error'; +import { AgentTurnAlreadyRunningError } from '../agent-turn-already-running.error'; import type { SteeredMessageEvent } from '../types/agent-steering'; // --------------------------------------------------------------------------- @@ -168,11 +175,10 @@ describe('agent-sse-stream — connection setup', () => { // stringifyError — tested through emitChunkEvents // --------------------------------------------------------------------------- -vi.mock('n8n-workflow', () => ({ - LoggerProxy: { - warn: vi.fn(), - }, -})); +vi.mock(import('n8n-workflow'), async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, LoggerProxy: { ...actual.LoggerProxy, warn: vi.fn() } }; +}); describe('agent-sse-stream — stringifyError (via error chunk)', () => { it('extracts .message from an Error instance', async () => { @@ -517,6 +523,30 @@ describe('agent-sse-stream — tool execution lifecycle chunks', () => { }); }); +describe('agent-sse-stream — toChatErrorEvent', () => { + it.each([ + [new AgentTurnAlreadyRunningError(), 'turn_already_running'], + [new AgentN8nChatUnavailableError(), 'agent_unavailable'], + [new Error('boom'), undefined], + ] as const)('maps %s to errorCode %s', (error, errorCode) => { + expect(toChatErrorEvent(error, 'fallback')).toEqual({ + type: 'error', + message: error.message, + ...(errorCode && { errorCode }), + }); + }); +}); + +it('scrubs secrets from the error message it sends to the client', () => { + const event = toChatErrorEvent( + new Error('Request failed: Authorization: Bearer sk-abcdef1234567890abcdef1234567890'), + 'fallback', + ); + expect(event.type === 'error' && event.message).not.toContain( + 'sk-abcdef1234567890abcdef1234567890', + ); +}); + describe('agent-sse-stream — subagent-chunk', () => { it('forwards allowlisted subagent-chunk events with parentToolCallId', async () => { const events = await collectEvents([ diff --git a/packages/cli/src/modules/agents/agent-chat.controller.ts b/packages/cli/src/modules/agents/agent-chat.controller.ts index f23d132bdef..f57c7b0b54a 100644 --- a/packages/cli/src/modules/agents/agent-chat.controller.ts +++ b/packages/cli/src/modules/agents/agent-chat.controller.ts @@ -39,15 +39,13 @@ import { BadRequestError, NotFoundError } from '@n8n/errors'; import { AgentsCredentialProvider } from './adapters/agents-credential-provider'; import { AgentChatAttachmentService } from './agent-chat-attachment.service'; +import { AgentN8nChatUnavailableError } from './agent-n8n-chat-unavailable.error'; import type { AgentChatAttachment } from './entities/agent-chat-attachment.entity'; import type { StoredAttachmentRef } from './types/agent-chat-attachment'; import { AgentExecutionOrchestratorService } from './agent-execution-orchestrator.service'; import { AgentMessageQueueService } from './agent-message-queue.service'; import { AgentQueuedPreviewStreamService } from './agent-queued-preview-stream.service'; -import { - AgentChatExecutionService, - AgentTurnAlreadyRunningError, -} from './agent-chat-execution.service'; +import { AgentChatExecutionService } from './agent-chat-execution.service'; import { AgentExecutionService } from './agent-execution.service'; import { type AgentSessionMode, @@ -55,7 +53,7 @@ import { threadBelongsTo, } from './utils/agent-thread-access'; import { messagesToDto } from './agent-message-mapper'; -import { type FlushableResponse, initSseStream } from './agent-sse-stream'; +import { type FlushableResponse, initSseStream, toChatErrorEvent } from './agent-sse-stream'; import { AgentTestChatService, chatThreadId } from './agent-test-chat.service'; import { AgentTestRunService } from './agent-test-run.service'; import { AgentsService } from './agents.service'; @@ -233,13 +231,7 @@ export class AgentChatController { send({ type: 'message-queued', queueId: result.item.id, sessionId: input.threadId }); await subscription?.done; } catch (error) { - send({ - type: 'error', - message: error instanceof Error ? error.message : 'Chat failed', - ...(error instanceof AgentTurnAlreadyRunningError - ? { errorCode: 'turn_already_running' } - : {}), - }); + send(toChatErrorEvent(error, 'Chat failed')); } finally { // Committed messages own their attachments, including after a disconnect. if (!accepted && attachments?.length) { @@ -266,14 +258,9 @@ export class AgentChatController { ) { const { projectId } = req.params; const resourceId = productionChatMemoryResourceId(req.user.id); - await this.relayQueuedMessage(res, async (send) => { + await this.relayQueuedMessage(res, async () => { if (!(await this.agentsService.isN8nChatPublished(agentId, projectId))) { - send({ - type: 'error', - message: 'This agent is not available in n8n Chat.', - errorCode: 'agent_unavailable', - }); - return undefined; + throw new AgentN8nChatUnavailableError(); } const sessionMode = payload.sessionId && !payload.newSession ? 'existing' : 'new'; // Keep a client session ID for a new session, so a retry stays a duplicate. @@ -317,14 +304,6 @@ export class AgentChatController { const { send, onChunk, abortSignal, onExecutionStarted } = execution; let executionId: string | undefined; try { - if (!(await this.agentsService.isN8nChatPublished(agentId, req.params.projectId))) { - send({ - type: 'error', - message: 'This agent is not available in n8n Chat.', - errorCode: 'agent_unavailable', - }); - return; - } abortSignal.throwIfAborted(); const stream = this.agentExecutionOrchestratorService.resumeForChat({ agentId, @@ -354,13 +333,7 @@ export class AgentChatController { send({ type: 'done', ...(executionId ? { executionId } : {}) }); } } catch (error) { - send({ - type: 'error', - message: error instanceof Error ? error.message : 'Resume failed', - ...(error instanceof AgentTurnAlreadyRunningError - ? { errorCode: 'turn_already_running' } - : {}), - }); + send(toChatErrorEvent(error, 'Resume failed')); } finally { execution.close(); } @@ -470,14 +443,7 @@ export class AgentChatController { }); } } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Resume failed'; - send({ - type: 'error', - message: errorMessage, - ...(error instanceof AgentTurnAlreadyRunningError - ? { errorCode: 'turn_already_running' } - : {}), - }); + send(toChatErrorEvent(error, 'Resume failed')); } finally { execution.close(); } 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 164b209c94b..aa6b0d40cc4 100644 --- a/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts +++ b/packages/cli/src/modules/agents/agent-execution-orchestrator.service.ts @@ -21,6 +21,7 @@ import { AgentChatExecutionService, type CancelSuspendedRunParams, } from './agent-chat-execution.service'; +import { AgentN8nChatUnavailableError } from './agent-n8n-chat-unavailable.error'; import type { AgentThreadAccess } from './entities/agent-execution-thread.entity'; import type { AgentChatSurface, AgentSessionMode } from './utils/agent-thread-access'; import { @@ -521,7 +522,7 @@ export class AgentExecutionOrchestratorService { resume.chatSurface === 'n8n-chat' && !(await this.agentRepository.isN8nChatPublished(resume.agentId, resume.projectId)) ) { - throw new UserError('This agent is not available in n8n Chat'); + throw new AgentN8nChatUnavailableError(); } if (await this.resumeBackgroundForChat(resume)) return; const checkpoint = await this.loadResumeCheckpoint(resume); @@ -1498,7 +1499,7 @@ export class AgentExecutionOrchestratorService { !skipPublishedCheck && !(await this.agentRepository.isN8nChatPublished(agentId, projectId)) ) { - throw new UserError('This agent is not available in n8n Chat'); + throw new AgentN8nChatUnavailableError(); } const fail = denied ?? (() => new UserError('Session not found')); if (resourceId !== chatSurfaceMemoryResourceId('n8n-chat', userId)) { diff --git a/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts b/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts index 12a9fd13693..027e7cfe94c 100644 --- a/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts +++ b/packages/cli/src/modules/agents/agent-message-queue-consumer.service.ts @@ -14,7 +14,7 @@ import { AgentExecutionOrchestratorService } from './agent-execution-orchestrato import { AgentExecutionService } from './agent-execution.service'; import { AgentMessageQueueService, type ClaimedAgentMessage } from './agent-message-queue.service'; import { AgentQueuedPreviewStreamService } from './agent-queued-preview-stream.service'; -import { emitChunkEvents } from './agent-sse-stream'; +import { emitChunkEvents, toChatErrorEvent } from './agent-sse-stream'; import { AgentTestRunService } from './agent-test-run.service'; import type { AgentExecutionThread } from './entities/agent-execution-thread.entity'; import type { AgentChatBridge } from './integrations/agent-chat-bridge'; @@ -166,10 +166,7 @@ export class AgentMessageQueueConsumer { } } catch (error) { await this.queue.recordFailure(claim, error, controller.signal); - sender?.send({ - type: 'error', - message: error instanceof Error ? error.message : 'Chat failed', - }); + sender?.send(toChatErrorEvent(error, 'Chat failed')); this.logger.warn('Queued agent message failed', { queueId: item.id, executionId: admission.executionId, diff --git a/packages/cli/src/modules/agents/agent-n8n-chat-unavailable.error.ts b/packages/cli/src/modules/agents/agent-n8n-chat-unavailable.error.ts new file mode 100644 index 00000000000..f4f5b8d4cc1 --- /dev/null +++ b/packages/cli/src/modules/agents/agent-n8n-chat-unavailable.error.ts @@ -0,0 +1,7 @@ +import { UserError } from 'n8n-workflow'; + +export class AgentN8nChatUnavailableError extends UserError { + constructor() { + super('This agent is not available in n8n Chat'); + } +} diff --git a/packages/cli/src/modules/agents/agent-sse-stream.ts b/packages/cli/src/modules/agents/agent-sse-stream.ts index 5b62607bea3..6b160f86d4d 100644 --- a/packages/cli/src/modules/agents/agent-sse-stream.ts +++ b/packages/cli/src/modules/agents/agent-sse-stream.ts @@ -9,6 +9,8 @@ import { scrubSecretsInText } from '@n8n/utils/scrub-secrets'; import type { Response } from 'express'; import { LoggerProxy } from 'n8n-workflow'; +import { AgentN8nChatUnavailableError } from './agent-n8n-chat-unavailable.error'; +import { AgentTurnAlreadyRunningError } from './agent-turn-already-running.error'; import type { AgentExecutionStreamChunk } from './types/agent-steering'; export type FlushableResponse = Response & { flush?: () => void }; @@ -71,6 +73,18 @@ export function initSseStream(res: FlushableResponse) { return { send, onChunk, abortSignal: abortController.signal, close }; } +/** Build the `error` SSE event for a caught error, mapping known error classes to a stable `errorCode`. */ +export function toChatErrorEvent(error: unknown, fallbackMessage: string): AgentSseEvent { + let errorCode: string | undefined; + if (error instanceof AgentTurnAlreadyRunningError) errorCode = 'turn_already_running'; + if (error instanceof AgentN8nChatUnavailableError) errorCode = 'agent_unavailable'; + return { + type: 'error', + message: scrubSecretsInText(error instanceof Error ? error.message : fallbackMessage), + ...(errorCode && { errorCode }), + }; +} + function toAgentSseMessage(message: AgentMessage): AgentSseMessage | undefined { if (!('content' in message) || !Array.isArray(message.content)) return undefined; diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index ebf052a5f03..59aeff27833 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -79,6 +79,7 @@ vi.mock('@n8n/stores/useRootStore', () => ({ useRootStore: () => ({ restApiContext: { baseUrl: '/rest' } }), })); let onHistoryLoaded: ((count: number) => void) | undefined; +let onAgentUnavailable: (() => void) | undefined; const fatalErrorMock = ref<{ missing: string[] } | null>(null); @@ -265,8 +266,10 @@ vi.mock('../composables/useAgentChatStream', () => ({ useAgentChatStream: (options: { channel?: { value: 'chat' | 'n8n-chat' }; onHistoryLoaded: (count: number) => void; + onAgentUnavailable?: () => void; }) => { onHistoryLoaded = options.onHistoryLoaded; + onAgentUnavailable = options.onAgentUnavailable; return { capabilities: computed(() => { // Mirrors the composable: every capability is off on the n8n Chat channel. @@ -345,6 +348,7 @@ describe('AgentChatPanel', () => { respondToApprovalMock.mockReset().mockResolvedValue(undefined); fatalErrorMock.value = null; onHistoryLoaded = undefined; + onAgentUnavailable = undefined; }); function mountPanel( @@ -3103,6 +3107,14 @@ describe('AgentChatPanel', () => { ); }); }); + + it('emits agent-unavailable when the stream reports the agent is no longer available', () => { + const wrapper = mountPanel(); + + onAgentUnavailable?.(); + + expect(wrapper.emitted('agent-unavailable')).toHaveLength(1); + }); }); describe('AgentPreviewDock stream lifecycle', () => { 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 b3e1fd3e39f..63b330847e4 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 @@ -219,6 +219,7 @@ function buildHook( options: { newSession?: Ref; onSessionCreated?: (sessionId: string) => void; + onAgentUnavailable?: () => void; budgetCards?: boolean; channel?: Ref<'chat' | 'n8n-chat'>; } = {}, @@ -1981,6 +1982,29 @@ describe('useAgentChatStream — SDK-aligned event handling', () => { }); }); + it('calls onAgentUnavailable (not fatalError, not a message bubble) for agent_unavailable errors', async () => { + const events: AgentSseEvent[] = [ + { + type: 'error', + message: 'This agent is not available in n8n Chat', + errorCode: 'agent_unavailable', + }, + ]; + globalThis.fetch = vi.fn(async () => makeSseResponse(events)) as typeof fetch; + const onAgentUnavailable = vi.fn(); + + const hook = buildHook(undefined, { onAgentUnavailable }); + await hook.sendMessage('run'); + await flushPromises(); + await nextTick(); + + // Only user message — no inline error bubble + expect(hook.messages.value).toHaveLength(1); + expect(onAgentUnavailable).toHaveBeenCalledOnce(); + expect(showQueueErrorMock).not.toHaveBeenCalled(); + expect(hook.fatalError.value).toBeNull(); + }); + it('drops empty orphan minted bubbles when any error arrives', async () => { const events: AgentSseEvent[] = [ // start-step mints a ChatMessage but no text/tool follows — it stays empty diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue index 008c50aa242..fb37b41a4f7 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue @@ -140,6 +140,8 @@ const emit = defineEmits<{ 'send-to-assistant': [event?: AgentSendToAssistantEvent]; /** The chat's first user message, for a host to title a thread that has no title yet. */ 'first-user-message': [text: string | undefined]; + /** The agent became unavailable in this channel (unpublished before send, or mid-run/resume). */ + 'agent-unavailable': []; }>(); const locale = useI18n(); @@ -188,6 +190,7 @@ const { } }, onSessionCreated: (sessionId) => emit('session-created', sessionId), + onAgentUnavailable: () => emit('agent-unavailable'), budgetCards: props.budgetCards, }); diff --git a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts index 01a46847e9c..c21bfab119d 100644 --- a/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts +++ b/packages/frontend/editor-ui/src/features/agents/composables/useAgentChatStream.ts @@ -80,6 +80,8 @@ export interface UseAgentChatStreamParams { channel?: Ref; onHistoryLoaded?: (count: number) => void; onSessionCreated?: (sessionId: string) => void; + /** The agent is no longer available in n8n Chat (unpublished before send, or mid-run/resume). */ + onAgentUnavailable?: () => void; /** Builder preview shows the budget stop and alert cards. Other chats ignore them. */ budgetCards?: boolean; } @@ -1230,6 +1232,8 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) { markInFlightStateFailed(session); if (event.errorCode === 'agent_misconfigured') { fatalError.value = { message: event.message, missing: event.missing ?? [] }; + } else if (event.errorCode === 'agent_unavailable') { + params.onAgentUnavailable?.(); } else if (session.userMessage && !session.executionId) { showError(new Error(event.message), locale.baseText('agents.chat.queue.sendError')); } else { diff --git a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue index a5ad5347a24..2689b527627 100644 --- a/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue +++ b/packages/frontend/editor-ui/src/features/agents/n8nChatPage/AgentN8nChatView.vue @@ -248,6 +248,7 @@ const agentPageRoute = computed(() => { @session-created="onSessionCreated" @update:streaming="onStreamingChange" @first-user-message="firstUserMessage = $event" + @agent-unavailable="notFound = true" >