mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 13:39:44 +02:00
feat: Show when an n8n Chat agent is no longer available (no-changelog) (#40430)
This commit is contained in:
parent
77a692a4bb
commit
3a37ec1dcc
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
export class AgentN8nChatUnavailableError extends UserError {
|
||||
constructor() {
|
||||
super('This agent is not available in n8n Chat');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user