refactor(core): Centralize agent draft model/credential validation (no-changelog) (#34775)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Robin Braumann
2026-07-23 14:05:53 +00:00
committed by GitHub
co-authored by Cursor
parent 05c691b0b7
commit 21f5097bfe
25 changed files with 346 additions and 52 deletions
@@ -508,3 +508,37 @@ describe('AgentJsonConfigSchema — skills', () => {
}
});
});
describe('AgentJsonConfigSchema — model/credential coupling', () => {
it('rejects a credential without a model', () => {
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
model: '',
credential: 'cred-id',
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.errors[0].path).toEqual(['credential']);
}
});
it('accepts a model without a credential', () => {
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
credential: undefined,
});
expect(result.success).toBe(true);
});
it('accepts a fully cleared draft', () => {
const result = AgentJsonConfigSchema.safeParse({
...minimalConfig,
model: '',
credential: '',
});
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,15 @@
/**
* Draft-lifecycle predicates. A capability is a "draft" while its setup is
* pending: the agent config until a model is chosen (`model: ""`), an
* integration entry until a credential is connected (`credentialId: ""`).
*/
/** True while no model has been chosen yet (setup pending). */
export function isDraftAgentConfig(config: { model?: string } | null | undefined): boolean {
return typeof config?.model !== 'string' || config.model.trim() === '';
}
/** True while no credential is connected yet (setup pending). */
export function isDraftIntegration(integration: { credentialId: string }): boolean {
return integration.credentialId.trim() === '';
}
@@ -14,6 +14,7 @@ export const agentCapabilityKindSchema = z.enum([
'skill',
'task',
'subAgent',
'vectorStore',
]);
export type AgentCapabilityKind = z.infer<typeof agentCapabilityKindSchema>;
@@ -1,5 +1,6 @@
import { z, type ZodError } from 'zod';
import { isDraftAgentConfig } from './agent-config-lifecycle';
import { AgentIntegrationConfigSchema } from './agent-integration.schema';
/**
* Regex for valid custom tool ids. Shared with the backend service layer
@@ -401,7 +402,12 @@ const AgentJsonToolConfigSchema = z.discriminatedUnion('type', [
NodeToolJsonConfigSchema,
]);
export const AgentJsonConfigSchema = z.object({
/**
* Unrefined agent config object shape. Use for schema derivation only
* (`.extend`, `.pick`, `.partial`, `.shape`) — validate with
* {@link AgentJsonConfigSchema} instead.
*/
export const AgentJsonConfigBaseSchema = z.object({
name: z.string().min(1).max(128),
model: DraftAgentModelSchema,
credential: z.string().optional(),
@@ -479,15 +485,23 @@ export const AgentJsonConfigSchema = z.object({
.optional(),
});
export const RunnableAgentJsonConfigSchema = AgentJsonConfigSchema.extend({
export const AgentJsonConfigSchema = AgentJsonConfigBaseSchema.superRefine((config, ctx) => {
if (config.credential?.trim() && isDraftAgentConfig(config)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['credential'],
message: 'A credential requires a model to be set',
});
}
});
export const RunnableAgentJsonConfigSchema = AgentJsonConfigBaseSchema.extend({
model: AgentModelSchema,
credential: z.string().refine((value) => value.trim().length > 0, {
message: 'Credential is required',
}),
});
export const AgentJsonConfigPartialSchema = AgentJsonConfigSchema.partial();
export type AgentJsonConfig = z.infer<typeof AgentJsonConfigSchema>;
export type RunnableAgentJsonConfig = z.infer<typeof RunnableAgentJsonConfigSchema>;
export type AgentJsonToolConfig = z.infer<typeof AgentJsonToolConfigSchema>;
+2 -1
View File
@@ -142,7 +142,8 @@ export class AgentChatResumeDto extends Z.class({
export class AgentDisconnectIntegrationDto extends Z.class({
type: z.string().min(1),
credentialId: z.string().min(1),
// Empty string targets a draft integration entry (`credentialId: ''`).
credentialId: z.string(),
}) {}
export class PublishAgentDto extends Z.class({
@@ -1,3 +1,4 @@
export * from './agent-config-lifecycle';
export * from './agent-config-validation.schema';
export * from './agent-files.constants';
export * from './agent-integration.schema';
@@ -1,7 +1,7 @@
import { z } from 'zod';
import {
AgentJsonConfigSchema,
AgentJsonConfigBaseSchema,
AgentModelSchema,
NodeToolJsonConfigSchema,
WorkflowToolJsonConfigSchema,
@@ -43,7 +43,7 @@ const InlineAgentSkillBodiesSchema = z.record(InlineSkillIdSchema, agentSkillSch
* only available on saved agents; runtime defaults apply. Skills are supported
* as refs here, with their bodies in the sibling `skills` record.
*/
export const InlineAgentJsonConfigSchema = AgentJsonConfigSchema.pick({
export const InlineAgentJsonConfigSchema = AgentJsonConfigBaseSchema.pick({
name: true,
model: true,
credential: true,
@@ -55,7 +55,7 @@ export const InlineAgentJsonConfigSchema = AgentJsonConfigSchema.pick({
// Approval suspends the run for a human, which workflow executions
// don't support — same reason the tool variants above omit
// `requireApproval`.
mcpServers: AgentJsonConfigSchema.shape.mcpServers.refine(
mcpServers: AgentJsonConfigBaseSchema.shape.mcpServers.refine(
(servers) => (servers ?? []).every((server) => server.approval === undefined),
{ message: 'MCP tool approval is not available for inline agents' },
),
@@ -1,6 +1,6 @@
import { z, type ZodDiscriminatedUnionOption } from 'zod';
import { AgentJsonConfigSchema } from './agent-json-config.schema';
import { AgentJsonConfigBaseSchema } from './agent-json-config.schema';
import { agentSkillSchema } from './agent-skill.schema';
const TYPED_ARRAY_CONFIG_KEYS = ['integrations', 'tools', 'skills', 'tasks'] as const;
@@ -190,7 +190,7 @@ function stripUnknownSchemaFields(value: unknown, schema: z.ZodTypeAny): unknown
/**
* Strip legacy or unsupported typed entries from agent JSON config before strict
* Zod validation. Unknown top-level keys are dropped from `AgentJsonConfigSchema`.
* Zod validation. Unknown top-level keys are dropped from `AgentJsonConfigBaseSchema`.
* This intentionally cleans unknown fields gracefully, so older persisted configs
* and generated drafts can move forward as the schema evolves.
*
@@ -202,12 +202,12 @@ export function sanitizeAgentJsonConfig(raw: unknown): unknown {
return raw;
}
const sanitized = stripUnknownSchemaFields(raw, AgentJsonConfigSchema);
const sanitized = stripUnknownSchemaFields(raw, AgentJsonConfigBaseSchema);
if (!isRecord(sanitized)) return sanitized;
for (const key of TYPED_ARRAY_CONFIG_KEYS) {
if (key in sanitized) {
const schema = getArrayElementSchema(AgentJsonConfigSchema.shape[key]);
const schema = getArrayElementSchema(AgentJsonConfigBaseSchema.shape[key]);
if (schema === undefined) continue;
sanitized[key] = filterUnsupportedTypedEntries(
@@ -635,6 +635,48 @@ describe('AgentIntegrationsController integration credentials', () => {
);
});
it('disconnects a draft integration entry with an empty credentialId', async () => {
const agentRepository = mock<AgentRepository>();
const agent = {
id: 'agent-1',
projectId: 'project-1',
integrations: [{ type: 'slack', credentialId: '' }],
};
agentRepository.findByIdAndProjectId.mockResolvedValue(agent as never);
const chatIntegrationService = mock<ChatIntegrationService>();
const agentIntegrationPersistenceService = mock<AgentIntegrationPersistenceService>();
const { controller } = makeController({
agentRepository,
chatIntegrationService,
agentIntegrationPersistenceService,
});
await expect(
controller.disconnectIntegration(
{
params: { projectId: 'project-1' },
user: { id: 'user-1' },
body: { type: 'slack', credentialId: '' },
} as never,
undefined as never,
'agent-1',
{ type: 'slack', credentialId: '' },
),
).resolves.toEqual({ status: 'disconnected' });
expect(chatIntegrationService.disconnectChannel).toHaveBeenCalledWith('agent-1', {
type: 'slack',
credentialId: '',
});
expect(agentIntegrationPersistenceService.removeCredentialIntegration).toHaveBeenCalledWith(
agent,
'slack',
'',
{ broadcast: false },
);
});
it('starts Slack app setup with the temporary app configuration token', async () => {
const slackAppSetupService = mock<SlackAppSetupService>();
slackAppSetupService.createApp.mockResolvedValue({
@@ -548,6 +548,57 @@ describe('AgentValidationService — structured issues', () => {
);
});
it('flags a vector store whose derived tool name collides with a configured tool', async () => {
const { service, agentRepository } = makeService();
agentRepository.findByIdAndProjectId.mockResolvedValue(
makeAgent(
{
...runnableConfig,
tools: [{ type: 'custom', id: 'search_product_docs' }],
vectorStores: [
{
provider: 'qdrant',
name: 'product-docs',
credential: 'qdrant-cred',
useWhen: 'Search product docs',
embedding: {
model: 'openai/text-embedding-3-small',
credential: 'embed-cred',
},
collectionName: 'product-docs',
},
],
},
{},
{
tools: {
search_product_docs: {
code: '',
descriptor: { name: 'search_product_docs' },
},
} as unknown as Agent['tools'],
},
),
);
const result = await service.validateAgentConfiguration(
agentId,
projectId,
makeCredentialProvider([{ id: 'openai-main', type: 'openAiApi' }]),
);
expect(result.status).toBe('invalid');
expect(result.issues).toEqual(
expect.arrayContaining([
{
code: 'invalid_value',
path: 'vectorStores.0.name',
capability: { kind: 'vectorStore', id: 'product-docs', index: 0 },
},
]),
);
});
it('flags an enabled task with an invalid schedule while keeping its body available, but ignores the same invalid body on a disabled task', async () => {
const { service, agentRepository, agentTaskRepository } = makeService();
agentTaskRepository.findByAgentId.mockResolvedValue([
@@ -1007,10 +1007,28 @@ describe('AgentsBuilderToolsService', () => {
expect(result).toEqual({
ok: false,
errors: expect.arrayContaining([
expect.objectContaining({ path: 'model' }),
expect.objectContaining({ path: 'credential' }),
]),
errors: expect.arrayContaining([expect.objectContaining({ path: 'model' })]),
});
expect(agentsService.updateConfig).not.toHaveBeenCalled();
});
it('write_config rejects a non-string model with a structured error instead of throwing', async () => {
const { service, agentsService } = makeService();
const currentConfig = { ...baseConfig, integrations: [] };
const malformedConfig = { ...currentConfig, model: 123 };
agentsService.findById.mockResolvedValue(makeAgent(baseConfig));
const result = await getJsonTool(service, BUILDER_TOOLS.WRITE_CONFIG).handler!(
{
baseConfigHash: getAgentConfigHash(currentConfig),
json: JSON.stringify(malformedConfig),
},
ctx,
);
expect(result).toEqual({
ok: false,
errors: expect.arrayContaining([expect.objectContaining({ path: 'model' })]),
});
expect(agentsService.updateConfig).not.toHaveBeenCalled();
});
@@ -1,5 +1,6 @@
import {
AgentIntegrationSchema,
isDraftIntegration,
type AgentIntegrationConfig,
type ChatIntegrationDescriptor,
} from '@n8n/api-types';
@@ -66,7 +67,7 @@ export class AgentIntegrationPersistenceService {
const validated = parseResult.data;
const { type, credentialId } = validated;
if (credentialId === '') {
if (isDraftIntegration(validated)) {
throw new UserError('Credential integration requires a credential ID.');
}
@@ -74,7 +75,7 @@ export class AgentIntegrationPersistenceService {
// before setup completes) so connecting a real credential replaces it
// instead of leaving both the draft and the connected entry behind.
const existing = (agent.integrations ?? []).filter(
(i) => !(i.type === type && i.credentialId === ''),
(i) => !(i.type === type && isDraftIntegration(i)),
);
const alreadyExists = existing.some((i) => i.type === type && i.credentialId === credentialId);
@@ -1,6 +1,7 @@
import {
AgentDisconnectIntegrationDto,
AgentIntegrationSchema,
isDraftIntegration,
type AgentIntegrationStatusResponse,
CreateSlackAgentAppDto,
type CreateSlackAgentAppResponse,
@@ -220,7 +221,7 @@ export class AgentIntegrationsController {
// them as disconnected so channel-setup UIs don't render an already-
// connected state and hide their own setup form.
const chatIntegrations = (agent.integrations ?? [])
.filter((i) => i.credentialId !== '')
.filter((i) => !isDraftIntegration(i))
.map((i) => ({
type: i.type,
credentialId: i.credentialId,
@@ -6,7 +6,7 @@ import { Service } from '@n8n/di';
import { isModelDiscoveryProvider } from '@n8n/ai-utilities/model-discovery';
import { BuilderModelLiveLookupService } from './builder/builder-model-live-lookup.service';
import { LLM_PROVIDER_DEFAULTS } from './builder/interactive/llm-provider-defaults';
import { LLM_PROVIDER_DEFAULTS } from './llm-provider-defaults';
/** Google's models API returns ids as `models/<id>`; the AI SDK expects the bare id. */
const GOOGLE_MODEL_ID_PREFIX = 'models/';
@@ -1,5 +1,6 @@
import {
type AgentConfigValidationResponse,
isDraftIntegration,
type AgentJsonConfig,
type AgentSkill,
type AgentVersionListItemDto,
@@ -202,7 +203,7 @@ export class AgentPublishService {
const baseIntegrations = agent.integrations ?? [];
const integrations = ignoreDraftIntegrations
? baseIntegrations.filter((integration) => integration.credentialId !== '')
? baseIntegrations.filter((integration) => !isDraftIntegration(integration))
: baseIntegrations;
const validation = targetHistory
@@ -4,6 +4,9 @@ import { getRequiredNodeCredentialSlots } from '@n8n/ai-utilities/node-catalog';
import {
AgentModelSchema,
agentTaskSchema,
findVectorStoreToolNameCollisions,
isDraftAgentConfig,
isDraftIntegration,
type AgentConfigValidationIssue,
type AgentConfigValidationIssueCode,
type AgentConfigValidationResponse,
@@ -20,7 +23,7 @@ import { isMcpOAuth2Authentication, NodeHelpers, type INodeParameters } from 'n8
import { getMissingSkillIds } from '@/modules/agents/utils/agent-missing-skill-ids';
import { NodeTypes } from '@/node-types';
import { LLM_PROVIDER_DEFAULTS } from './builder/interactive/llm-provider-defaults';
import { LLM_PROVIDER_DEFAULTS } from './llm-provider-defaults';
import type { AgentHistory } from './entities/agent-history.entity';
import type { Agent } from './entities/agent.entity';
import { ChatIntegrationRegistry } from './integrations/agent-chat-integration';
@@ -255,6 +258,7 @@ export class AgentValidationService {
const { agentsById, workflowsByName } = await this.prefetchReferenceLookups(ctx);
this.collectCoreIssues(config, issues);
this.collectVectorStoreIssues(config, issues);
await this.collectMainCredentialIssues(config, findCredential, issues);
this.collectSubAgentRefIssues(ctx, agentsById, issues);
this.collectSkillIssues(config, ctx.skills, issues);
@@ -315,7 +319,7 @@ export class AgentValidationService {
issues.push(agentIssue('missing_required', 'instructions'));
}
if (!config.model?.trim()) {
if (isDraftAgentConfig(config)) {
issues.push(agentIssue('missing_required', 'model'));
} else if (!AgentModelSchema.safeParse(config.model).success) {
issues.push(agentIssue('invalid_value', 'model'));
@@ -413,6 +417,29 @@ export class AgentValidationService {
}
}
/**
* A vector store registers a `search_<sanitized-name>` tool at runtime; a
* collision with a configured tool name only fails once the agent is built.
* The write gate (AgentConfigService.validateConfig) checks this too — this
* re-check covers configs that reached the entity through other paths
* (e.g. history restore).
*/
private collectVectorStoreIssues(config: AgentJsonConfig, issues: AgentConfigValidationIssue[]) {
const collisions = new Set(findVectorStoreToolNameCollisions(config));
const stores = config.vectorStores ?? [];
for (let index = 0; index < stores.length; index++) {
const store = stores[index];
if (!collisions.has(`search_${store.name.replace(/-/g, '_')}`)) continue;
issues.push(
issue('invalid_value', `vectorStores.${index}.name`, {
kind: 'vectorStore',
id: store.name,
index,
}),
);
}
}
private async collectChannelIssues(
integrations: AgentIntegrationConfig[],
findCredential: FindCredential,
@@ -426,12 +453,11 @@ export class AgentValidationService {
id: integration.type,
index,
};
const credentialId = integration.credentialId?.trim();
if (!credentialId) {
if (isDraftIntegration(integration)) {
issues.push(issue('missing_credential', path, capability));
continue;
}
const credentialId = integration.credentialId.trim();
const credential = await this.findCredentialSafe(findCredential, credentialId);
if (!credential) {
@@ -15,7 +15,8 @@ import {
PROVIDER_CAPABILITIES,
resolvePromptCaching,
AgentJsonConfigSchema,
RunnableAgentJsonConfigSchema,
isDraftAgentConfig,
isDraftIntegration,
sanitizeAgentJsonConfig,
tryParseConfigJson,
type AgentJsonConfig,
@@ -137,14 +138,28 @@ function snapshotFromConfig(config: AgentJsonConfig | null): AgentConfigSnapshot
}
/**
* Draft writes (empty `model`, no `credential`) are only for agents that
* don't have a model yet. Once the stored config has a model, require the
* runnable schema so a builder write can't wipe it back into an unrunnable
* draft.
* Once the stored config has a model, a builder write can't clear it back to
* a draft (`model: ""`). A missing credential does NOT reject the write —
* it surfaces as a `missing_credential` validation issue instead.
*/
function parseBuilderWriteConfig(incoming: unknown, currentConfig: AgentJsonConfig | null) {
const schema = currentConfig?.model ? RunnableAgentJsonConfigSchema : AgentJsonConfigSchema;
return schema.safeParse(sanitizeAgentJsonConfig(incoming));
const sanitized = sanitizeAgentJsonConfig(incoming);
if (
!isDraftAgentConfig(currentConfig) &&
isDraftAgentConfig(sanitized as { model?: string } | null | undefined)
) {
return {
success: false as const,
error: new z.ZodError([
{
code: z.ZodIssueCode.custom,
path: ['model'],
message: 'Model cannot be cleared once set',
},
]),
};
}
return AgentJsonConfigSchema.safeParse(sanitized);
}
/**
@@ -626,8 +641,8 @@ export class AgentsBuilderToolsService {
listIntegrationCredentialIds: async () => {
const agent = await this.agentsService.findById(agentId, projectId);
return (agent?.integrations ?? [])
.map((integration) => integration.credentialId)
.filter((credentialId) => credentialId.length > 0);
.filter((integration) => !isDraftIntegration(integration))
.map((integration) => integration.credentialId);
},
}),
buildAskEmbeddingCredentialTool({
@@ -657,8 +672,8 @@ export class AgentsBuilderToolsService {
listIntegrationCredentialIds: async () => {
const agent = await this.agentsService.findById(agentId, projectId);
return (agent?.integrations ?? [])
.map((integration) => integration.credentialId)
.filter((credentialId) => credentialId.length > 0);
.filter((integration) => !isDraftIntegration(integration))
.map((integration) => integration.credentialId);
},
listChatIntegrationTypes: () =>
this.agentIntegrationPersistenceService
@@ -1,7 +1,7 @@
import type { CredentialListItem, CredentialProvider } from '@n8n/agents';
import type { Mock } from 'vitest';
import { LLM_PROVIDER_DEFAULTS, LLM_PROVIDER_PRIORITY } from '../llm-provider-defaults';
import { LLM_PROVIDER_DEFAULTS, LLM_PROVIDER_PRIORITY } from '../../../llm-provider-defaults';
import type { FreeCreditsProvisioner, ModelLookup } from '../resolve-llm.tool';
import { buildResolveLlmTool } from '../resolve-llm.tool';
@@ -8,7 +8,7 @@ import {
LLM_PROVIDER_DEFAULTS,
LLM_PROVIDER_PRIORITY,
type LlmProviderDefault,
} from './llm-provider-defaults';
} from '../../llm-provider-defaults';
export interface ModelLookup {
list(
@@ -1,4 +1,4 @@
import { AgentJsonConfigSchema, AgentModelSchema } from '@n8n/api-types';
import { AgentJsonConfigBaseSchema, AgentModelSchema } from '@n8n/api-types';
import type { JSONSchema7 } from 'json-schema';
import type { ZodObject, ZodRawShape } from 'zod';
import { z } from 'zod';
@@ -41,7 +41,7 @@ const BuilderPromptMemoryConfigSchema = z.object({
.optional(),
});
const BuilderPromptAgentJsonConfigSchema = AgentJsonConfigSchema.extend({
const BuilderPromptAgentJsonConfigSchema = AgentJsonConfigBaseSchema.extend({
memory: BuilderPromptMemoryConfigSchema.optional(),
});
@@ -22,11 +22,15 @@ describe('sanitizeUnknownAgentCredentials', () => {
it('preserves known credential fields', () => {
const result = sanitizeUnknownAgentCredentials(
{ credential: 'known-cred', name: 'Agent' },
{ credential: 'known-cred', model: 'anthropic/claude-sonnet-4-5', name: 'Agent' },
accessibleCredentialIds,
);
expect(result).toEqual({ credential: 'known-cred', name: 'Agent' });
expect(result).toEqual({
credential: 'known-cred',
model: 'anthropic/claude-sonnet-4-5',
name: 'Agent',
});
});
it('preserves managed proxy credential tokens only for episodic memory embeddings', () => {
@@ -269,4 +273,25 @@ describe('sanitizeUnknownAgentCredentials', () => {
'credential',
);
});
it('clears a top-level credential when model is empty', () => {
const result = sanitizeUnknownAgentCredentials(
{ model: '', credential: 'known-cred' },
accessibleCredentialIds,
);
expect(result).toEqual({ model: '', credential: '' });
});
it('preserves a top-level credential when model is set', () => {
const result = sanitizeUnknownAgentCredentials(
{ model: 'anthropic/claude-sonnet-4-5', credential: 'known-cred' },
accessibleCredentialIds,
);
expect(result).toEqual({
model: 'anthropic/claude-sonnet-4-5',
credential: 'known-cred',
});
});
});
@@ -1,4 +1,4 @@
import { MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
import { isDraftAgentConfig, MANAGED_CREDENTIAL_TOKEN } from '@n8n/api-types';
function clearUnknownCredentialId(
credentialId: unknown,
@@ -99,6 +99,10 @@ function sanitizeUnknownCredentialsInValue(
* Replace credential IDs that are not accessible to the agent project with `""`.
* Walks the config recursively and only targets credential-like fields:
* `credential`, `credentialId`, and `credentials.*.id`.
*
* A top-level credential without a model is also cleared so legacy rows
* converge to a clean draft instead of failing the `credential ⇒ model`
* schema refine.
*/
export function sanitizeUnknownAgentCredentials(
raw: unknown,
@@ -108,5 +112,23 @@ export function sanitizeUnknownAgentCredentials(
return raw;
}
return sanitizeUnknownCredentialsInValue(raw, accessibleCredentialIds);
const sanitized = sanitizeUnknownCredentialsInValue(raw, accessibleCredentialIds);
if (!isRecord(sanitized)) {
return sanitized;
}
const model = typeof sanitized.model === 'string' ? sanitized.model : undefined;
if (
isDraftAgentConfig({ model }) &&
typeof sanitized.credential === 'string' &&
sanitized.credential !== ''
) {
sanitized.credential = '';
}
return sanitized;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -1,9 +1,13 @@
import { mount } from '@vue/test-utils';
import { mount, VueWrapper } from '@vue/test-utils';
import { ref } from 'vue';
import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import AgentChannelModal from '../components/AgentChannelModal.vue';
type SlackSetupStubWrapper = VueWrapper<{
disconnectSlackApp: () => Promise<void>;
}>;
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string) => key,
@@ -15,6 +19,8 @@ const catalog = ref([
{ type: 'linear', label: 'Linear', icon: 'zap' },
]);
const disconnectMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
vi.mock('../composables/useAgentIntegrationsCatalog', () => ({
useAgentIntegrationsCatalog: () => ({
catalog,
@@ -32,7 +38,7 @@ vi.mock('../composables/useAgentIntegrationStatus', () => ({
errorIsConflict: ref({}),
isConnected: () => false,
connect: vi.fn(),
disconnect: vi.fn(),
disconnect: disconnectMock,
}),
}));
@@ -79,7 +85,7 @@ function mountModal(props: Record<string, unknown>) {
N8nText: { template: '<span><slot /></span>' },
AgentChannelListItem: { template: '<li data-testid="channel-list-item" />' },
AgentChannelSlackSetup: {
props: ['mode'],
props: ['mode', 'disconnectSlackApp'],
template: '<div data-testid="slack-setup" :data-mode="mode" />',
},
AgentChannelLinearSetup: {
@@ -96,6 +102,10 @@ function mountModal(props: Record<string, unknown>) {
}
describe('AgentChannelModal', () => {
beforeEach(() => {
disconnectMock.mockClear();
});
it('renders the channel list for the list view', () => {
const wrapper = mountModal({ view: 'list' });
@@ -115,4 +125,20 @@ describe('AgentChannelModal', () => {
const linearSetup = wrapper.find('[data-testid="linear-setup"]');
expect(linearSetup.attributes('data-mode')).toBe('edit');
});
it('disconnects a draft slack channel and closes the modal', async () => {
const wrapper = mountModal({
view: 'slack_edit',
connectedChannels: ['slack'],
});
const slackSetup = wrapper.findComponent(
'[data-testid="slack-setup"]',
) as SlackSetupStubWrapper;
await slackSetup.vm.disconnectSlackApp();
expect(disconnectMock).toHaveBeenCalledWith('slack', '');
expect(wrapper.emitted('channel-disconnected')).toEqual([['slack']]);
expect(wrapper.emitted('update:open')).toEqual([[false]]);
});
});
@@ -207,9 +207,9 @@ async function setupSlackApp(appConfigurationToken: string): Promise<boolean> {
}
async function handleDisconnected(channelType: string) {
const credentialId = connectedCredentials.value[channelType];
if (!credentialId) return;
// Draft channels (configured but missing a credential) have no connected
// credential — send '' so the backend removes the draft entry by type.
const credentialId = connectedCredentials.value[channelType] ?? '';
await disconnect(channelType, credentialId);
emit('channel-disconnected', channelType);
emit('agent-changed');