feat: Show when an n8n Chat agent is no longer available (no-changelog) (#40430)

This commit is contained in:
Benjamin Schroth 2026-10-08 15:25:09 +00:00 committed by GitHub
parent 77a692a4bb
commit 3a37ec1dcc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 165 additions and 65 deletions

View File

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

View File

@ -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<AiConfig>({
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();
});
});

View File

@ -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',

View File

@ -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([

View File

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

View File

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

View File

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

View File

@ -0,0 +1,7 @@
import { UserError } from 'n8n-workflow';
export class AgentN8nChatUnavailableError extends UserError {
constructor() {
super('This agent is not available in n8n Chat');
}
}

View File

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

View File

@ -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', () => {

View File

@ -219,6 +219,7 @@ function buildHook(
options: {
newSession?: Ref<boolean>;
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

View File

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

View File

@ -80,6 +80,8 @@ export interface UseAgentChatStreamParams {
channel?: Ref<AgentChatChannel>;
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 {

View File

@ -248,6 +248,7 @@ const agentPageRoute = computed(() => {
@session-created="onSessionCreated"
@update:streaming="onStreamingChange"
@first-user-message="firstUserMessage = $event"
@agent-unavailable="notFound = true"
>
<template #empty-state>
<div :class="$style.emptyState" data-testid="agent-n8n-chat-empty-state">

View File

@ -112,7 +112,7 @@ function renderView(
'backgroundJobsActive',
'centerEmptyState',
],
emits: ['session-created', 'update:streaming', 'first-user-message'],
emits: ['session-created', 'update:streaming', 'first-user-message', 'agent-unavailable'],
setup: () => ({ subAgentNames: inject(AGENT_SUB_AGENT_NAMES_KEY) }),
methods: { sendMessageFromOutside: sendMessageFromOutsideMock },
},
@ -357,6 +357,19 @@ describe('AgentN8nChatView', () => {
expect(wrapper.text()).toContain('Other Agent');
});
it('switches to the unavailable empty state when the panel reports the agent is no longer available', async () => {
const wrapper = renderView();
await flushPromises();
expect(wrapper.find('[data-testid="chat-panel-stub"]').exists()).toBe(true);
const panel = wrapper.findComponent({ name: 'AgentChatPanel' });
await panel.vm.$emit('agent-unavailable');
await flushPromises();
expect(wrapper.find('[data-testid="agent-n8n-chat-unavailable"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="chat-panel-stub"]').exists()).toBe(false);
});
it('toasts on a non-404 load failure instead of showing the unavailable state', async () => {
getN8nChatAgentMock.mockRejectedValue(new Error('network down'));
const wrapper = renderView();