feat(agents): expose ChatIntegrationRegistry via API and consume in FE

- Add display metadata fields (displayLabel, displayIcon, displayHelpText,
  displayConnectedText) to AgentChatIntegration abstract class and all
  three platform implementations (Slack, Telegram, Linear)
- Add ChatIntegrationDescriptor DTO to @n8n/api-types
- Add GET /catalog/integrations endpoint on AgentsController backed by
  AgentsService.listChatIntegrations()
- Add listAgentIntegrations() API client and useAgentIntegrationsCatalog
  composable (module-scoped singleton with dedup in-flight logic)
- Replace hardcoded KNOWN_TRIGGER_TYPES in AgentBuilderView with dynamic
  catalog fetch; add tests for the new composable and update view test mock

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Braumann 2026-04-24 09:09:42 +02:00
parent 8c1181d838
commit 282004508d
14 changed files with 292 additions and 6 deletions

View File

@ -13,6 +13,19 @@ import {
SCHEDULE_TRIGGER_NODE_TYPE,
} from 'n8n-workflow';
/**
* Describes a chat platform integration that agents can connect to.
* Source of truth: the backend `ChatIntegrationRegistry`.
*/
export interface ChatIntegrationDescriptor {
type: string;
label: string;
icon: string;
credentialTypes: string[];
helpText: string;
connectedText: string;
}
/**
* Node types a workflow can use as its trigger to be eligible as an agent
* tool. Single source of truth for both the backend compatibility check

View File

@ -9,6 +9,11 @@ import { AgentsService, chatThreadId } from '../agents.service';
import type { AgentPublishedVersion } from '../entities/agent-published-version.entity';
import type { Agent } from '../entities/agent.entity';
import { ChatIntegrationService } from '../integrations/chat-integration.service';
import {
AgentChatIntegration,
ChatIntegrationRegistry,
type AgentChatIntegrationContext,
} from '../integrations/agent-chat-integration';
import type { N8nMemory } from '../integrations/n8n-memory';
import type { AgentJsonConfig } from '../json-config/agent-json-config';
import type { AgentPublishedVersionRepository } from '../repositories/agent-published-version.repository';
@ -297,6 +302,49 @@ describe('AgentsService', () => {
});
});
describe('listChatIntegrations', () => {
class TestIntegration extends AgentChatIntegration {
readonly type = 'test-platform';
readonly credentialTypes = ['testApi'];
readonly displayLabel = 'Test Platform';
readonly displayIcon = 'circle';
readonly displayHelpText = 'Connect a Test credential.';
readonly displayConnectedText = 'Connected to Test.';
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
return {};
}
}
it('returns one descriptor per registered integration', () => {
const registry = new ChatIntegrationRegistry();
registry.register(new TestIntegration());
Container.set(ChatIntegrationRegistry, registry);
const result = service.listChatIntegrations();
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
type: 'test-platform',
label: 'Test Platform',
icon: 'circle',
credentialTypes: ['testApi'],
helpText: 'Connect a Test credential.',
connectedText: 'Connected to Test.',
});
});
it('returns an empty array when no integrations are registered', () => {
const registry = new ChatIntegrationRegistry();
Container.set(ChatIntegrationRegistry, registry);
expect(service.listChatIntegrations()).toEqual([]);
});
afterEach(() => {
Container.reset();
});
});
describe('delete — chat cleanup', () => {
it('removes the test-chat thread + messages after removing the agent', async () => {
const agent = makeAgent();

View File

@ -1,5 +1,5 @@
import type { AgentMessage, StreamChunk } from '@n8n/agents';
import type { AgentPersistedMessageDto } from '@n8n/api-types';
import type { AgentPersistedMessageDto, ChatIntegrationDescriptor } from '@n8n/api-types';
import { AuthenticatedRequest } from '@n8n/db';
import { Body, Delete, Get, Param, Patch, Post, Put, RestController } from '@n8n/decorators';
import { randomUUID } from 'crypto';
@ -140,6 +140,11 @@ export class AgentsController {
return await fetchProviderCatalog();
}
@Get('/catalog/integrations')
listIntegrations(): ChatIntegrationDescriptor[] {
return this.agentsService.listChatIntegrations();
}
@Get('/threads')
async listThreads(
req: AuthenticatedRequest<

View File

@ -5,6 +5,7 @@ import type {
StreamChunk,
ToolDescriptor,
} from '@n8n/agents';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import * as agents from '@n8n/agents';
import { Logger } from '@n8n/backend-common';
import { Time } from '@n8n/constants';
@ -146,6 +147,23 @@ export class AgentsService {
private readonly agentPublishedVersionRepository: AgentPublishedVersionRepository,
) {}
/**
* Return the list of registered chat platform integrations with their
* FE display metadata. Used by `GET /agents/integrations`.
*/
listChatIntegrations(): ChatIntegrationDescriptor[] {
return Container.get(ChatIntegrationRegistry)
.list()
.map((i) => ({
type: i.type,
label: i.displayLabel,
icon: i.displayIcon,
credentialTypes: i.credentialTypes,
helpText: i.displayHelpText,
connectedText: i.displayConnectedText,
}));
}
async create(projectId: string, name: string): Promise<Agent> {
// New agents start with no instructions so the home screen routes the
// first user message to the builder (/build) instead of to the chat

View File

@ -70,6 +70,10 @@ class BufferingTestIntegration extends AgentChatIntegration {
readonly credentialTypes: string[] = [];
readonly supportedComponents: string[] = [];
readonly description = '';
readonly displayLabel = 'Test Buffered';
readonly displayIcon = 'circle';
readonly displayHelpText = '';
readonly displayConnectedText = '';
readonly disableStreaming = true;
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
return {};
@ -81,6 +85,10 @@ class StreamingTestIntegration extends AgentChatIntegration {
readonly credentialTypes: string[] = [];
readonly supportedComponents: string[] = [];
readonly description = '';
readonly displayLabel = 'Test Streaming';
readonly displayIcon = 'circle';
readonly displayHelpText = '';
readonly displayConnectedText = '';
async createAdapter(_ctx: AgentChatIntegrationContext): Promise<unknown> {
return {};
}

View File

@ -28,6 +28,23 @@ export abstract class AgentChatIntegration {
/** Credential types accepted by the frontend selector. */
abstract readonly credentialTypes: string[];
// ---------------------------------------------------------------------------
// FE display metadata — shown in the trigger-picker and integration cards.
// These are user-facing; keep them distinct from `description` (LLM-facing).
// ---------------------------------------------------------------------------
/** Human-readable label shown in UI. */
abstract readonly displayLabel: string;
/** Lucide icon name (from the shared icon set) for the integration card. */
abstract readonly displayIcon: string;
/** Help text shown in the trigger-picker before the user connects. */
abstract readonly displayHelpText: string;
/** Confirmation text shown after the integration is connected. */
abstract readonly displayConnectedText: string;
/**
* Component types this platform supports in rich_interaction cards.
* Omit to signal that the platform has no rich_interaction surface the

View File

@ -24,6 +24,16 @@ export class LinearIntegration extends AgentChatIntegration {
readonly credentialTypes = ['linearApi', 'linearOAuth2Api'];
readonly displayLabel = 'Linear';
readonly displayIcon = 'list-checks';
readonly displayHelpText =
'Connect a Linear API credential to let this agent respond to comments in Linear issues. ' +
'Point a Linear webhook at the URL below and paste its signing secret into the credential.';
readonly displayConnectedText = 'Your agent is connected to Linear and can reply to @-mentions.';
constructor(private readonly logger: Logger) {
super();
}

View File

@ -16,6 +16,15 @@ export class SlackIntegration extends AgentChatIntegration {
readonly credentialTypes = ['slackApi', 'slackOAuth2Api'];
readonly displayLabel = 'Slack';
readonly displayIcon = 'hashtag';
readonly displayHelpText =
'Connect a Slack bot credential to allow this agent to receive and respond to Slack messages.';
readonly displayConnectedText = 'Your agent is connected to Slack and can receive messages.';
readonly supportedComponents = [
'section',
'button',

View File

@ -30,6 +30,15 @@ export class TelegramIntegration extends AgentChatIntegration {
readonly credentialTypes = ['telegramApi'];
readonly displayLabel = 'Telegram';
readonly displayIcon = 'paper-plane';
readonly displayHelpText =
'Connect a Telegram bot credential to allow this agent to receive and respond to Telegram messages.';
readonly displayConnectedText = 'Your agent is connected to Telegram and can receive messages.';
readonly supportedComponents = ['section', 'button', 'divider', 'fields'];
readonly description =

View File

@ -103,6 +103,13 @@ vi.mock('../agentSessions.store', () => ({
}),
}));
vi.mock('../composables/useAgentIntegrationsCatalog', () => ({
useAgentIntegrationsCatalog: () => ({
catalog: { value: [] },
ensureLoaded: vi.fn().mockResolvedValue([]),
}),
}));
const baseTextFn = (key: string) => {
const map: Record<string, string> = {
'agents.builder.chatMode.build': 'Build',

View File

@ -0,0 +1,96 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockListAgentIntegrations = vi.fn();
vi.mock('../composables/useAgentApi', () => ({
listAgentIntegrations: mockListAgentIntegrations,
}));
vi.mock('@n8n/stores/useRootStore', () => ({
useRootStore: () => ({ restApiContext: { baseUrl: 'http://localhost:5678' } }),
}));
describe('useAgentIntegrationsCatalog', () => {
const projectId = 'p1';
const fakeIntegrations = [
{
type: 'slack',
label: 'Slack',
icon: 'hashtag',
credentialTypes: ['slackApi'],
helpText: 'Connect Slack.',
connectedText: 'Connected to Slack.',
},
];
beforeEach(async () => {
vi.clearAllMocks();
// Reset module state between tests by re-importing a fresh module.
vi.resetModules();
});
it('fetches integrations on first call and returns them', async () => {
mockListAgentIntegrations.mockResolvedValue(fakeIntegrations);
const { useAgentIntegrationsCatalog } = await import(
'../composables/useAgentIntegrationsCatalog'
);
const { ensureLoaded } = useAgentIntegrationsCatalog();
const result = await ensureLoaded(projectId);
expect(mockListAgentIntegrations).toHaveBeenCalledTimes(1);
expect(result).toEqual(fakeIntegrations);
});
it('does not re-fetch on second call (cache hit)', async () => {
mockListAgentIntegrations.mockResolvedValue(fakeIntegrations);
const { useAgentIntegrationsCatalog } = await import(
'../composables/useAgentIntegrationsCatalog'
);
const { ensureLoaded } = useAgentIntegrationsCatalog();
await ensureLoaded(projectId);
const second = await ensureLoaded(projectId);
expect(mockListAgentIntegrations).toHaveBeenCalledTimes(1);
expect(second).toEqual(fakeIntegrations);
});
it('deduplicates concurrent in-flight requests', async () => {
let resolve!: (v: typeof fakeIntegrations) => void;
const deferred = new Promise<typeof fakeIntegrations>((res) => {
resolve = res;
});
mockListAgentIntegrations.mockReturnValue(deferred);
const { useAgentIntegrationsCatalog } = await import(
'../composables/useAgentIntegrationsCatalog'
);
const { ensureLoaded } = useAgentIntegrationsCatalog();
const p1 = ensureLoaded(projectId);
const p2 = ensureLoaded(projectId);
resolve(fakeIntegrations);
const [r1, r2] = await Promise.all([p1, p2]);
expect(mockListAgentIntegrations).toHaveBeenCalledTimes(1);
expect(r1).toEqual(fakeIntegrations);
expect(r2).toEqual(fakeIntegrations);
});
it('clears inFlight and re-throws on error', async () => {
mockListAgentIntegrations.mockRejectedValueOnce(new Error('network error'));
const { useAgentIntegrationsCatalog } = await import(
'../composables/useAgentIntegrationsCatalog'
);
const { ensureLoaded } = useAgentIntegrationsCatalog();
await expect(ensureLoaded(projectId)).rejects.toThrow('network error');
// After failure, a subsequent call should try again.
mockListAgentIntegrations.mockResolvedValue(fakeIntegrations);
const result = await ensureLoaded(projectId);
expect(mockListAgentIntegrations).toHaveBeenCalledTimes(2);
expect(result).toEqual(fakeIntegrations);
});
});

View File

@ -1,4 +1,4 @@
import type { AgentPersistedMessageDto } from '@n8n/api-types';
import type { AgentPersistedMessageDto, ChatIntegrationDescriptor } from '@n8n/api-types';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
import type { AgentResource, AgentJsonConfig } from '../types';
@ -292,3 +292,14 @@ export const deleteCustomTool = async (
`/projects/${projectId}/agents/v2/${agentId}/tools/${toolId}`,
);
};
export const listAgentIntegrations = async (
context: IRestApiContext,
projectId: string,
): Promise<ChatIntegrationDescriptor[]> => {
return await makeRestApiRequest<ChatIntegrationDescriptor[]>(
context,
'GET',
`/projects/${projectId}/agents/v2/catalog/integrations`,
);
};

View File

@ -0,0 +1,33 @@
import { ref } from 'vue';
import type { ChatIntegrationDescriptor } from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { listAgentIntegrations } from './useAgentApi';
const catalog = ref<ChatIntegrationDescriptor[] | null>(null);
let inFlight: Promise<ChatIntegrationDescriptor[]> | null = null;
export function useAgentIntegrationsCatalog() {
const rootStore = useRootStore();
async function ensureLoaded(projectId: string): Promise<ChatIntegrationDescriptor[]> {
if (catalog.value) return catalog.value;
if (!inFlight) {
inFlight = listAgentIntegrations(rootStore.restApiContext, projectId)
.then((list) => {
catalog.value = list;
inFlight = null;
return list;
})
.catch((err: unknown) => {
inFlight = null;
throw err;
});
}
return await inFlight;
}
return {
catalog,
ensureLoaded,
};
}

View File

@ -16,6 +16,7 @@ import { useToast } from '@/app/composables/useToast';
import { MODAL_CONFIRM, MODAL_CANCEL, getDebounceTime } from '@/app/constants';
import { deepCopy } from 'n8n-workflow';
import { getAgent, deleteAgent, publishAgent } from '../composables/useAgentApi';
import { useAgentIntegrationsCatalog } from '../composables/useAgentIntegrationsCatalog';
import type { AgentResource, AgentJsonConfig, AgentJsonToolRef } from '../types';
import { deriveAgentStatus } from '../composables/agentTelemetry.utils';
import { useAgentBuilderTelemetry } from '../composables/useAgentBuilderTelemetry';
@ -87,6 +88,8 @@ const { config, fetchConfig, updateConfig } = useAgentConfig();
const localConfig = ref<AgentJsonConfig | null>(null);
const connectedTriggers = ref<string[]>([]);
const { ensureLoaded: ensureIntegrationsCatalog } = useAgentIntegrationsCatalog();
const builderTelemetry = useAgentBuilderTelemetry({
agentId,
projectId,
@ -113,9 +116,6 @@ watch(
{ immediate: true },
);
// Keep in sync with AgentIntegrationsPanel.integrationConfigs
const KNOWN_TRIGGER_TYPES = ['slack', 'telegram'] as const;
async function fetchAgent() {
const data = await getAgent(rootStore.restApiContext, projectId.value, agentId.value);
agent.value = data;
@ -397,7 +397,9 @@ async function initialize() {
void (async () => {
// Non-fatal on failure, leave connectedTriggers empty; the sidebar emit
// will correct it once the user expands the Triggers section.
const connected = await builderTelemetry.fetchInitialTriggersBaseline(KNOWN_TRIGGER_TYPES);
const integrations = await ensureIntegrationsCatalog(projectId.value).catch(() => []);
const triggerTypes = integrations.map((i) => i.type);
const connected = await builderTelemetry.fetchInitialTriggersBaseline(triggerTypes);
if (connected) connectedTriggers.value = connected;
})();