Merge branch 'master' into ds-554-update-n8n-dialog

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sebastien Powell 2026-10-16 10:48:28 +01:00
commit c3a3a7a5df
56 changed files with 1726 additions and 260 deletions

File diff suppressed because one or more lines are too long

View File

@ -43,7 +43,8 @@
"validator": "catalog:",
"xss": "catalog:",
"@n8n/permissions": "workspace:*",
"@n8n/inbound-auth": "workspace:*"
"@n8n/inbound-auth": "workspace:*",
"@n8n/utils": "workspace:*"
},
"peerDependencies": {
"zod": "catalog:"

View File

@ -1,3 +1,5 @@
import { oklchToRgb, rgbToHex } from '@n8n/utils/color/oklch';
import { DEFAULT_AGENT_PERSONALISATION, type AgentJsonConfig } from './agent-json-config.schema';
export type AgentPersonalisation = NonNullable<AgentJsonConfig['personalisation']>;
@ -17,35 +19,8 @@ const randomInRange = (random: () => number, min: number, max: number) =>
const randomInteger = (random: () => number, min: number, max: number) =>
Math.round(randomInRange(random, min, max));
function hexChannel(value: number) {
return Math.round(clamp(value, 0, 1) * 255)
.toString(16)
.padStart(2, '0')
.toUpperCase();
}
function linearSrgbToSrgb(value: number) {
return value >= 0.0031308 ? 1.055 * Math.pow(value, 1 / 2.4) - 0.055 : 12.92 * value;
}
function oklchToHex(lightness: number, chroma: number, hue: number) {
const hueRadians = (hue * Math.PI) / 180;
const a = chroma * Math.cos(hueRadians);
const b = chroma * Math.sin(hueRadians);
const l_ = lightness + 0.3963377774 * a + 0.2158037573 * b;
const m_ = lightness - 0.1055613458 * a - 0.0638541728 * b;
const s_ = lightness - 0.0894841775 * a - 1.291485548 * b;
const l = l_ ** 3;
const m = m_ ** 3;
const s = s_ ** 3;
const red = linearSrgbToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s);
const green = linearSrgbToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s);
const blue = linearSrgbToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s);
return `#${[red, green, blue].map(hexChannel).join('')}`;
return rgbToHex(oklchToRgb([lightness, chroma, hue]));
}
function randomComplementaryColors(random: () => number) {
@ -83,6 +58,15 @@ export function getRandomAgentPersonalisationGradient(
};
}
export function resolveAgentPersonalisation(
value?: Partial<AgentPersonalisation> | null,
): AgentPersonalisation {
return {
icon: value?.icon ?? DEFAULT_AGENT_PERSONALISATION_ICON,
gradient: { ...DEFAULT_AGENT_PERSONALISATION.gradient, ...value?.gradient },
};
}
export function addMissingAgentPersonalisation(
config: AgentJsonConfig,
random?: () => number,

View File

@ -117,6 +117,12 @@ export class ListSkillsQueryDto extends Z.class({
attachableTo: z.string().max(36).optional(),
}) {}
/**
* A skills hub name, trimmed first. A name of only spaces is refused, and padding is not
* stored, so it cannot make a name look free or show as an untitled row.
*/
const skillNameSchema = z.string().trim().min(1).max(AGENT_SKILL_NAME_MAX_LENGTH);
/**
* Asks for a name that no skill in the scope has yet, as `/workflows/new` does for a
* workflow: the requested name, or the same name with the first free number after it.
@ -127,8 +133,7 @@ export class SkillNewNameQueryDto extends Z.class({
projectId: z.string().max(36).optional(),
/** Required for an agent skill, absent otherwise. */
agentId: z.string().max(36).optional(),
/** Trimmed first, so a name of only spaces is refused instead of suggested as "". */
name: z.string().trim().min(1).max(AGENT_SKILL_NAME_MAX_LENGTH),
name: skillNameSchema,
}) {}
export type SkillNewNameResponse = { name: string };
@ -143,7 +148,7 @@ export class CreateSkillDto extends Z.class({
/** Required for an agent skill, absent otherwise. */
agentId: z.string().max(36).optional(),
/** Hub skills ignore `allowedTools`, so a create that sends it is refused. */
skill: agentSkillSchema.omit({ allowedTools: true }),
skill: agentSkillSchema.omit({ allowedTools: true }).extend({ name: skillNameSchema }),
}) {}
/**
@ -151,7 +156,7 @@ export class CreateSkillDto extends Z.class({
* started from: a save based on an older version is refused with 409.
*/
export class SaveSkillDto extends Z.class({
name: agentSkillShape.name.optional(),
name: skillNameSchema.optional(),
description: agentSkillShape.description.optional(),
instructions: agentSkillShape.instructions.optional(),
references: agentSkillShape.references.optional(),

View File

@ -0,0 +1,46 @@
import { hexToRgb, oklchMixer, oklchToRgb, rgbToHex, rgbToOklch } from './oklch';
describe('oklch', () => {
test.each(['#FF0000', '#2563EB', '#00C0CC', '#FFFFFF', '#000000', '#808080'])(
'round-trips %s through oklch',
(hex) => {
expect(rgbToHex(oklchToRgb(rgbToOklch(hexToRgb(hex))))).toBe(hex);
},
);
it('matches the CSS oklch value of red', () => {
const [lightness, chroma, hue] = rgbToOklch(hexToRgb('#FF0000'));
expect(lightness).toBeCloseTo(0.628, 3);
expect(chroma).toBeCloseTo(0.2577, 3);
expect(hue).toBeCloseTo(29.23, 1);
});
it('clips colours outside the sRGB gamut', () => {
expect(oklchToRgb([0.9, 0.4, 145]).every((channel) => channel >= 0 && channel <= 255)).toBe(
true,
);
});
describe('oklchMixer', () => {
it('returns the end colours at 0 and 1', () => {
const mix = oklchMixer('#FF8000', '#0080FF');
expect(rgbToHex(mix(0))).toBe('#FF8000');
expect(rgbToHex(mix(1))).toBe('#0080FF');
});
it('keeps complementary colours saturated in the middle', () => {
// Mixed in sRGB, orange and azure meet at a flat grey.
const middle = oklchMixer('#FF8000', '#0080FF')(0.5);
expect(Math.max(...middle) - Math.min(...middle)).toBeGreaterThan(100);
});
it('takes the hue of the other colour when one end is grey', () => {
const [, , hue] = rgbToOklch(oklchMixer('#808080', '#2563EB')(0.5));
expect(hue).toBeCloseTo(rgbToOklch(hexToRgb('#2563EB'))[2], 0);
});
});
});

View File

@ -0,0 +1,62 @@
/**
* sRGB ⇄ oklch conversions, using Björn Ottosson's matrices. Channels are
* 0..255; oklch is [lightness 0..1, chroma, hue in degrees].
*/
export type Rgb = [number, number, number];
export type Oklch = [number, number, number];
const clamp = (value: number) => Math.min(Math.max(value, 0), 1);
const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
const fromLinear = (c: number) => (c >= 0.0031308 ? 1.055 * c ** (1 / 2.4) - 0.055 : 12.92 * c);
/** Reads `#RRGGBB`. */
export function hexToRgb(hex: string): Rgb {
const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16);
return [channel(1), channel(3), channel(5)];
}
/** Writes `#RRGGBB` in upper case. */
export function rgbToHex(rgb: Rgb): string {
return `#${rgb.map((channel) => channel.toString(16).padStart(2, '0')).join('')}`.toUpperCase();
}
export function rgbToOklch([r, g, b]: Rgb): Oklch {
const [lr, lg, lb] = [r, g, b].map((c) => toLinear(c / 255));
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
return [L, Math.hypot(A, B), (Math.atan2(B, A) * 180) / Math.PI];
}
/** Clips to the sRGB gamut. */
export function oklchToRgb([lightness, chroma, hue]: Oklch): Rgb {
const hueRadians = (hue * Math.PI) / 180;
const a = chroma * Math.cos(hueRadians);
const b = chroma * Math.sin(hueRadians);
const l = (lightness + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (lightness - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (lightness - 0.0894841775 * a - 1.291485548 * b) ** 3;
const channel = (value: number) => Math.round(clamp(fromLinear(value)) * 255);
return [
channel(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
channel(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
channel(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
];
}
/**
* Mixes two colours in oklch along the shorter hue arc, as CSS
* `color-mix(in oklch, …)` and `linear-gradient(in oklch, …)` do.
*/
export function oklchMixer(from: string, to: string): (progress: number) => Rgb {
const [l1, c1, h1] = rgbToOklch(hexToRgb(from));
const [l2, c2, h2] = rgbToOklch(hexToRgb(to));
// A grey has no hue of its own, so it takes the other colour's.
const [hueFrom, hueTo] = [c1 < 1e-4 ? h2 : h1, c2 < 1e-4 ? h1 : h2];
const hueDelta = ((hueTo - hueFrom + 540) % 360) - 180;
return (t) => oklchToRgb([l1 + (l2 - l1) * t, c1 + (c2 - c1) * t, hueFrom + hueDelta * t]);
}

View File

@ -145,6 +145,7 @@
"@chat-adapter/whatsapp": "catalog:",
"@google-cloud/secret-manager": "5.6.0",
"@grpc/grpc-js": "^1.14.3",
"@iconify-json/lucide": "catalog:",
"@joplin/turndown-plugin-gfm": "catalog:",
"@langchain/core": "catalog:",
"@modelcontextprotocol/node": "catalog:",
@ -201,6 +202,7 @@
"@parcel/watcher": "^2.5.1",
"@pinecone-database/pinecone": "catalog:",
"@qdrant/js-client-rest": "catalog:",
"@resvg/resvg-wasm": "2.6.2",
"@rudderstack/rudder-sdk-node": "catalog:",
"@sentry/node": "catalog:sentry",
"@supabase/supabase-js": "catalog:",

View File

@ -608,6 +608,75 @@ describe('AgentsBuilderToolsService', () => {
expect(result).toEqual(expect.objectContaining({ ok: true, configMutated: true, agentId }));
});
it('finish_setup writes the resolved credential into the empty node-tool slots of its type', async () => {
const { service, agentsService, nodeTypes } = makeService();
const gmailTool = (
name: string,
credentials?: Record<string, { id: string; name: string }>,
) => ({
type: 'node' as const,
name,
description: name,
node: {
nodeType: 'n8n-nodes-base.gmailTool',
nodeTypeVersion: 2.2,
nodeParameters: {},
credentials,
},
});
const ownCredential = { gmailOAuth2: { id: 'own', name: 'Own account' } };
let stored: AgentJsonConfig = {
...baseConfig,
tools: [gmailTool('search'), gmailTool('reply'), gmailTool('archive', ownCredential)],
};
agentsService.listChatIntegrations.mockReturnValue([]);
agentsService.findById.mockImplementation(async () => makeAgent(stored));
agentsService.updateConfig.mockImplementation(async (_agentId, _projectId, config) => {
stored = config as AgentJsonConfig;
return {
config: stored,
configHash: getAgentConfigHash(stored),
updatedAt: '2026-01-02T00:00:00.000Z',
versionId: 'v2',
};
});
nodeTypes.getByNameAndVersion.mockReturnValue({
description: {
displayName: 'Gmail Tool',
name: 'n8n-nodes-base.gmailTool',
group: [],
version: 2.2,
description: '',
defaults: {},
inputs: [],
outputs: [],
properties: [],
credentials: [{ name: 'gmailOAuth2' }],
},
} as unknown as ReturnType<NodeTypes['getByNameAndVersion']>);
credentialService.list.mockResolvedValue([
{ id: 'g1', name: 'Gmail account', type: 'gmailOAuth2' },
]);
const tool = getJsonTool(service, BUILDER_TOOLS.FINISH_SETUP);
const result = await tool.handler!(
{ credentialRequests: [{ credentialType: 'gmailOAuth2', purpose: 'Order emails' }] },
ctx as never,
);
const resolved = { gmailOAuth2: { id: 'g1', name: 'Gmail account' } };
expect(
stored.tools?.map((t) => (t.type === 'node' ? t.node.credentials : undefined)),
).toEqual([resolved, resolved, ownCredential]);
expect(result).toEqual(
expect.objectContaining({
completed: true,
credentialsWrittenToTools: ['gmailOAuth2'],
configHash: getAgentConfigHash(stored),
}),
);
});
it('patch_config saves a removed top-level field as removed', async () => {
const { service, agentsService } = makeService();
const telegram: AgentIntegrationConfig = {

View File

@ -379,6 +379,30 @@ describe('SkillService', () => {
expect(skills.insertSavedVersion).not.toHaveBeenCalled();
});
it('trims a new name before it checks and stores it', async () => {
const baseSkillHash = givenLatest(versionRow(skill, 2));
const result = await service.save('skill_a', { baseSkillHash, name: ' Renamed ' }, user.id);
expect(result.skill.name).toBe('Renamed');
expect(skills.insertSavedVersion).toHaveBeenCalledWith(
'skill_a',
3,
expect.objectContaining({ name: 'Renamed' }),
user.id,
expect.anything(),
);
});
it('refuses a new name of only spaces', async () => {
const baseSkillHash = givenLatest(versionRow(skill, 2));
await expect(
service.save('skill_a', { baseSkillHash, name: ' ' }, user.id),
).rejects.toThrow('Skill name is required.');
expect(skills.insertSavedVersion).not.toHaveBeenCalled();
});
it('does not check the name when the save keeps it', async () => {
const baseSkillHash = givenLatest(versionRow(skill, 2));

View File

@ -27,7 +27,7 @@ import { composeJsonConfig } from './json-config/agent-config-composition';
import type { AgentSkillRefs } from './json-config/agent-document';
import { AgentRepository } from './repositories/agent.repository';
import { findIntroducedNameClashes, findNameClashGroups } from './skills/skill-names';
import { SkillService } from './skills/skill.service';
import { SkillService, withTrimmedName } from './skills/skill.service';
import { getAgentConfigHash, getAgentSkillHash } from './utils/agent-config-hash';
import { markAgentDraftDirty, saveAgentDraftFenced } from './utils/agent-draft.utils';
import { getAgentOrThrow } from './utils/get-agent-or-throw';
@ -134,10 +134,13 @@ export class AgentSkillsService {
);
if (!entity.schema) throw new UserError('Agent has no JSON config yet.');
// With the hub off, skills keep `allowedTools` as on master. Hub skills ignore it.
// With the hub off, skills keep `allowedTools` as on master. Hub skills ignore it, and
// get the trimmed name the hub stores, so the answer and its hash match the saved skill.
const bodies = skills.map((skill) => {
const validated = this.validateSkill(skill);
return this.agentSkillRefs.usesSkillsHub ? toStoredSkill(validated) : validated;
return this.agentSkillRefs.usesSkillsHub
? withTrimmedName(toStoredSkill(validated))
: validated;
});
if (this.agentSkillRefs.usesSkillsHub) {
await this.assertCanCreateSkills(context.user, entity.projectId, availableTo);

View File

@ -86,13 +86,20 @@ import {
buildFinishSetupTool,
buildResolveLlmTool,
} from './interactive';
import type {
NodeToolCredentialWriteResult,
ResolvedCredential,
} from './interactive/finish-setup.tool';
import type { ModelLookup } from './interactive/resolve-llm.tool';
import { SKILL_BODY_GUIDANCE, SKILL_DESCRIPTION_RULE } from './skill-body-template';
import { TASK_OBJECTIVE_GUIDANCE } from './task-objective-template';
import { buildVerifyMcpServerTool, type McpCredentialApplyResult } from './verify-mcp-server.tool';
import { AgentSkillRefsService } from '../agent-skill-refs.service';
import { composeJsonConfig } from '../json-config/agent-config-composition';
import { listAiGatewayManagedCredentialTypes } from '../json-config/reconcile-node-tool-gateway-credentials';
import {
assignCredentialToNodeTools,
listAiGatewayManagedCredentialTypes,
} from '../json-config/reconcile-node-tool-gateway-credentials';
import { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { getAgentConfigHash, isSameAgentConfig } from '../utils/agent-config-hash';
import { SkillNameTakenError, skillNameTakenToolMessage } from '../skills/skill-name-taken.error';
@ -559,7 +566,7 @@ export class AgentsBuilderToolsService {
),
this.withConfigMutationMarker(
this.withConfigSnapshotOnResume(
this.createFinishSetupTool(credentialService, agentId, projectId, track),
this.createFinishSetupTool(credentialService, agentId, projectId, user, track),
agentId,
projectId,
(result) => result.completed === true,
@ -595,6 +602,7 @@ export class AgentsBuilderToolsService {
credentialService: InstanceAiCredentialService,
agentId: string,
projectId: string,
user: User,
track: BuilderTrackFn,
): BuiltTool {
return buildFinishSetupTool({
@ -602,6 +610,8 @@ export class AgentsBuilderToolsService {
agentId,
projectId,
track,
writeCredentialsToNodeTools: async (credentials) =>
await this.writeCredentialsToNodeTools(agentId, projectId, user, credentials),
isCredentialTypeKnown: (credentialType) => this.credentialTypes.recognizes(credentialType),
listIntegrationCredentialIds: async () => {
const agent = await this.agentsService.findById(agentId, projectId);
@ -1375,6 +1385,45 @@ export class AgentsBuilderToolsService {
return { applied: true, config: saved.config, configHash: saved.configHash };
}
private async writeCredentialsToNodeTools(
agentId: string,
projectId: string,
user: User,
credentials: ResolvedCredential[],
): Promise<NodeToolCredentialWriteResult> {
const failed = (errors: Array<{ message: string }>): NodeToolCredentialWriteResult => ({
credentialTypes: [],
error: errors.map((e) => e.message).join('; '),
});
const editorLock = await this.getEditorLockFailure(agentId);
if (editorLock) return failed(editorLock.errors);
const { config, configHash } = await this.getConfigSnapshot(agentId, projectId);
if (!config) return failed([{ message: 'Agent has no JSON config yet.' }]);
const tools = structuredClone(config.tools ?? []);
const credentialTypes: string[] = [];
for (const { credentialType, id, name } of credentials) {
if (assignCredentialToNodeTools(tools, this.nodeTypes, credentialType, { id, name }) > 0) {
credentialTypes.push(credentialType);
}
}
if (credentialTypes.length === 0) return { credentialTypes };
const next = { ...config, tools };
const validated = this.validateBuilderConfig(next, config);
if (!validated.ok) return failed(validated.errors);
const saved = await this.saveBuilderConfig(
agentId,
projectId,
user,
validated.config,
configHash,
next,
);
if (!saved.ok) return failed(saved.errors);
return { credentialTypes, configHash: saved.configHash };
}
private async getFreshConfigSnapshot(
agentId: string,
projectId: string,

View File

@ -422,4 +422,80 @@ describe('finish_setup tool', () => {
).rejects.toThrow('Unsupported chat channel "carrier-pigeon"');
expect(ctx.suspend).not.toHaveBeenCalled();
});
describe('writing credentials to node tools', () => {
it('writes each resolved credential by its credential type', async () => {
const writeCredentialsToNodeTools = vi
.fn()
.mockResolvedValue({ credentialTypes: ['gmailOAuth2'], configHash: 'hash-2' });
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialService: makeCredentialService([
{ id: 'g1', name: 'Gmail account', type: 'gmailOAuth2' },
]),
writeCredentialsToNodeTools,
});
const result = await tool.handler!(
{
credentialRequests: [
{ credentialType: 'gmailOAuth2', purpose: 'Order emails', credentialSlot: 'gmail' },
],
},
makeCtx() as never,
);
expect(writeCredentialsToNodeTools).toHaveBeenCalledWith([
{ credentialType: 'gmailOAuth2', id: 'g1', name: 'Gmail account' },
]);
expect(result).toEqual({
completed: true,
credentials: { gmail: { id: 'g1', name: 'Gmail account' } },
credentialsWrittenToTools: ['gmailOAuth2'],
configHash: 'hash-2',
});
});
it('writes nothing when every credential is skipped', async () => {
const writeCredentialsToNodeTools = vi.fn();
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialService: makeCredentialService([]),
writeCredentialsToNodeTools,
});
const input = {
credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }],
};
const credentialsPayload = await tool.handler!(input, makeCtx() as never);
const result = await tool.handler!(
input,
makeCtx({ resumeData: { skipped: true }, suspendPayload: credentialsPayload }) as never,
);
expect(writeCredentialsToNodeTools).not.toHaveBeenCalled();
expect(result).toEqual({ completed: true, credentials: { airtableApi: 'skipped' } });
});
it('reports a failed write without failing the setup flow', async () => {
const tool = buildFinishSetupTool({
...BASE_DEPS,
credentialService: makeCredentialService([
{ id: 'c1', name: 'My Airtable', type: 'airtableApi' },
]),
writeCredentialsToNodeTools: vi.fn().mockRejectedValue(new Error('locked')),
});
const result = await tool.handler!(
{ credentialRequests: [{ credentialType: 'airtableApi', purpose: 'Airtable log' }] },
makeCtx() as never,
);
expect(result).toEqual({
completed: true,
credentials: { airtableApi: { id: 'c1', name: 'My Airtable' } },
credentialWriteError: 'locked',
});
});
});
});

View File

@ -45,6 +45,23 @@ export interface FinishSetupToolDeps extends CredentialSetupDeps, ChannelSetupDe
* empty on any tool is excluded, so an uncovered node/operation keeps prompting.
*/
listAiGatewayManagedCredentialTypes?: () => Promise<string[]>;
/** Writes each credential into the empty node-tool slots of its type. */
writeCredentialsToNodeTools?: (
credentials: ResolvedCredential[],
) => Promise<NodeToolCredentialWriteResult>;
}
export interface ResolvedCredential {
credentialType: string;
id: string;
name: string;
}
export interface NodeToolCredentialWriteResult {
/** Credential types written to at least one node-tool slot. */
credentialTypes: string[];
configHash?: string;
error?: string;
}
const finishSetupCredentialRequestInputSchema = z.object({
@ -151,6 +168,43 @@ type FinishSetupCtx = InterruptibleToolContext<FinishSetupSuspendPayload, Finish
interface FinishSetupToolResult extends Collected {
completed: true;
credentialsWrittenToTools?: string[];
configHash?: string;
credentialWriteError?: string;
}
/** Collected credential outcomes keyed by slot, mapped back to their credential types. */
function resolvedCredentials(input: FinishSetupInput, collected: Collected): ResolvedCredential[] {
return Object.entries(collected.credentials ?? {}).flatMap(([slotKey, outcome]) => {
if (outcome === 'skipped') return [];
const request = input.credentialRequests?.find(
(slot) => (slot.credentialSlot ?? slot.credentialType) === slotKey,
);
return [{ credentialType: request?.credentialType ?? slotKey, ...outcome }];
});
}
/** Complete the flow and write the resolved credentials into the empty node-tool slots. */
async function complete(
input: FinishSetupInput,
collected: Collected,
deps: FinishSetupToolDeps,
): Promise<FinishSetupToolResult> {
const result: FinishSetupToolResult = { completed: true, ...collected };
const credentials = resolvedCredentials(input, collected);
if (!deps.writeCredentialsToNodeTools || credentials.length === 0) return result;
try {
const written = await deps.writeCredentialsToNodeTools(credentials);
if (written.credentialTypes.length > 0) {
result.credentialsWrittenToTools = written.credentialTypes;
}
if (written.configHash) result.configHash = written.configHash;
if (written.error) result.credentialWriteError = written.error;
} catch (error) {
result.credentialWriteError = error instanceof Error ? error.message : String(error);
}
return result;
}
/** Normalize checkpoint-only legacy outcomes before they enter the current setup flow. */
@ -386,7 +440,7 @@ async function startPlan(
): Promise<FinishSetupToolResult> {
const { phases, collected } = await computeInitialPlan(input, deps);
if (phases.length === 0) {
return { completed: true, ...collected };
return await complete(input, collected, deps);
}
const [currentPhase, ...remainingPhases] = phases;
@ -417,7 +471,7 @@ async function resumePlan(
);
if (chain.remainingPhases.length === 0) {
return { completed: true, ...collected };
return await complete(input, collected, deps);
}
const [nextPhase, ...restPhases] = chain.remainingPhases;
@ -447,11 +501,14 @@ export function buildFinishSetupTool(deps: FinishSetupToolDeps): BuiltTool {
'to configure; do not infer channel names. Each channel card persists the configuration ' +
'or skips it, so channel outcomes are `"configured"` or `"skipped"`. Do not call ' +
'configure_channel again for a channel handled by ' +
'this flow. Returns { completed, answers, credentials, ' +
'channels } (plus configMutated/agentId refresh metadata when completed): resolve the ' +
'model answer with resolve_llm, copy returned credential ids into the config, and verify ' +
'MCP servers with them. Auto-resolves credential slots that match an existing single ' +
'credential or configured channel credential.',
'this flow. Returns { completed, answers, credentials, channels, ' +
'credentialsWrittenToTools } (plus configMutated/agentId refresh metadata when ' +
'completed). The tool writes each resolved credential into every empty node-tool slot ' +
'of its type and lists those types in `credentialsWrittenToTools`; do not patch them ' +
'again. Resolve the model answer with resolve_llm, verify MCP servers with the returned ' +
'credential ids, and patch a credential yourself only if `credentialWriteError` is set. ' +
'Auto-resolves credential slots that match an existing single credential or configured ' +
'channel credential.',
)
.input(finishSetupInputSchema)
.suspend(finishSetupSuspendSchema)

View File

@ -55,8 +55,9 @@ During an initial build:
pending: the model choice and open decisions as \`questions\`, one
\`credentialRequests\` entry per credential slot, and one \`channels\` entry
per drafted channel integration — it configures or skips each channel itself,
always as the last cards in the flow. Resolve its results — \`resolve_llm\`
with the model answer, patch returned credential ids into the config,
always as the last cards in the flow. It writes each resolved credential
into the empty node-tool slots of its type; do not patch those again.
Resolve the rest of its results — \`resolve_llm\` with the model answer,
verify MCP servers — and finish every build step that waited on that setup.
- Do not call \`configure_channel\` again after \`finish_setup\` handles a channel card.
- After \`finish_setup\`, end your reply with a short setup checklist for

View File

@ -83,7 +83,7 @@ conventions. Never add an incomplete tool or use a placeholder URL.
- Gateway credits cover many services, including some community nodes. Adding a node tool with its credential slot omitted triggers server-side assignment: for a covered service the server attaches the managed \`Gateway credits\` credential (\`{ id: null, name: "Gateway credits", __aiGatewayManaged: true }\`) to each required, eligible slot on write — but only when the project has no credential of that type; an existing credential of the type wins and the slot stays empty for the normal credential flow below. Add the tool with the credential slot omitted, then check the slot in the write result: when the server assigned a credential, the result carries the saved \`config\`; when the result has no \`config\`, the slot stayed empty.
- Exception — when the user explicitly asks to run a tool on Gateway credits, write \`{ "id": null, "name": "Gateway credits", "__aiGatewayManaged": true }\` into that credential slot yourself: the server keeps it when the service is covered (even if the user has their own credential of the type) and removes it when not covered — check the \`config\` in the write result (it is present when the server removed the slot) and resolve a real credential if it was removed.
- The \`Gateway credits\` managed credential IS the real, working credential — the tool executes through n8n's gateway on Gateway credits, so NO separate API key is needed. It is NOT a placeholder and NOT "invalid for the service", even for a community node. For a slot that the config (from \`agent-context({ type: "config" })\` or a write result) shows populated with it: the slot is fully connected and the tool WILL run. Do NOT call \`ask_credential\` for it; do NOT include it in \`finish_setup\`; NEVER clear, remove, or replace it via \`patch_config\`; and NEVER seek a "real" API key to swap in for it. Report the tool as ready, running on Gateway credits — exactly like a managed model. Never tell the user the credential is "not connected"/"not set up" or that the tool "won't run until a credential is added".
- Only for a required slot that is still empty after the write (a service Gateway credits do not cover) do you resolve a real credential: call \`ask_credential\` once before the config mutation for an addition to an existing agent. ${INITIAL_BUILD_NOTE} After the trailing \`finish_setup\` resolves the credential, copy the returned credentials into \`node.credentials\` via \`patch_config\`; for resource-locator resolution follow \`agent-builder-resource-locators\` then. Pass the node's credential key as \`credentialSlot\`. On success, copy the returned \`credentials\` object directly to \`node.credentials\`. If skipped, still add the tool and omit only that credential slot.
- Only for a required slot that is still empty after the write (a service Gateway credits do not cover) do you resolve a real credential: call \`ask_credential\` once before the config mutation for an addition to an existing agent. ${INITIAL_BUILD_NOTE} The trailing \`finish_setup\` writes the resolved credential into the empty slot itself; do not patch it, unless the \`finish_setup\` result sets \`credentialWriteError\`. In that case the write failed, so patch the resolved credential into the slot yourself. For resource-locator resolution, follow \`agent-builder-resource-locators\` after \`finish_setup\`. Pass the node's credential key as \`credentialSlot\`. On success, copy the returned \`credentials\` object directly to \`node.credentials\`. If skipped, still add the tool and omit only that credential slot.
- When the agent already has a chat channel configured and the tool needs the same
credential type, \`ask_credential\` reuses the channel's credential automatically —
do not ask the user to pick a different one.

View File

@ -0,0 +1,57 @@
import { inflateSync } from 'node:zlib';
export interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* Minimal reader for the 8-bit RGBA, non-interlaced PNGs the Teams package carries.
* Written out rather than pulling in a decoder and its types for a few assertions.
*/
export function readRgbaPixels(png: Buffer): Rgba[] {
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
expect([png.readUInt8(24), png.readUInt8(25), png.readUInt8(28)]).toEqual([8, 6, 0]);
const idat: Buffer[] = [];
for (let offset = 8; offset + 8 <= png.length; ) {
const length = png.readUInt32BE(offset);
const type = png.toString('ascii', offset + 4, offset + 8);
if (type === 'IDAT') idat.push(png.subarray(offset + 8, offset + 8 + length));
offset += length + 12;
}
const raw = inflateSync(Buffer.concat(idat));
const bpp = 4;
const stride = width * bpp;
const out = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let x = 0; x < stride; x++) {
const left = x >= bpp ? out[y * stride + x - bpp] : 0;
const up = y > 0 ? out[(y - 1) * stride + x] : 0;
const upLeft = y > 0 && x >= bpp ? out[(y - 1) * stride + x - bpp] : 0;
let value = line[x];
if (filter === 1) value += left;
else if (filter === 2) value += up;
else if (filter === 3) value += Math.floor((left + up) / 2);
else if (filter === 4) {
const p = left + up - upLeft;
const [dL, dU, dUL] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)];
value += dL <= dU && dL <= dUL ? left : dU <= dUL ? up : upLeft;
}
out[y * stride + x] = value & 0xff;
}
}
const pixels: Rgba[] = [];
for (let i = 0; i < out.length; i += bpp) {
pixels.push({ r: out[i], g: out[i + 1], b: out[i + 2], a: out[i + 3] });
}
return pixels;
}

View File

@ -0,0 +1,93 @@
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import type { EventEmitter } from 'node:events';
import { renderTeamsAppIcons } from '../teams-app-icons';
// A separate file, so the stand-in icon set does not reach the tests that read the real one.
vi.mock('@iconify-json/lucide', () => ({
icons: {
width: 24,
height: 24,
icons: {
line: { body: '<path stroke="currentColor" stroke-width="2" d="M2 2L22 22"/>' },
unclosed: { body: '<g stroke="currentColor"' },
},
},
}));
const fake = vi.hoisted(() => ({
behave: undefined as ((worker: EventEmitter) => void) | undefined,
}));
vi.mock('node:worker_threads', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:worker_threads')>();
const { EventEmitter: Emitter } = await import('node:events');
function Worker(...args: ConstructorParameters<typeof actual.Worker>) {
const behave = fake.behave;
if (!behave) return new actual.Worker(...args);
const worker = Object.assign(new Emitter(), { terminate: async () => 0 });
setImmediate(() => {
worker.emit('online');
behave(worker);
});
return worker;
}
return { ...actual, Worker };
});
const logger = mockInstance(Logger);
const gradient = { from: '#FF0000', to: '#0000FF', angle: 90, fromStop: 0, toStop: 100 };
const expectFallbackWithWarning = async (icon: string) => {
await expect(renderTeamsAppIcons({ icon, gradient })).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
'Could not draw the agent icon for the Teams app',
expect.objectContaining({ icon }),
);
};
describe('renderTeamsAppIcons when the icon cannot be drawn', () => {
beforeEach(() => {
logger.warn.mockClear();
fake.behave = undefined;
});
afterEach(() => vi.useRealTimers());
it('keeps the bundled icons when resvg cannot read the SVG', async () => {
await expectFallbackWithWarning('unclosed');
});
it('keeps the bundled icons when the worker exits with an error', async () => {
fake.behave = (worker) => worker.emit('exit', 1);
await expectFallbackWithWarning('line');
});
it('keeps the bundled icons when the worker replies without images', async () => {
fake.behave = (worker) => worker.emit('message', { color: 'not an image' });
await expectFallbackWithWarning('line');
});
it('keeps the bundled icons when the worker does not answer in time', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] });
fake.behave = () => {};
const result = renderTeamsAppIcons({ icon: 'line', gradient });
await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1));
await vi.advanceTimersByTimeAsync(5_000);
await expect(result).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalled();
});
it('does not log an icon name Lucide does not have', async () => {
await expect(
renderTeamsAppIcons({ icon: 'not-a-lucide-icon', gradient }),
).resolves.toBeUndefined();
expect(logger.warn).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,198 @@
import type { AgentPersonalisation } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { mockInstance } from '@n8n/backend-test-utils';
import { icons as lucide } from '@iconify-json/lucide';
import { initWasm, Resvg } from '@resvg/resvg-wasm';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import { join } from 'node:path';
import { readRgbaPixels, type Rgba } from './png-pixels';
import { buildTeamsIconSvgs, renderTeamsAppIcons } from '../teams-app-icons';
const workers = vi.hoisted(() => ({ live: 0, mostAtOnce: 0 }));
// Counts workers alive at once. A worker counts until `terminate()` settles,
// which the renderer awaits before it hands the next render a turn.
vi.mock('node:worker_threads', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:worker_threads')>();
class CountedWorker extends actual.Worker {
constructor(...args: ConstructorParameters<typeof actual.Worker>) {
super(...args);
workers.live++;
workers.mostAtOnce = Math.max(workers.mostAtOnce, workers.live);
}
async terminate() {
const code = await super.terminate();
workers.live--;
return code;
}
}
return { ...actual, Worker: CountedWorker };
});
mockInstance(Logger);
const personalisation = (
icon: string,
gradient: Partial<AgentPersonalisation['gradient']> = {},
): AgentPersonalisation => ({
icon,
gradient: { from: '#FF0000', to: '#0000FF', angle: 90, fromStop: 0, toStop: 100, ...gradient },
});
async function render(value: AgentPersonalisation | null) {
const icons = await renderTeamsAppIcons(value);
if (!icons) throw new Error('expected icons');
return icons;
}
const pixelsOf = (png: Buffer, size = 192) => {
const pixels = readRgbaPixels(png);
return (x: number, y: number): Rgba => pixels[y * size + x];
};
const isWhite = ({ r, g, b, a }: Rgba) => r === 255 && g === 255 && b === 255 && a === 255;
/** The default tolerance allows for rounding in the colour conversions. */
const expectColor = ({ r, g, b }: Rgba, expected: [number, number, number], tolerance = 3) =>
[r, g, b].forEach((channel, i) =>
expect(Math.abs(channel - expected[i])).toBeLessThanOrEqual(tolerance),
);
describe('renderTeamsAppIcons', () => {
it('draws a 192px colour icon and a 32px outline icon', async () => {
const { color, outline } = await render(personalisation('bot'));
expect([color.readUInt32BE(16), color.readUInt32BE(20)]).toEqual([192, 192]);
expect([outline.readUInt32BE(16), outline.readUInt32BE(20)]).toEqual([32, 32]);
});
it('runs the gradient along its angle, from one stop to the other', async () => {
const pixelAt = pixelsOf((await render(personalisation('bot'))).color);
// Row 5 is above the glyph, so only the gradient shows there. Edge pixel
// centres sit half a pixel inside the stops, so they are close but not exact.
expectColor(pixelAt(0, 5), [255, 0, 0], 10);
expectColor(pixelAt(191, 5), [0, 0, 255], 10);
});
it('holds each end colour outside its stop', async () => {
const pixelAt = pixelsOf(
(await render(personalisation('bot', { fromStop: 45, toStop: 55 }))).color,
);
expectColor(pixelAt(60, 5), [255, 0, 0]);
expectColor(pixelAt(130, 5), [0, 0, 255]);
});
it('draws the glyph in white over the gradient', async () => {
// The square's left edge sits at x=3 of 24, inside the glyph area centred on the tile.
const pixelAt = pixelsOf((await render(personalisation('square'))).color);
expect(isWhite(pixelAt(52, 96))).toBe(true);
expect(isWhite(pixelAt(96, 96))).toBe(false);
});
it('mixes the gradient in oklch, so complementary colours stay saturated', async () => {
// Mixed in sRGB, orange and azure meet at a flat grey.
const { accentColor } = await render(
personalisation('bot', { from: '#FF8000', to: '#0080FF' }),
);
const channels = [1, 3, 5].map((i) => parseInt(accentColor.slice(i, i + 2), 16));
expect(Math.max(...channels) - Math.min(...channels)).toBeGreaterThan(100);
});
it('takes the accent colour from the middle of the tile, after the stops', async () => {
const accentFor = async (fromStop: number, toStop: number) => {
const { accentColor } = await render(personalisation('bot', { fromStop, toStop }));
const [red, , blue] = [1, 3, 5].map((i) => parseInt(accentColor.slice(i, i + 2), 16));
return { accentColor, red, blue };
};
const late = await accentFor(45, 100);
const even = await accentFor(0, 100);
const early = await accentFor(0, 55);
expect(late.red).toBeGreaterThan(late.blue);
expect(early.blue).toBeGreaterThan(early.red);
expect(['#FF0000', '#0000FF']).not.toContain(even.accentColor);
});
it('draws the outline icon in white and transparency only', async () => {
const pixels = readRgbaPixels((await render(personalisation('bot'))).outline);
// Teams rejects a coloured outline icon with InvalidOutlineIconTransparency.
expect(
pixels.filter(({ r, g, b, a }) => a > 0 && (r !== 255 || g !== 255 || b !== 255)),
).toEqual([]);
expect(pixels.some(({ a }) => a === 255)).toBe(true);
expect(pixels.some(({ a }) => a === 0)).toBe(true);
});
it('draws the default icon when the agent has no personalisation', async () => {
await expect(renderTeamsAppIcons(null)).resolves.toBeDefined();
});
it('returns undefined for an icon name Lucide does not have', async () => {
await expect(
renderTeamsAppIcons(personalisation('not-a-lucide-icon')),
).resolves.toBeUndefined();
});
it('renders downloads that arrive together one at a time', async () => {
workers.mostAtOnce = 0;
const results = await Promise.all(
['bot', 'heart', 'mail'].map(
async (icon) => await renderTeamsAppIcons(personalisation(icon)),
),
);
expect(results.every((icons) => icons?.color.length)).toBe(true);
expect(workers.mostAtOnce).toBe(1);
});
it('draws every Lucide icon', async () => {
// Rendered here rather than through workers, which would take minutes for 1,803 icons.
await initWasm(readFileSync(require.resolve('@resvg/resvg-wasm/index_bg.wasm')));
const missing: string[] = [];
for (const name of Object.keys(lucide.icons)) {
const svgs = await buildTeamsIconSvgs(name, personalisation(name).gradient);
if (!svgs) {
missing.push(name);
continue;
}
// One wasm instance serves the whole loop, so each render is freed by hand.
const resvg = new Resvg(svgs.outline);
const image = resvg.render();
try {
if (!readRgbaPixels(Buffer.from(image.asPng())).some(({ a }) => a === 255)) {
missing.push(name);
}
} finally {
image.free();
resvg.free();
}
}
expect(missing).toEqual([]);
}, 60_000);
it('reads the same Lucide release the editor picker offers', () => {
// The picker reads Lucide from `@iconify/json`, a separately versioned
// package. If the two drift, a picked icon falls back to the n8n icons.
const designSystem = join(__dirname, '../../../../../../../../frontend/@n8n/design-system');
const editorSet: { lastModified?: number; icons: Record<string, unknown> } = JSON.parse(
readFileSync(
createRequire(join(designSystem, 'package.json')).resolve('@iconify/json/json/lucide.json'),
'utf8',
),
);
expect(lucide.lastModified).toBe(editorSet.lastModified);
expect(Object.keys(lucide.icons).sort()).toEqual(Object.keys(editorSet.icons).sort());
});
});

View File

@ -2,66 +2,10 @@ import Ajv from 'ajv-draft-04';
import { unzipSync } from 'fflate';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { inflateSync } from 'node:zlib';
import { readRgbaPixels } from './png-pixels';
import { TeamsManifestService } from '../teams-manifest.service';
interface Rgba {
r: number;
g: number;
b: number;
a: number;
}
/**
* Minimal reader for the 8-bit RGBA, non-interlaced PNGs this service bundles.
* Written out rather than pulling in a decoder and its types for one assertion.
*/
function readRgbaPixels(png: Buffer): Rgba[] {
const width = png.readUInt32BE(16);
const height = png.readUInt32BE(20);
expect([png.readUInt8(24), png.readUInt8(25), png.readUInt8(28)]).toEqual([8, 6, 0]);
const idat: Buffer[] = [];
for (let offset = 8; offset + 8 <= png.length; ) {
const length = png.readUInt32BE(offset);
const type = png.toString('ascii', offset + 4, offset + 8);
if (type === 'IDAT') idat.push(png.subarray(offset + 8, offset + 8 + length));
offset += length + 12;
}
const raw = inflateSync(Buffer.concat(idat));
const bpp = 4;
const stride = width * bpp;
const out = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
const filter = raw[y * (stride + 1)];
const line = raw.subarray(y * (stride + 1) + 1, (y + 1) * (stride + 1));
for (let x = 0; x < stride; x++) {
const left = x >= bpp ? out[y * stride + x - bpp] : 0;
const up = y > 0 ? out[(y - 1) * stride + x] : 0;
const upLeft = y > 0 && x >= bpp ? out[(y - 1) * stride + x - bpp] : 0;
let value = line[x];
if (filter === 1) value += left;
else if (filter === 2) value += up;
else if (filter === 3) value += Math.floor((left + up) / 2);
else if (filter === 4) {
const p = left + up - upLeft;
const [dL, dU, dUL] = [Math.abs(p - left), Math.abs(p - up), Math.abs(p - upLeft)];
value += dL <= dU && dL <= dUL ? left : dU <= dUL ? up : upLeft;
}
out[y * stride + x] = value & 0xff;
}
}
const pixels: Rgba[] = [];
for (let i = 0; i < out.length; i += bpp) {
pixels.push({ r: out[i], g: out[i + 1], b: out[i + 2], a: out[i + 3] });
}
return pixels;
}
const schema: object = JSON.parse(
readFileSync(join(__dirname, 'fixtures', 'MicrosoftTeams.schema.v1.16.json'), 'utf8'),
);
@ -415,6 +359,23 @@ describe('TeamsManifestService', () => {
expect(png.readUInt32BE(20)).toBe(size);
});
it("packs the agent's icons as given, and takes their accent colour", async () => {
const icons = {
color: Buffer.from('color-bytes'),
outline: Buffer.from('outline-bytes'),
accentColor: '#2563EB',
};
const entries = unzipSync(await service.buildPackage(options({ icons })));
const manifest = JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'));
expect(Object.keys(entries).sort()).toEqual(['color.png', 'manifest.json', 'outline.png']);
expect(Buffer.from(entries['color.png'])).toEqual(icons.color);
expect(Buffer.from(entries['outline.png'])).toEqual(icons.outline);
expect(manifest.accentColor).toBe('#2563EB');
const ajv = new Ajv({ strict: false });
expect(ajv.validate(schema, manifest)).toBe(true);
});
it('bundles an outline icon made only of white and transparent pixels', async () => {
const entries = unzipSync(await service.buildPackage(options()));
const pixels = readRgbaPixels(Buffer.from(entries['outline.png']));

View File

@ -1,5 +1,7 @@
import { mock } from 'vitest-mock-extended';
import { unzipSync } from 'fflate';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import type { GlobalConfig } from '@n8n/config';
import type { User } from '@n8n/db';
import type { InstanceSettings } from 'n8n-core';
@ -37,11 +39,11 @@ describe('TeamsSetupService', () => {
let service: TeamsSetupService;
/**
* `integrations` is assigned rather than passed to `mock`, which proxies
* nested objects: the integration's `settings` would come back as mock
* functions, so an absent `displayName` would read as present.
* `integrations` and `schema` are assigned rather than passed to `mock`,
* which proxies nested objects: the integration's `settings` would come back
* as mock functions, so an absent `displayName` would read as present.
*/
const agentWith = (integrations: Agent['integrations']) =>
const agentWith = (integrations: Agent['integrations'], schema: Agent['schema'] = null) =>
Object.assign(
mock<Agent>({
id: AGENT_ID,
@ -49,7 +51,7 @@ describe('TeamsSetupService', () => {
name: 'Support Bot',
updatedAt: new Date('2026-09-15T10:00:00.000Z'),
}),
{ integrations },
{ integrations, schema },
);
const connectTeamsCredential = (type = 'microsoftEntraServicePrincipalApi') => {
@ -263,6 +265,23 @@ describe('TeamsSetupService', () => {
expect((await service.getSetupState(user, scope)).appOutdated).toBe(false);
});
// The package carries the agent's accent colour, which the check does not draw.
it("matches an app drawn with the agent's own icon", async () => {
connectTeamsCredential();
agentRepository.findByIdAndProjectId.mockResolvedValue(
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: {} }], {
personalisation: {
icon: 'heart',
gradient: { from: '#2563EB', to: '#7C3AED', angle: 135, fromStop: 0, toStop: 100 },
},
} as Agent['schema']),
);
const { fingerprint } = await service.buildPackage(user, scope);
recorded(fingerprint);
expect((await service.getSetupState(user, scope)).appOutdated).toBe(false);
});
it('flags an agent renamed since then', async () => {
connectTeamsCredential();
const { fingerprint } = await service.buildPackage(user, scope);
@ -505,6 +524,47 @@ describe('TeamsSetupService', () => {
expect(chosen).not.toBe(stored);
});
describe('app icons', () => {
const bundled = (name: string) => readFileSync(join(__dirname, '..', 'assets', name));
const packageFor = async (icon: string) => {
connectTeamsCredential();
agentRepository.findByIdAndProjectId.mockResolvedValue(
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: {} }], {
name: 'Support Bot',
model: 'anthropic/claude-sonnet-4-5',
instructions: '',
personalisation: {
icon,
gradient: { from: '#2563EB', to: '#2563EB', angle: 90, fromStop: 0, toStop: 100 },
},
}),
);
const { archive } = await service.buildPackage(user, {
projectId: PROJECT_ID,
agentId: AGENT_ID,
});
return unzipSync(archive);
};
it("draws the agent's icon and colour into the package", async () => {
const entries = await packageFor('heart');
const manifest = JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'));
expect(Buffer.from(entries['color.png']).equals(bundled('color.png'))).toBe(false);
expect(Buffer.from(entries['outline.png']).equals(bundled('outline.png'))).toBe(false);
expect(manifest.accentColor).toBe('#2563EB');
});
it('keeps the n8n icons for an icon name Lucide does not have', async () => {
const entries = await packageFor('not-a-lucide-icon');
const manifest = JSON.parse(Buffer.from(entries['manifest.json']).toString('utf8'));
expect(Buffer.from(entries['color.png']).equals(bundled('color.png'))).toBe(true);
expect(Buffer.from(entries['outline.png']).equals(bundled('outline.png'))).toBe(true);
expect(manifest.accentColor).toBe('#EA4B71');
});
});
it('falls back to the stored settings when none are given', async () => {
agentRepository.findByIdAndProjectId.mockResolvedValue(
agentWith([{ type: 'teams', credentialId: CREDENTIAL_ID, settings: { groupChats: true } }]),

View File

@ -0,0 +1,207 @@
import { resolveAgentPersonalisation, type AgentPersonalisation } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { Container } from '@n8n/di';
import { oklchMixer, rgbToHex, type Rgb } from '@n8n/utils/color/oklch';
import { isRecord } from '@n8n/utils/is-record';
import { runSerially } from '@n8n/utils/run-serially';
import { UnexpectedError } from 'n8n-workflow';
import { Worker, type ResourceLimits } from 'node:worker_threads';
/**
* Draws the agent's personalisation icon as the two PNGs a Teams app package
* needs: the Lucide glyph on the agent's gradient, built as SVG here and
* rasterised by resvg in a worker thread.
*
* The worker keeps resvg's wasm memory out of the main isolate, which would
* hold it for the life of the process. The process keeps a flat high-water
* mark instead: about 12 MB of container memory, measured over 20 renders in
* the Alpine image. Memory a terminated worker freed returns to the
* allocator, not to the system, and the next worker reuses it.
*/
export interface TeamsAppIcons {
/** 192×192, full bleed: Teams rounds the corners itself. */
color: Buffer;
/** 32×32, white on transparent: Teams rejects any other colour. */
outline: Buffer;
accentColor: string;
}
interface IconSvgs {
color: string;
outline: string;
accentColor: string;
}
const COLOR_SIZE = 192;
const OUTLINE_SIZE = 32;
/** The glyph share of the tile in `AgentPersonalisationIcon.vue`. */
const GLYPH_RATIO = 0.6;
/** SVG gradients mix in sRGB, so the oklch path is sampled into this many stops. */
const GRADIENT_STOPS = 16;
/** Counted from when the worker runs, so a slow worker start does not use it up. */
const RENDER_TIMEOUT_MS = 5_000;
const WORKER_LIMITS: ResourceLimits = {
maxYoungGenerationSizeMb: 2,
maxOldGenerationSizeMb: 16,
codeRangeSizeMb: 8,
stackSizeMb: 1,
};
type Gradient = AgentPersonalisation['gradient'];
const renders = new Map<string, Promise<unknown>>();
/** Returns undefined for an icon that cannot be drawn, so the caller keeps the bundled icons. */
export async function renderTeamsAppIcons(
personalisation?: AgentPersonalisation | null,
): Promise<TeamsAppIcons | undefined> {
const { icon, gradient } = resolveAgentPersonalisation(personalisation);
try {
const svgs = await buildTeamsIconSvgs(icon, gradient);
if (!svgs) return undefined;
// Downloads at the same time would otherwise each hold a worker and its wasm memory.
const { color, outline } = await runSerially(
renders,
'teams-app-icons',
async () => await rasterise(svgs),
);
return { color, outline, accentColor: svgs.accentColor };
} catch (error) {
// Rendering is optional: the package is still valid with the bundled icons.
Container.get(Logger).warn('Could not draw the agent icon for the Teams app', {
icon,
error,
});
return undefined;
}
}
/** Returns undefined for an icon name Lucide does not have. */
export async function buildTeamsIconSvgs(
iconName: string,
gradient: Gradient,
): Promise<IconSvgs | undefined> {
// Loaded once and cached. Parsing the set again on each call grows the V8
// heap far more than the parsed set costs to keep.
const { icons: lucide } = await import('@iconify-json/lucide');
// Aliases are skipped, as the editor's icon loader skips them.
if (!Object.hasOwn(lucide.icons, iconName)) return undefined;
const icon = lucide.icons[iconName];
const viewBox = `0 0 ${icon.width ?? lucide.width ?? 24} ${icon.height ?? lucide.height ?? 24}`;
const glyph = icon.body.replaceAll('currentColor', '#FFFFFF');
// Mixed in oklch, as the editor does: random agent gradients pair
// complementary hues, which sRGB mixing would turn grey in the middle.
const colorAt = oklchMixer(gradient.from, gradient.to);
const glyphSize = COLOR_SIZE * GLYPH_RATIO;
const offset = (COLOR_SIZE - glyphSize) / 2;
return {
color: svg(
COLOR_SIZE,
`0 0 ${COLOR_SIZE} ${COLOR_SIZE}`,
`${gradientDefinition(gradient, colorAt)}<rect width="${COLOR_SIZE}" height="${COLOR_SIZE}" fill="url(#g)"/>` +
`<svg x="${offset}" y="${offset}" width="${glyphSize}" height="${glyphSize}" viewBox="${viewBox}">${glyph}</svg>`,
),
outline: svg(OUTLINE_SIZE, viewBox, glyph),
accentColor: rgbToHex(colorAt(stopProgress(gradient, 0.5))),
};
}
function svg(size: number, viewBox: string, content: string): string {
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${viewBox}">${content}</svg>`;
}
/**
* CSS angles point up at 0deg and turn clockwise, and the gradient line is
* long enough that the stops land exactly on the corners. `pad` holds the end
* colours outside the stops, as CSS does.
*/
function gradientDefinition(gradient: Gradient, colorAt: (progress: number) => Rgb): string {
const radians = (gradient.angle * Math.PI) / 180;
const [ux, uy] = [Math.sin(radians), -Math.cos(radians)];
const half = (COLOR_SIZE * (Math.abs(ux) + Math.abs(uy))) / 2;
const centre = COLOR_SIZE / 2;
const stops = Array.from({ length: GRADIENT_STOPS + 1 }, (_, i) => {
const progress = i / GRADIENT_STOPS;
const offset = (gradient.fromStop + (gradient.toStop - gradient.fromStop) * progress) / 100;
return `<stop offset="${offset}" stop-color="${rgbToHex(colorAt(progress))}"/>`;
}).join('');
return (
'<defs><linearGradient id="g" gradientUnits="userSpaceOnUse" spreadMethod="pad" ' +
`x1="${centre - ux * half}" y1="${centre - uy * half}" x2="${centre + ux * half}" y2="${centre + uy * half}">` +
`${stops}</linearGradient></defs>`
);
}
/** Where a point at `position` (0..1 along the gradient line) falls between the two stops. */
function stopProgress({ fromStop, toStop }: Gradient, position: number): number {
return Math.max(0, Math.min(1, (position - fromStop / 100) / ((toStop - fromStop) / 100)));
}
// Inline, so no separate worker file has to ship or be built for tests. The
// module paths are resolved here, because eval'd code cannot resolve them.
const WORKER_SOURCE = `
const { parentPort, workerData } = require('node:worker_threads');
const { readFileSync } = require('node:fs');
const { initWasm, Resvg } = require(workerData.modulePath);
initWasm(readFileSync(workerData.wasmPath)).then(() => {
const render = (svg) => {
const image = new Resvg(svg).render();
const png = image.asPng();
image.free();
return png;
};
parentPort.postMessage({ color: render(workerData.color), outline: render(workerData.outline) });
});
`;
async function rasterise(svgs: IconSvgs): Promise<Pick<TeamsAppIcons, 'color' | 'outline'>> {
const worker = new Worker(WORKER_SOURCE, {
eval: true,
resourceLimits: WORKER_LIMITS,
workerData: {
modulePath: require.resolve('@resvg/resvg-wasm'),
wasmPath: require.resolve('@resvg/resvg-wasm/index_bg.wasm'),
color: svgs.color,
outline: svgs.outline,
},
});
try {
const result = await new Promise<unknown>((resolve, reject) => {
let timer: NodeJS.Timeout | undefined;
worker.once('online', () => {
timer = setTimeout(
() => reject(new UnexpectedError('Rendering the Teams app icons timed out')),
RENDER_TIMEOUT_MS,
);
});
worker.once('message', (message) => {
clearTimeout(timer);
resolve(message);
});
worker.once('error', (error) => {
clearTimeout(timer);
reject(error);
});
// A clean exit can arrive right after the message; only a failure decides here.
worker.once('exit', (code) => {
if (code === 0) return;
clearTimeout(timer);
reject(new UnexpectedError(`The icon renderer exited with code ${code}`));
});
});
if (
!isRecord(result) ||
!(result.color instanceof Uint8Array) ||
!(result.outline instanceof Uint8Array)
) {
throw new UnexpectedError('The icon renderer returned no images');
}
return { color: Buffer.from(result.color), outline: Buffer.from(result.outline) };
} finally {
await worker.terminate();
}
}

View File

@ -11,12 +11,13 @@ import { readFile } from 'node:fs/promises';
import { join } from 'node:path';
import { v5 as uuidv5 } from 'uuid';
import type { TeamsAppIcons } from './teams-app-icons';
import { sanitiseAppName, truncateToCodePoints } from '../../integration-helpers';
const MANIFEST_VERSION = '1.16';
const MANIFEST_SCHEMA = `https://developer.microsoft.com/json-schemas/teams/v${MANIFEST_VERSION}/MicrosoftTeams.schema.json`;
/** Matches the colour of the bundled icons. */
/** Matches the colour of the bundled icons, which are used when the agent's are not. */
const ACCENT_COLOR = '#EA4B71';
const DEFAULT_APP_NAME = 'n8n Agent';
@ -56,6 +57,8 @@ export interface BuildTeamsManifestOptions {
* only, with the name and description falling back to the agent's.
*/
settings?: AgentTeamsIntegrationSettings;
/** Drawn from the agent's personalisation. Absent means the bundled n8n icons. */
icons?: TeamsAppIcons;
}
/** The placeholder Microsoft's own resource-specific consent examples carry. */
@ -122,7 +125,7 @@ export class TeamsManifestService {
color: 'color.png',
outline: 'outline.png',
},
accentColor: ACCENT_COLOR,
accentColor: options.icons?.accentColor ?? ACCENT_COLOR,
bots: [
{
botId: options.botId,
@ -155,10 +158,12 @@ export class TeamsManifestService {
*/
async buildPackage(options: BuildTeamsManifestOptions): Promise<Buffer> {
const manifest = this.buildManifest(options);
const [color, outline] = await Promise.all([
readFile(join(__dirname, 'assets', 'color.png')),
readFile(join(__dirname, 'assets', 'outline.png')),
]);
const [color, outline] = options.icons
? [options.icons.color, options.icons.outline]
: await Promise.all([
readFile(join(__dirname, 'assets', 'color.png')),
readFile(join(__dirname, 'assets', 'outline.png')),
]);
return Buffer.from(
zipSync({

View File

@ -6,6 +6,7 @@ import { BadRequestError, NotFoundError } from '@n8n/errors';
import { UrlService } from '@n8n/backend-services';
import { adminConsentRedirectUri, adminConsentUrl } from './teams-admin-consent';
import { renderTeamsAppIcons } from './teams-app-icons';
import { TeamsArmTemplateService } from './teams-arm-template.service';
import { botResourceId } from './teams-bot-provisioning.service';
import { TeamsManifestService } from './teams-manifest.service';
@ -134,7 +135,13 @@ export class TeamsSetupService {
settings: selectedSettings ?? this.teamsSettingsOf(agent),
};
return {
archive: await this.manifestService.buildPackage(options),
archive: await this.manifestService.buildPackage({
...options,
// The draft, like the name: the setup shows the agent as it is being edited.
icons: await renderTeamsAppIcons(agent.schema?.personalisation),
}),
// Without the icons: `appOutdated` does not draw them, and the
// personalisation they come from is part of the fingerprint already.
fingerprint: this.manifestService.fingerprint(
this.manifestService.buildManifest(options),
agent.schema?.personalisation,

View File

@ -5,6 +5,7 @@ import { mock } from 'vitest-mock-extended';
import type { NodeTypes } from '@/node-types';
import {
assignCredentialToNodeTools,
listAiGatewayManagedCredentialTypes,
reconcileNodeToolGatewayCredentials,
} from '../reconcile-node-tool-gateway-credentials';
@ -408,3 +409,61 @@ describe('listAiGatewayManagedCredentialTypes', () => {
expect(listAiGatewayManagedCredentialTypes(tools, nodeTypes)).toEqual([]);
});
});
describe('assignCredentialToNodeTools', () => {
const credential = { id: 'g1', name: 'Gmail account' };
it('fills every empty slot of the type and counts them', () => {
const tools = [nodeTool('n8n-nodes-base.gmailTool'), nodeTool('n8n-nodes-base.gmailTool')];
const filled = assignCredentialToNodeTools(
tools,
nodeTypesWithCredentials(['gmailOAuth2']),
'gmailOAuth2',
credential,
);
expect(filled).toBe(2);
expect(tools.map((t) => t.node.credentials)).toEqual([
{ gmailOAuth2: credential },
{ gmailOAuth2: credential },
]);
});
it('keeps a real credential and a managed marker', () => {
const tools = [
nodeTool('n8n-nodes-base.gmailTool', { gmailOAuth2: { id: 'other', name: 'Other' } }),
nodeTool('n8n-nodes-base.gmailTool', { gmailOAuth2: { ...SENTINEL } }),
];
const filled = assignCredentialToNodeTools(
tools,
nodeTypesWithCredentials(['gmailOAuth2']),
'gmailOAuth2',
credential,
);
expect(filled).toBe(0);
expect(tools[0].node.credentials).toEqual({ gmailOAuth2: { id: 'other', name: 'Other' } });
expect(tools[1].node.credentials).toEqual({ gmailOAuth2: SENTINEL });
});
it('skips a node whose type does not declare the credential', () => {
const tools = [nodeTool('n8n-nodes-base.slackTool')];
const filled = assignCredentialToNodeTools(
tools,
nodeTypesWithCredentials(['slackApi']),
'gmailOAuth2',
credential,
);
expect(filled).toBe(0);
expect(tools[0].node.credentials).toBeUndefined();
});
it('skips a slot hidden by the current authentication option', () => {
const tools = [nodeTool('n8n-nodes-base.service')];
const filled = assignCredentialToNodeTools(
tools,
nodeTypesWithDescription(multiAuthNodeDescription),
'serviceApiKey',
{ id: 'k1', name: 'Key' },
);
expect(filled).toBe(0);
});
});

View File

@ -19,6 +19,11 @@ function aiGatewayManagedCredential(): NodeToolCredential {
};
}
/** Sanitization clears inaccessible ids to '', which leaves the slot empty. */
function isCredentialSlotFilled(ref: NodeToolCredential | undefined): boolean {
return !!ref && (!!ref.id || AI_GATEWAY_MANAGED_CREDENTIAL_FLAG in ref);
}
function setAiGatewayManagedCredential(
node: NodeToolConfig,
credentialType: string,
@ -155,8 +160,7 @@ export function listAiGatewayManagedCredentialTypes(
const resolvedParameters = resolveNodeParameters(node, description);
for (const slot of getRequiredNodeCredentialSlots(description)) {
const credentialType = slot.credentialType;
const ref = node.credentials?.[credentialType];
if (ref && (ref.id || AI_GATEWAY_MANAGED_CREDENTIAL_FLAG in ref)) continue;
if (isCredentialSlotFilled(node.credentials?.[credentialType])) continue;
if (isCredentialDisplayed(node, description, credentialType, resolvedParameters)) {
unsatisfied.add(credentialType);
}
@ -166,6 +170,41 @@ export function listAiGatewayManagedCredentialTypes(
return [...managed].filter((credentialType) => !unsatisfied.has(credentialType));
}
/**
* Write a resolved credential into every node-tool slot of its type that is
* still empty and displayed for the node's current parameters. A slot with a
* real id or a managed marker is left alone. Returns the number of slots filled.
*/
export function assignCredentialToNodeTools(
tools: AgentJsonToolConfig[] | undefined,
nodeTypes: NodeTypes,
credentialType: string,
credential: { id: string; name: string },
): number {
let assigned = 0;
for (const tool of tools ?? []) {
if (tool.type !== 'node') continue;
const node = tool.node;
let description: INodeTypeDescription;
try {
description = nodeTypes.getByNameAndVersion(node.nodeType, node.nodeTypeVersion).description;
} catch {
continue;
}
if (!description.credentials?.some((c) => c.name === credentialType)) continue;
if (isCredentialSlotFilled(node.credentials?.[credentialType])) continue;
const resolvedParameters = resolveNodeParameters(node, description);
if (!isCredentialDisplayed(node, description, credentialType, resolvedParameters)) continue;
node.credentials = { ...(node.credentials ?? {}), [credentialType]: { ...credential } };
assigned++;
}
return assigned;
}
function reconcileNode(
node: NodeToolConfig,
nodeTypes: NodeTypes,
@ -226,10 +265,7 @@ function reconcileNode(
for (const slot of getRequiredNodeCredentialSlots(description)) {
const credentialType = slot.credentialType;
// A ref only occupies the slot with a real id or the managed flag —
// sanitization clears inaccessible ids to '', leaving the slot empty.
const existing = node.credentials?.[credentialType];
if (existing && (existing.id || AI_GATEWAY_MANAGED_CREDENTIAL_FLAG in existing)) continue;
if (isCredentialSlotFilled(node.credentials?.[credentialType])) continue;
// Own credential wins (same precedence as MCP and model slots): leave the
// slot empty so the builder resolves the user's credential instead.

View File

@ -71,6 +71,24 @@ function assertHasInstructions(skill: AgentSkill): void {
if (!skill.instructions.trim()) throw new UserError('Skill instructions are required.');
}
/**
* Builder and MCP tools write here without the REST DTOs, so the name is trimmed here
* too. A name of only spaces is refused.
*/
function trimmedName(name: string): string {
const trimmed = name.trim();
if (!trimmed) throw new UserError('Skill name is required.');
return trimmed;
}
/**
* The skill with the name that `create` stores. A caller that answers with the body or
* its hash uses this, so the answer matches the stored skill.
*/
export function withTrimmedName<T extends { name: string }>(skill: T): T {
return { ...skill, name: trimmedName(skill.name) };
}
function toRecord(rows: Map<string, ResolvedSkillRow>): Record<string, AgentSkill> {
return Object.fromEntries([...rows].map(([refId, row]) => [refId, toAgentSkill(row)]));
}
@ -176,14 +194,15 @@ export class SkillService {
},
ctx: OperationContext = {},
): Promise<string> {
assertHasInstructions(input.skill);
const skill = withTrimmedName(input.skill);
assertHasInstructions(skill);
if (input.checkNameTaken) {
await this.assertNameFree(input.skill.name, input.target, undefined, ctx);
await this.assertNameFree(skill.name, input.target, undefined, ctx);
}
const id = `skill_${generateNanoId()}`;
await this.skillRepository.createSkill(
{ id, target: input.target, source: input.source, createdById: input.createdById },
toSkillContent(input.skill),
toSkillContent(skill),
ctx,
);
this.logger.debug('Created skill', { skillId: id });
@ -213,6 +232,8 @@ export class SkillService {
if (!latest) throw new NotFoundError('Skill not found');
const current = toAgentSkill(latest);
const { baseSkillHash, allowedTools: _ignored, ...changes } = update;
// Only a new name is trimmed, so a save never renames a skill it did not rename.
if (changes.name !== undefined) changes.name = trimmedName(changes.name);
if (baseSkillHash !== getAgentSkillHash(current)) {
throw new ConflictError(
'The skill was changed somewhere else. Reload to get the latest version.',

View File

@ -396,6 +396,27 @@ describe('Agent skills in the skills hub', () => {
expect(Object.values(skills)).toEqual([body('Private')]);
});
it('trims the name of a skill the agent page creates, and refuses a blank one', async () => {
const agent = await createAgent([]);
const context = { user: editor, modifiedBy: 'user' as const };
await expect(
agentSkillsService.createAndAttachSkill(agent.id, project.id, body(' '), context),
).rejects.toThrow('Skill name is required.');
const created = await agentSkillsService.createAndAttachSkill(
agent.id,
project.id,
body(' Summarize '),
context,
);
// The answer and its hash match the stored skill, so the next edit passes the base check.
expect(created.skill.name).toBe('Summarize');
const { skills } = await skillRefs.readDraft(await reload(agent), {});
expect(skills[created.id]?.name).toBe('Summarize');
expect(created.skillHash).toBe(getAgentSkillHash(skills[created.id]!));
});
it('changes where a skill can be added only while no other agent uses it', async () => {
const agent = await createAgent([]);
const context = { user: editor, modifiedBy: 'user' as const };

View File

@ -414,6 +414,29 @@ describe('SkillsController', () => {
expect(response.body.message).toBe('That name is taken, choose another');
});
it('refuses a name of only spaces and trims the padding of a name', async () => {
await owner
.post('/skills')
.send({ scope: 'instance', skill: { ...body, name: ' ' } })
.expect(400);
const { id, skillHash } = await create(owner, {
scope: 'instance',
skill: { ...body, name: ' Padded name ' },
});
expect((await owner.get(`/skills/${id}`).expect(200)).body.data.name).toBe('Padded name');
await owner
.patch(`/skills/${id}`)
.send({ name: ' ', baseSkillHash: skillHash })
.expect(400);
await owner
.patch(`/skills/${id}`)
.send({ name: ' Renamed ', baseSkillHash: skillHash })
.expect(200);
expect((await owner.get(`/skills/${id}`).expect(200)).body.data.name).toBe('Renamed');
});
it('allows the same name in another scope', async () => {
await create(editor, { scope: 'project', projectId: project.id, skill: body });

View File

@ -19,7 +19,7 @@ export const ICON_PICKER_BLOCKLIST: ReadonlySet<string> = new Set([
'share', // "Shared with me" section
'user', // Personal project link
'message-circle', // Chat hub navigation link
'cloud', // Cloud admin panel link
'cloud', // Cloud console link
'lightbulb', // Resource center
'package-open', // Templates link
'chart-column-decreasing', // Insights analytics page

View File

@ -1969,6 +1969,7 @@
"logs.details.body.multipleInputs.openingTheNode": "opening the node",
"mainSidebar.userMenu": "User menu",
"mainSidebar.aboutN8n": "About n8n",
"mainSidebar.cloudConsole": "Cloud console",
"mainSidebar.state.collapse": "Collapse sidebar",
"mainSidebar.state.expand": "Expand sidebar",
"mainSidebar.confirmMessage.workflowArchive.cancelButtonText": "",
@ -2411,7 +2412,7 @@
"aiGateway.unsupportedAction.notice": "{actionName} is currently not supported via Gateway credits. Switch to using your own credential to use it.",
"aiGateway.topUp.modal.title.member": "Contact your admin to top-up",
"aiGateway.topUp.modal.title.trial": "Upgrade your plan to top-up",
"aiGateway.topUp.modal.description.member": "Only the Instance Owner can top up credits and configure auto-top-up in the Cloud Admin Panel. Ask them to add credits so you can keep using AI models and connected services without managing API keys.",
"aiGateway.topUp.modal.description.member": "Only the Instance Owner can top up credits and configure auto-top-up in the Cloud console. Ask them to add credits so you can keep using AI models and connected services without managing API keys.",
"aiGateway.topUp.modal.description.member.trial": "This instance is on a trial, so the Instance Owner needs to upgrade to a paid plan before they can top up credits. Ask them to upgrade and add credits so you can keep using AI models and connected services without managing API keys.",
"aiGateway.topUp.modal.description.trial": "Access to a paid plan is required to top up credits and configure auto-top-up. Upgrade to add credits so you can keep using AI models and connected services without managing API keys.",
"aiGateway.topUp.modal.servicesHint": "Zero setup access to:",
@ -2428,7 +2429,7 @@
"aiGateway.topUp.modal.service.llamaIndex": "LlamaIndex",
"aiGateway.topUp.modal.cta.contactAdmin": "Contact admin",
"aiGateway.topUp.modal.cta.contactAdmin.subject": "Gateway credits top-up",
"aiGateway.topUp.modal.cta.openAdminPanelError": "Could not open the Cloud Admin Panel",
"aiGateway.topUp.modal.cta.openAdminPanelError": "Could not open the Cloud console",
"nodeCredentials.oauth.accountConnectionFailed": "Account connection failed",
"nodeErrorView.cause": "Cause",
"nodeErrorView.copyToClipboard": "Copy to Clipboard",
@ -3465,6 +3466,7 @@
"settings.context.skills.empty.description": "Add a skill by hand or upload a SKILL.md file. Agents in the scope of the skill can then use it.",
"settings.context.skills.modal.scope.label": "Where can this skill be added?",
"settings.context.skills.modal.scope.tooltip": "A global skill can be added to any agent. A project skill can be added to the agents of that project. An agent skill belongs to one agent.",
"settings.context.skills.modal.scope.noDelete": "You need permission to delete this skill to move it, because moving removes it from where it is now.",
"settings.context.skills.modal.scope.inUse": "Used by {count} agent. Remove the skill from it to change where it is available. | Used by {count} agents. Remove the skill from them to change where it is available.",
"settings.context.skills.delete.inUse": "Used by {count} agent. Remove the skill from the agent first. | Used by {count} agents. Remove the skill from each agent first.",
"settings.context.skills.delete.confirm.title": "Delete skill?",
@ -4140,7 +4142,7 @@
"settings.personal.role.tooltip.chatUser": "Access to Chat feature only",
"settings.personal.role.tooltip.admin": "Full access to manage workflows,tags, credentials, projects, users and more",
"settings.personal.role.tooltip.owner": "Manage everything{cloudAccess}",
"settings.personal.role.tooltip.cloud": " and access Cloud dashboard",
"settings.personal.role.tooltip.cloud": " and access the Cloud console",
"settings.personal.save": "Save",
"settings.personal.security": "Security",
"settings.signup.signUpInviterInfo": "{firstName} {lastName} has invited you to n8n",
@ -8979,6 +8981,7 @@
"agents.builder.skills.picker.create": "Create skill",
"agents.builder.skills.picker.create.description": "Write a new skill for this agent",
"agents.builder.skills.picker.add": "Add skill",
"agents.builder.skills.picker.manage": "Manage skills",
"agents.builder.skills.picker.loading": "Loading skills…",
"agents.builder.skills.picker.empty": "No skills to add. Create one, or ask an admin to share one with this project.",
"agents.builder.skills.picker.noResults": "No skills match your search",

View File

@ -12,7 +12,7 @@ import { useAssistantTopUpEligibility } from './useAssistantTopUpEligibility';
/** Sources whose "upgrade" CTA is really a top-up for eligible cloud accounts. */
const ASSISTANT_UPGRADE_SOURCES = new Set<string>(['ai-builder-sidebar', 'instance-ai']);
/** Cloud dashboard path that carries the assistant top-up UI. */
/** Cloud console path that carries the assistant top-up UI. */
const ASSISTANT_TOP_UP_REDIRECT_PATH = '/manage/assistant';
/**
@ -85,7 +85,7 @@ export function useBasePageRedirectionHelper({ guard }: { guard?: UpgradeRedirec
/**
* If the user is an instance owner in the cloud, it generates an auto-login link to the
* cloud dashboard that redirects the user to the /manage page where they can upgrade to a new n8n version.
* Cloud console that redirects the user to the /manage page where they can upgrade to a new n8n version.
* Otherwise, it redirect them to our docs.
*/
const goToVersions = async () => {
@ -103,7 +103,7 @@ export function useBasePageRedirectionHelper({ guard }: { guard?: UpgradeRedirec
/**
* If the user is an instance owner in the cloud, it generates an auto-login link to the
* cloud dashboard that redirects the user to the /account/change-plan page where they upgrade/downgrade the current plan.
* Cloud console that redirects the user to the /account/change-plan page where they upgrade/downgrade the current plan.
* Otherwise, it redirect them our website.
*/

View File

@ -203,6 +203,8 @@ const workflowMenuItems = computed<WorkflowMenuItem[]>(() => {
label: locale.baseText('generic.rename'),
icon: { type: 'icon', value: 'pencil' },
disabled: props.workflowPermissions.update !== true,
// Focus moves to the name input; returning it to the trigger blurs and ends the edit.
suppressCloseAutoFocus: true,
});
}

View File

@ -292,6 +292,21 @@ describe('WorkflowDetails', () => {
});
describe('Workflow menu', () => {
it('should keep the name input in edit mode after the rename action', async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:update']);
const { getByTestId, queryByTestId } = renderComponent({
props: { ...defaultProps },
});
await userEvent.click(getByTestId('workflow-menu'));
await userEvent.click(getByTestId('workflow-menu-item-rename'));
await vi.waitFor(() =>
expect(queryByTestId('workflow-menu-item-rename')).not.toBeInTheDocument(),
);
expect(getByTestId('inline-edit-input')).toHaveFocus();
});
it('should not show a dependencies entry when the workflow has none', async () => {
workflowDocumentStoreRef.value?.setScopes(['workflow:read']);
const { getByTestId, queryByTestId } = renderComponent({

View File

@ -94,7 +94,7 @@ const mainMenuItems = computed<IMenuItem[]>(() => [
{
id: 'cloud-admin',
position: 'bottom',
label: 'Admin Panel',
label: i18n.baseText('mainSidebar.cloudConsole'),
icon: 'cloud',
available: settingsStore.isCloudDeployment && hasPermission(['instanceOwner']),
},

View File

@ -51,7 +51,7 @@ describe('useAiGatewayTopUp', () => {
});
});
it('sends non-trial owners to the Cloud Admin Panel', async () => {
it('sends non-trial owners to the Cloud console', async () => {
const usersStore = mockedStore(useUsersStore);
const uiStore = mockedStore(useUIStore);
usersStore.isInstanceOwner = true;
@ -67,7 +67,7 @@ describe('useAiGatewayTopUp', () => {
expect(uiStore.openModalWithData).not.toHaveBeenCalled();
});
it('reports the error when the Admin Panel link fails', async () => {
it('reports the error when the Cloud console link fails', async () => {
const usersStore = mockedStore(useUsersStore);
const uiStore = mockedStore(useUIStore);
usersStore.isInstanceOwner = true;
@ -80,7 +80,7 @@ describe('useAiGatewayTopUp', () => {
expect(uiStore.openModalWithData).not.toHaveBeenCalled();
});
it('does not open a modal when Admin Panel navigation does not happen', async () => {
it('does not open a modal when Cloud console navigation does not happen', async () => {
const usersStore = mockedStore(useUsersStore);
const uiStore = mockedStore(useUIStore);
usersStore.isInstanceOwner = true;

View File

@ -430,6 +430,33 @@ describe('WorkflowsView', () => {
await waitFor(() => expect(router.currentRoute.value.query).toStrictEqual({}));
});
it('should not replace the route when the query does not change', async () => {
await router.replace({ query: { search: 'one' } });
const replaceSpy = vi.spyOn(router, 'replace');
renderComponent({ pinia });
await waitAllPromises();
expect(replaceSpy).not.toHaveBeenCalled();
replaceSpy.mockRestore();
});
it('should not replace the route when the route changes while tags load', async () => {
await router.replace({ query: { tags: 'non-existing-tag' } });
workflowsListStore.fetchWorkflowsPage.mockResolvedValue([]);
const tagStore = mockedStore(useTagsStore);
tagStore.fetchAll.mockImplementation(async () => {
await router.push({ query: { search: 'new' } });
return [];
});
const replaceSpy = vi.spyOn(router, 'replace');
renderComponent({ pinia });
await waitAllPromises();
expect(replaceSpy).not.toHaveBeenCalled();
expect(router.currentRoute.value.query).toStrictEqual({ search: 'new' });
replaceSpy.mockRestore();
});
it('should show archived only hint', async () => {
foldersStore.totalWorkflowCount = 1;
workflowsListStore.fetchWorkflowsPage.mockResolvedValue([]);

View File

@ -96,6 +96,7 @@ import { useI18n } from '@n8n/i18n';
import { getResourcePermissions } from '@n8n/permissions';
import { createEventBus } from '@n8n/utils/event-bus';
import debounce from 'lodash/debounce';
import isEqual from 'lodash/isEqual';
import { type IUser, PROJECT_ROOT } from 'n8n-workflow';
import { computed, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import { type LocationQueryRaw, useRoute, useRouter } from 'vue-router';
@ -1069,6 +1070,7 @@ const saveFiltersOnQueryString = () => {
};
const setFiltersFromQueryString = async () => {
const { fullPath } = route;
const newQuery: LocationQueryRaw = { ...route.query };
const { tags, status, search, homeProject, sort, showArchived } = route.query ?? {};
@ -1139,7 +1141,13 @@ const setFiltersFromQueryString = async () => {
filters.value.showArchived = false;
}
void router.replace({ query: newQuery });
// The route changed while the stores loaded. Do not write this query onto the new route.
if (route.fullPath !== fullPath) return;
// A replace cancels any pending navigation, e.g. a tab click made while this view loads.
if (!isEqual(newQuery, route.query)) {
void router.replace({ query: newQuery });
}
};
// Misc methods

View File

@ -3,6 +3,7 @@ import { ref } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { configure, fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
@ -75,6 +76,14 @@ vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({
},
}));
const { resolveRoute } = vi.hoisted(() => ({
resolveRoute: vi.fn(() => ({ href: '/settings/context/skills' })),
}));
vi.mock('vue-router', async (importOriginal) => ({
...(await importOriginal<typeof import('vue-router')>()),
useRouter: () => ({ resolve: resolveRoute }),
}));
vi.mock('../composables/useAgentTelemetry', () => ({
useAgentTelemetry: () => ({ trackImportedSkill }),
}));
@ -108,6 +117,7 @@ function renderModal({
| 'onRemove'
| 'outdated'
| 'onUseLatestVersion'
| 'getDefaultName'
> = {}) {
const renderComponent = createComponentRenderer(AgentSkillModal, {
global: {
@ -667,6 +677,40 @@ describe('AgentSkillModal', () => {
);
});
it('links to the skills settings page from the picker, in a new tab', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const open = vi.spyOn(window, 'open').mockReturnValue(null);
const { getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
expect(getByTestId('agent-skill-picker-manage')).toHaveTextContent(
'agents.builder.skills.picker.manage',
);
// The picker step has no Cancel and no Save.
expect(queryByTestId('agent-modal-cancel')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-picker-manage'));
expect(resolveRoute).toHaveBeenCalledWith({ name: VIEWS.SETTINGS_CONTEXT_SKILLS });
expect(open).toHaveBeenCalledWith('/settings/context/skills', '_blank', 'noopener');
open.mockRestore();
});
it('shows the settings link only on the picker step', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const { getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(getByTestId('agent-skill-picker-create'));
expect(queryByTestId('agent-skill-picker-manage')).not.toBeInTheDocument();
});
it('goes from the picker to the upload step and back', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const { getByTestId, queryByTestId } = renderModal({
@ -682,6 +726,23 @@ describe('AgentSkillModal', () => {
expect(getByTestId('agent-skill-picker')).toBeVisible();
});
it('asks for a default name where "available to" puts the new skill', async () => {
listAttachableSkills.mockResolvedValue([]);
const getDefaultName = vi.fn().mockResolvedValue('New skill 3');
const { getByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
getDefaultName,
});
// "Create" from the "This agent" tab makes the skill available to this agent only.
await fireEvent.click(getByTestId('agent-skill-picker-create-this-agent'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
expect(getDefaultName).toHaveBeenCalledWith('agent');
await waitFor(() => expect(getTitleInput(document.body)).toHaveValue('New skill 3'));
});
it('opens a read-only preview from a row click and adds the skill from there', async () => {
listAttachableSkills.mockResolvedValue([projectSkill, instanceSkill]);
getHubSkill.mockResolvedValue(
@ -781,7 +842,8 @@ describe('AgentSkillModal', () => {
});
it.each([
['project', 'agents.builder.skills.availableTo.project'],
// The project option names the project, which the modal hides behind it.
['project', 'Marketing'],
['instance', 'agents.builder.skills.availableTo.instance'],
] as const)(
'saves a new skill for the %s from the availability select',
@ -816,6 +878,22 @@ describe('AgentSkillModal', () => {
},
);
it('falls back to "This project" when the project name is unknown', async () => {
listAttachableSkills.mockResolvedValue([]);
const { getByTestId, findByText } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(getByTestId('agent-skill-picker-create'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.click(
getByTestId('agent-skill-availability-select').querySelector('.select-trigger')!,
);
expect(await findByText('agents.builder.skills.availableTo.project')).toBeInTheDocument();
});
it.each([
['all', true, 'agent'],
['project', true, 'project'],

View File

@ -8,8 +8,9 @@ import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { AGENT_TOOLS_MODAL_KEY, AGENT_SKILL_MODAL_KEY } from '../constants';
import { useAgentCapabilitiesActions } from '../composables/useAgentCapabilitiesActions';
import { createAgentSkill, getHubSkill } from '../composables/useAgentApi';
import type { SkillDetail, SkillListItem } from '@n8n/api-types';
import { createAgentSkill, getHubSkill, getNewSkillName } from '../composables/useAgentApi';
import { SKILL_NAME_TAKEN_MESSAGE, type SkillDetail, type SkillListItem } from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import type {
AgentJsonConfig,
AgentJsonMcpServerConfig,
@ -37,6 +38,7 @@ vi.mock('@n8n/composables/useToast', () => ({
vi.mock('../composables/useAgentApi', () => ({
createAgentSkill: vi.fn(),
getHubSkill: vi.fn(),
getNewSkillName: vi.fn(),
}));
const { hasScopeSpy } = vi.hoisted(() => ({
@ -177,11 +179,12 @@ type SkillModalData = {
onUseLatestVersion?: (skillId: string) => void;
getAttachedSkills?: () => Array<{ id: string; name: string }>;
canCreateInstanceSkills?: boolean;
getDefaultName?: (availableTo: string) => Promise<string>;
onConfirm: (payload: {
id?: string;
skill: AgentSkill;
availableTo?: 'project' | 'agent' | 'instance';
}) => void;
}) => unknown;
onAttach?: (skill: SkillListItem) => Promise<void>;
onRemove?: (skillId: string) => void;
};
@ -244,6 +247,12 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => {
const persisted = new Promise<void>((resolve) => {
releasePersist = resolve;
});
vi.mocked(createAgentSkill).mockResolvedValue({
id: 'skill_new',
skill: { ...triage, name: 'New Skill' },
skillHash: 'hash-new',
versionId: 'v2',
});
const { uiStore, actions } = setup({ ensureAgentPersisted: async () => await persisted });
actions.onOpenAddSkillModal();
@ -730,6 +739,50 @@ describe('useAgentCapabilitiesActions — skills hub', () => {
expect(hasScopeSpy).toHaveBeenCalledWith('skill:create');
});
it.each([
['project', { scope: 'project', projectId: 'project-1' }],
['agent', { scope: 'agent', agentId: 'agent-1' }],
['instance', { scope: 'instance' }],
])(
'asks the server for a default name where "available to" %s puts the skill',
async (availableTo, target) => {
vi.mocked(getNewSkillName).mockResolvedValue('New skill 3');
const { modalData } = setupHub();
await expect(modalData.getDefaultName?.(availableTo)).resolves.toBe('New skill 3');
expect(getNewSkillName).toHaveBeenCalledWith(expect.anything(), {
...target,
name: 'agents.builder.skills.defaultName',
});
},
);
it('keeps the modal open with the error on the name when the name is taken', async () => {
vi.mocked(createAgentSkill).mockRejectedValue(
new ResponseError(SKILL_NAME_TAKEN_MESSAGE, { httpStatusCode: 409 }),
);
const { modalData } = setupHub();
const result = await modalData.onConfirm({ skill: sqlHelper, availableTo: 'project' });
expect(result).toEqual({
saved: false,
nameError: 'settings.context.skills.error.nameTaken',
});
expect(showErrorSpy).not.toHaveBeenCalled();
});
it('keeps the modal open and reports any other failed create', async () => {
const error = new Error('Network down');
vi.mocked(createAgentSkill).mockRejectedValue(error);
const { modalData } = setupHub();
const result = await modalData.onConfirm({ skill: sqlHelper, availableTo: 'project' });
expect(result).toEqual({ saved: false });
expect(showErrorSpy).toHaveBeenCalledWith(error, 'agents.builder.skills.create.error');
});
it('keeps the picker off and creates the skill on the agent when the skills hub is off', async () => {
vi.mocked(createAgentSkill).mockResolvedValue({
id: 'sql',
@ -740,6 +793,8 @@ describe('useAgentCapabilitiesActions — skills hub', () => {
const { modalData, actions, uiStore } = setupHub({ skillsHubEnabled: false });
expect(modalData.hubSkillsEnabled).toBe(false);
// Without the hub, names are only unique per agent, so the modal numbers them itself.
expect(modalData.getDefaultName).toBeUndefined();
modalData.onConfirm({ skill: sqlHelper, availableTo: 'project' });
await flushPromises();
expect(createAgentSkill).toHaveBeenCalledWith(

View File

@ -3,13 +3,14 @@ import { describe, expect, it, vi, beforeEach } from 'vitest';
import { createTestingPinia } from '@pinia/testing';
import { configure, fireEvent, waitFor, within } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { defineComponent, ref } from 'vue';
import { computed, defineComponent, ref } from 'vue';
import AgentChannelTeamsEditView from './AgentChannelTeamsEditView.vue';
import type { TeamsChannelRuntime } from './useTeamsChannelRuntime';
import { checkTeamsFileAccess, fetchTeamsAppPackage, getTeamsSetupState } from './api';
import { fileAccessJustApproved } from './fileAccessConsent';
import { ActionDropdownTestStub } from '../../__tests__/utils/ActionDropdownTestStub';
import { AGENT_PERSONALISATION_KEY } from '../types';
vi.mock('@n8n/i18n', async (importOriginal) => ({
...(await importOriginal()),
@ -587,4 +588,20 @@ describe('AgentChannelTeamsEditView', () => {
expect(queryByTestId('teams-managed-app')).not.toBeInTheDocument();
expect(queryByTestId('teams-settings-publish')).not.toBeInTheDocument();
});
it("shows the agent's own icon on the identity card", async () => {
const personalisation = computed(() => ({
icon: 'heart',
gradient: { from: '#2563EB', to: '#7C3AED', angle: 135, fromStop: 0, toStop: 100 },
}));
const { container, getByTestId } = renderHost({
global: { provide: { [AGENT_PERSONALISATION_KEY]: personalisation } },
});
await waitFor(() => expect(getByTestId('teams-identity')).toBeInTheDocument());
const tile = container.querySelector<HTMLElement>(
'[data-test-id="agent-personalisation-icon-tile"]',
);
expect(tile?.style.getPropertyValue('--agent-personalisation-gradient-from')).toBe('#2563EB');
});
});

View File

@ -1,14 +1,14 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue';
import { computed, inject, onBeforeUnmount, ref, watch } from 'vue';
import { saveAs } from 'file-saver';
import { N8nButton, N8nCopyInput, N8nIcon, N8nLink, N8nStepper, N8nText } from '@n8n/design-system';
import type {
AgentJsonConfig,
AgentTeamsIntegrationSettings,
ChatIntegrationDescriptor,
TeamsAgentSetupState,
TeamsCredentialCheck,
TeamsFileAccessCheck,
import {
resolveAgentPersonalisation,
type AgentTeamsIntegrationSettings,
type ChatIntegrationDescriptor,
type TeamsAgentSetupState,
type TeamsCredentialCheck,
type TeamsFileAccessCheck,
} from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
@ -40,6 +40,7 @@ import {
fetchTeamsAppPackage,
getTeamsSetupState,
} from './api';
import { AGENT_PERSONALISATION_KEY } from '../types';
const credentialId = defineModel<string>({ default: '' });
@ -59,7 +60,6 @@ const props = withDefaults(
agentId: string;
forceNewCredential?: boolean;
savedSettings?: AgentTeamsIntegrationSettings;
personalisation?: AgentJsonConfig['personalisation'] | null;
ensureAgentPersisted?: () => Promise<void>;
/** Saves without closing the modal, so the card can act on saved settings. */
persistChannel?: () => Promise<boolean>;
@ -79,12 +79,17 @@ const props = withDefaults(
errorIsConflict: false,
forceNewCredential: false,
savedSettings: undefined,
personalisation: null,
ensureAgentPersisted: undefined,
persistChannel: undefined,
},
);
// The builder provides it. The default keeps the card drawable when mounted on its own.
const personalisation = inject(
AGENT_PERSONALISATION_KEY,
computed(() => resolveAgentPersonalisation(null)),
);
const emit = defineEmits<{
create: [];
edit: [];

View File

@ -1,16 +1,23 @@
import type {
AgentIntegrationDisconnectWarning,
AgentJsonConfig,
AgentPersonalisation,
AgentIntegrationSettings,
ChatIntegrationDescriptor,
} from '@n8n/api-types';
import type { IconName } from '@n8n/design-system';
import type { BaseTextKey } from '@n8n/i18n';
import type { PermissionsRecord } from '@n8n/permissions';
import type { Component, Ref, VNode } from 'vue';
import type { Component, ComputedRef, InjectionKey, Ref, VNode } from 'vue';
import type { AgentCredentialOption } from '../components/AgentCredentialSelect.vue';
/**
* The agent's icon and gradient, with defaults applied. Injected rather than
* passed down, because only the views that show the agent read it.
*/
export const AGENT_PERSONALISATION_KEY: InjectionKey<ComputedRef<AgentPersonalisation>> =
Symbol('agentPersonalisation');
export type AgentChannelMode = 'setup' | 'edit';
export type AgentChannelView = 'list' | `${string}_${AgentChannelMode}`;
@ -73,7 +80,6 @@ export interface AgentChannelViewProps {
agentName: string;
projectId: string;
agentId: string;
personalisation?: AgentJsonConfig['personalisation'] | null;
ensureAgentPersisted?: () => Promise<void>;
/**
* Saves the channel without closing the modal, and reports whether it

View File

@ -1,8 +1,12 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { computed, provide, ref, watch } from 'vue';
import { N8nCard, N8nIcon, N8nTabs, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import type { AgentConfigValidationIssue, AgentFileDto } from '@n8n/api-types';
import {
resolveAgentPersonalisation,
type AgentConfigValidationIssue,
type AgentFileDto,
} from '@n8n/api-types';
import type { AgentBuilderMainTab } from '../composables/useAgentBuilderMainTabs';
import type { SetupTask } from './AgentSetupTasks/agentSetupTasks.registry';
@ -20,6 +24,7 @@ import AgentAdvancedPanel from './AgentAdvancedPanel.vue';
import AgentBudgetPanel from './AgentBudgetPanel.vue';
import AgentCapabilitiesSection from './AgentCapabilitiesSection.vue';
import AgentTriggersSection from './AgentTriggersSection.vue';
import { AGENT_PERSONALISATION_KEY } from '../channels/types';
import AgentIdentityHeader from './AgentIdentityHeader.vue';
import AgentInfoPanel from './AgentInfoPanel.vue';
import AgentFilesPanel from './AgentFilesPanel.vue';
@ -68,6 +73,14 @@ const props = defineProps<{
}>();
const childrenDisabled = computed(() => !props.canEditAgent);
provide(
AGENT_PERSONALISATION_KEY,
computed(() =>
resolveAgentPersonalisation(
props.localConfig?.personalisation ?? props.agent?.schema?.personalisation,
),
),
);
const isKnowledgeAdvancedExpanded = ref(false);
const settingsStore = useSettingsStore();
@ -231,7 +244,6 @@ defineExpose({ onSetupTaskAction });
:saved-description="savedDescription"
:save-description="saveDescription"
:task-refs="localConfig?.tasks ?? []"
:personalisation="localConfig?.personalisation ?? agent?.schema?.personalisation"
:reload-key="tasksReloadKey"
@update:connected-triggers="emit('update:connected-triggers', $event)"
@trigger-added="emit('trigger-added', $event)"

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import type { AgentApproval, AgentJsonConfig } from '@n8n/api-types';
import type { AgentApproval } from '@n8n/api-types';
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import { N8nButton, N8nIcon, N8nText, type DropdownMenuItemProps } from '@n8n/design-system';
@ -37,7 +37,6 @@ interface Props {
isPublished?: boolean;
simpleSetup?: boolean;
ensureAgentPersisted?: () => Promise<void>;
personalisation?: AgentJsonConfig['personalisation'] | null;
/** n8n Chat's saved description, seeded into its view when opened. */
savedDescription?: string;
/** Persists n8n Chat's description. It skips the request when nothing changed. */
@ -49,7 +48,6 @@ const props = withDefaults(defineProps<Props>(), {
isPublished: false,
simpleSetup: false,
ensureAgentPersisted: undefined,
personalisation: null,
});
const emit = defineEmits<{
@ -709,7 +707,6 @@ watch(
:agent-name="agentId"
:project-id="projectId"
:agent-id="agentId"
:personalisation="personalisation"
:ensure-agent-persisted="ensureAgentPersisted"
:persist-channel="writeChannelConfig"
:force-new-credential="false"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { DEFAULT_AGENT_PERSONALISATION, type AgentJsonConfig } from '@n8n/api-types';
import { resolveAgentPersonalisation, type AgentJsonConfig } from '@n8n/api-types';
import { N8nIcon } from '@n8n/design-system';
type AgentPersonalisation = NonNullable<AgentJsonConfig['personalisation']>;
@ -13,13 +13,9 @@ const props = withDefaults(
{ personalisation: null, size: 24 },
);
const resolved = computed<AgentPersonalisation>(() => {
const value = props.personalisation;
return {
icon: value?.icon ?? DEFAULT_AGENT_PERSONALISATION.icon,
gradient: { ...DEFAULT_AGENT_PERSONALISATION.gradient, ...value?.gradient },
};
});
const resolved = computed<AgentPersonalisation>(() =>
resolveAgentPersonalisation(props.personalisation),
);
const tileStyle = computed<Record<string, string>>(() => ({
'--agent-personalisation-gradient-from': resolved.value.gradient.from,

View File

@ -2,6 +2,7 @@
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import { useRouter } from 'vue-router';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
@ -25,6 +26,7 @@ import type { IconName } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
import type {
@ -64,6 +66,7 @@ export type AgentSkillModalData = {
existingSkillNames?: string[];
hubSkillsEnabled?: boolean;
getAttachedSkills?: () => Array<{ id: string; name: string }>;
/** The agent's project as the user knows it. Names the picker tab and the "available to" option. */
projectLabel?: string;
canCreateInstanceSkills?: boolean;
/**
@ -73,8 +76,9 @@ export type AgentSkillModalData = {
scopeOptions?: AgentSkillModalScopeOption[];
initialScope?: string;
/**
* Settings only: a default name that no skill in the scope has yet, as new workflows
* get one. Called with the chosen scope value when the user starts a skill by hand.
* A default name that no skill in the scope has yet, as new workflows get one. Called
* when the user starts a skill by hand, with the Settings scope value or, in the agent
* builder, the "available to" value.
*/
getDefaultName?: (scope: string) => Promise<string>;
/** Locks the scope field and explains why in a tooltip. */
@ -104,6 +108,7 @@ const props = defineProps<{
const i18n = useI18n();
const uiStore = useUIStore();
const router = useRouter();
const rootStore = useRootStore();
const { showError } = useToast();
const agentTelemetry = useAgentTelemetry();
@ -158,13 +163,13 @@ async function refreshDefaultName() {
const current = skill.value.name;
if (current !== getDefaultSkillName() && current !== serverDefaultName.value) return;
const request = ++latestNameRequest;
const requestedScope = scope.value;
const requestedScope = nameScope.value;
defaultNameLoading.value = true;
try {
const name = await props.data.getDefaultName(requestedScope);
// Only the newest answer for the scope still selected counts, and only while the
// user has not typed a name of their own.
if (request !== latestNameRequest || scope.value !== requestedScope) return;
if (request !== latestNameRequest || nameScope.value !== requestedScope) return;
if (skill.value.name !== current) return;
serverDefaultName.value = name;
skill.value = { ...skill.value, name };
@ -180,10 +185,6 @@ function onAddManually() {
void refreshDefaultName();
}
// A new scope can hold other names, so a default name follows the scope.
watch(scope, () => {
if (step.value === 'manual') void refreshDefaultName();
});
const showScope = computed(() => !!props.data.scopeOptions?.length && step.value !== 'preview');
const canChangeScope = computed(
() => showScope.value && !props.data.scopeLockedReason && !props.data.readOnly,
@ -192,6 +193,15 @@ const selectedScopeIcon = computed(
() => props.data.scopeOptions?.find((option) => option.value === scope.value)?.icon,
);
const availableTo = ref<AgentSkillAvailability>('project');
/**
* Where the new skill goes, for its default name: the scope field in Settings, or
* "available to" in the agent builder. `getDefaultName` gets this value.
*/
const nameScope = computed<string>(() => (showScope.value ? scope.value : availableTo.value));
// A new scope can hold other names, so a default name follows the scope.
watch(nameScope, () => {
if (step.value === 'manual') void refreshDefaultName();
});
const librarySkills = ref<SkillListItem[]>([]);
const libraryLoading = ref(false);
@ -231,6 +241,7 @@ const showRemove = computed(() => {
return step.value === 'preview' ? previewIsAdded.value : isEditing.value;
});
const footerPrimaryAction = computed<'add' | 'save' | 'useLatest' | undefined>(() => {
if (step.value === 'select') return undefined;
if (step.value === 'preview') return previewIsAdded.value ? undefined : 'add';
if (isOutdated.value) return props.data.onUseLatestVersion ? 'useLatest' : undefined;
return isReadOnly.value ? undefined : 'save';
@ -284,7 +295,11 @@ const AVAILABILITY_ICONS = {
instance: 'globe',
} as const satisfies Record<AgentSkillAvailability, string>;
const availabilityOptions = computed(() => [
{ value: 'project' as const, label: i18n.baseText('agents.builder.skills.availableTo.project') },
// The modal hides the page behind it, so the project option names the project.
{
value: 'project' as const,
label: props.data.projectLabel ?? i18n.baseText('agents.builder.skills.availableTo.project'),
},
{ value: 'agent' as const, label: i18n.baseText('agents.builder.skills.availableTo.agent') },
...(showInstanceTarget.value
? [
@ -469,6 +484,11 @@ async function attachLibrarySkill(skillId: string) {
}
}
/** Opens Settings > Context > Skills in a new tab, so the agent and the picker stay open. */
function onManageSkills() {
window.open(router.resolve({ name: VIEWS.SETTINGS_CONTEXT_SKILLS }).href, '_blank', 'noopener');
}
function onPreviewAdd() {
if (!previewSkill.value) return;
void attachLibrarySkill(previewSkill.value.id);
@ -651,7 +671,8 @@ function onRemove() {
:title-error="visibleNameError"
:editable-title="step === 'manual' || (step === 'edit' && !isReadOnly)"
:show-back="step === 'manual' || step === 'preview' || (step === 'upload' && hasPicker)"
:show-footer="step === 'manual' || step === 'edit' || step === 'preview'"
:show-footer="step === 'select' || step === 'manual' || step === 'edit' || step === 'preview'"
:show-cancel="step !== 'select'"
:busy="isImporting || previewLoading || isSaving"
size="fit"
data-testid="agent-skill-modal"
@ -845,6 +866,12 @@ function onRemove() {
{{ removeLabel }}
</N8nButton>
</template>
<template v-else-if="step === 'select'" #footerLeft>
<N8nButton variant="ghost" data-testid="agent-skill-picker-manage" @click="onManageSkills">
<template #icon><N8nIcon icon="settings" :size="16" /></template>
{{ i18n.baseText('agents.builder.skills.picker.manage') }}
</N8nButton>
</template>
<template v-if="footerHint" #footerBeforeCancel>
<N8nText
size="small"

View File

@ -2,7 +2,6 @@
import { N8N_CHAT_INTEGRATION_TYPE } from '@n8n/api-types';
import type {
AgentConfigValidationIssue,
AgentJsonConfig,
AgentJsonTaskConfig,
TeamsFileAccessConsentState,
} from '@n8n/api-types';
@ -44,7 +43,6 @@ const props = withDefaults(
/** No agent row exists yet — nothing can be connected to it. */
agentUnsaved?: boolean;
ensureAgentPersisted?: () => Promise<void>;
personalisation?: AgentJsonConfig['personalisation'] | null;
/** n8n Chat's saved description, forwarded to the channel modal. */
savedDescription?: string;
/** Persists n8n Chat's description, forwarded to the channel modal. */
@ -59,7 +57,6 @@ const props = withDefaults(
simpleChannelSetup: false,
taskRefs: () => [],
ensureAgentPersisted: undefined,
personalisation: null,
},
);
@ -366,7 +363,6 @@ function handleChannelDisconnected(channelType: string) {
:disabled="props.disabled"
:simple-setup="simpleChannelSetup"
:ensure-agent-persisted="ensureAgentPersisted"
:personalisation="personalisation"
:saved-description="savedDescription"
:save-description="saveDescription"
@channel-connected="handleChannelConnected"

View File

@ -37,6 +37,8 @@ import type {
SkillDetail,
SkillListItem,
SkillListResponse,
SkillNewNameQueryDto,
SkillNewNameResponse,
VectorStoreTestResult,
} from '@n8n/api-types';
import { getFullApiResponse, makeRestApiRequest, request } from '@n8n/rest-api-client';
@ -755,6 +757,20 @@ export const getHubSkill = async (
return await makeRestApiRequest<SkillDetail>(context, 'GET', `/skills/${skillId}`);
};
/** A name like `name` that no skill in the scope has yet, such as "New skill 2". */
export const getNewSkillName = async (
context: IRestApiContext,
query: SkillNewNameQueryDto,
): Promise<string> => {
const { name } = await makeRestApiRequest<SkillNewNameResponse>(
context,
'GET',
'/skills/new-name',
query,
);
return name;
};
export const getAgentBackgroundJobs = async (
context: IRestApiContext,
projectId: string,

View File

@ -10,11 +10,12 @@ import {
type AgentSkillAvailability,
type SkillDetail,
type SkillListItem,
type SkillNewNameQueryDto,
} from '@n8n/api-types';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { createAgentSkill, getHubSkill } from './useAgentApi';
import { createAgentSkill, getHubSkill, getNewSkillName } from './useAgentApi';
import { useAgentProjectBreadcrumb } from './useAgentProjectBreadcrumb';
import { mcpServerToNode } from './useMcpServerAdapter';
import {
@ -22,8 +23,9 @@ import {
AGENT_TOOL_CONFIG_MODAL_KEY,
AGENT_SKILL_MODAL_KEY,
} from '../constants';
import { normalizeAgentSkillForSave } from '../utils/agentSkill';
import { isSkillNameTakenError, normalizeAgentSkillForSave } from '../utils/agentSkill';
import type { ToolOpenTarget, ToolPickerMode } from '../components/AgentCapabilitiesSection.types';
import type { AgentSkillConfirmResult } from '../components/AgentSkillModal.vue';
import type {
AgentResource,
AgentJsonConfig,
@ -351,6 +353,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
existingSkillNames: appliedSkillNames(id),
hubSkillsEnabled: hubSkillsEnabled.value,
outdated: hubSkillsEnabled.value && (agent.value?.outdatedSkillIds ?? []).includes(id),
projectLabel: projectName.value ?? undefined,
canCreateInstanceSkills: rbacStore.hasScope('skill:create'),
onUseLatestVersion: (skillId: string) => {
if (agentId.value !== targetAgentId) return;
@ -409,6 +412,17 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
* Authoring-time mirror of the backend's `assertSkillNameIsUnique` for
* local-skill hosts. The modal validates against `existingSkillNames` before closing
*/
/** The scope of the new-name lookup for an "available to" choice. */
function newNameTarget(
availableTo: string,
targetProjectId: string,
targetAgentId: string,
): Pick<SkillNewNameQueryDto, 'scope' | 'projectId' | 'agentId'> {
if (availableTo === 'instance') return { scope: 'instance' };
if (availableTo === 'agent') return { scope: 'agent', agentId: targetAgentId };
return { scope: 'project', projectId: targetProjectId };
}
function hasDuplicateSkillName(name: string, excludeId?: string): boolean {
const key = skillNameKey(name);
return appliedSkillNames(excludeId).some((existing) => skillNameKey(existing) === key);
@ -483,6 +497,17 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
appliedSkills.value.map(({ id, skill }) => ({ id, name: skill.name })),
projectLabel: projectName.value ?? undefined,
canCreateInstanceSkills: rbacStore.hasScope('skill:create'),
// The hub refuses a name that the skill's scope already has, so the default
// name comes from the server, for the "available to" choice.
...(hubSkillsEnabled.value
? {
getDefaultName: async (availableTo: string) =>
await getNewSkillName(rootStore.restApiContext, {
...newNameTarget(availableTo, targetProjectId, targetAgentId),
name: locale.baseText('agents.builder.skills.defaultName'),
}),
}
: {}),
onRemove: (skillId: string) => {
if (!isCurrentTarget()) return;
onRemoveSkill(skillId);
@ -492,59 +517,63 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
isCurrentTarget,
async () => await getHubSkill(rootStore.restApiContext, librarySkill.id),
),
onConfirm: ({
// The modal stays open until the skill is created, and stays open when the create
// fails, so the user keeps what they typed. A taken name shows on the name field.
onConfirm: async ({
skill,
availableTo,
}: {
id?: string;
skill: AgentSkill;
availableTo?: AgentSkillAvailability;
}) => {
}): Promise<AgentSkillConfirmResult> => {
if (localSkills) {
if (!isCurrentTarget()) return;
if (!isCurrentTarget()) return undefined;
const sanitizedSkill = normalizeAgentSkillForSave(skill);
if (hasDuplicateSkillName(sanitizedSkill.name)) {
showDuplicateSkillNameError(sanitizedSkill.name);
return;
return undefined;
}
// The host mints the skill id and writes body + ref together.
localSkills.createSkill(sanitizedSkill);
return;
return undefined;
}
const sanitizedSkill = normalizeAgentSkillForSave(skill);
try {
await beforeAgentMutation?.();
} catch {
// The host owns the autosave error message. Do not also report
// this as a skill-creation failure.
return { saved: false };
}
if (!isCurrentTarget()) return undefined;
let result: Awaited<ReturnType<typeof createAgentSkill>>;
try {
await ensureAgentPersisted?.();
if (!isCurrentTarget()) return undefined;
result = await createAgentSkill(
rootStore.restApiContext,
targetProjectId,
targetAgentId,
sanitizedSkill,
hubSkillsEnabled.value ? availableTo : undefined,
);
} catch (error) {
if (isSkillNameTakenError(error)) {
return {
saved: false,
nameError: locale.baseText('settings.context.skills.error.nameTaken'),
};
}
showError(error, locale.baseText('agents.builder.skills.create.error'));
return { saved: false };
}
// The skill exists now, so the modal can close while the agent catches up.
void (async () => {
const sanitizedSkill = normalizeAgentSkillForSave(skill);
let created: AgentSkill;
let skillHash: string;
let versionId: string | null;
let skillId: string;
try {
await beforeAgentMutation?.();
} catch {
// The host owns the autosave error message. Do not also report
// this as a skill-creation failure.
return;
}
if (!isCurrentTarget()) return;
try {
await ensureAgentPersisted?.();
if (!isCurrentTarget()) return;
const result = await createAgentSkill(
rootStore.restApiContext,
targetProjectId,
targetAgentId,
sanitizedSkill,
hubSkillsEnabled.value ? availableTo : undefined,
);
skillId = result.id;
created = result.skill;
skillHash = result.skillHash;
versionId = result.versionId;
} catch (error) {
showError(error, locale.baseText('agents.builder.skills.create.error'));
return;
}
const { id: skillId, skill: created, skillHash, versionId } = result;
if (!isCurrentTarget() || agent.value?.id !== targetAgentId) return;
agent.value = {
...agent.value,
@ -576,6 +605,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
};
}
})();
return undefined;
},
},
});

View File

@ -1,5 +1,17 @@
import { SKILL_NAME_TAKEN_MESSAGE } from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import type { AgentSkill } from '../types';
/** Whether a save failed because the skill's scope already has a name like it. */
export function isSkillNameTakenError(error: unknown): boolean {
return (
error instanceof ResponseError &&
error.httpStatusCode === 409 &&
error.message === SKILL_NAME_TAKEN_MESSAGE
);
}
/** Skills drop `allowedTools`: the editor does not show it and the runtime does not get it. */
export function normalizeAgentSkillForSave(skill: AgentSkill): AgentSkill {
const { allowedTools: _allowedTools, ...rest } = skill;

View File

@ -633,7 +633,7 @@ describe('SettingsPersonalView', () => {
['Member', ROLE.Member, false, 'Create and manage own workflows and credentials'],
['Admin', ROLE.Admin, false, 'Full access to manage workflows'],
['Owner', ROLE.Owner, false, 'Manage everything'],
['Owner', ROLE.Owner, true, 'Manage everything and access Cloud dashboard'],
['Owner', ROLE.Owner, true, 'Manage everything and access the Cloud console'],
])(
'should show %s user role information with tooltip',
async (label, role, hasCloudPlan, expectedTooltip) => {

View File

@ -335,6 +335,22 @@ describe('SettingsSkillsView', () => {
);
});
it('locks the scope when the user may edit but not delete the skill', async () => {
skillsStore.skills = [skill()];
skillsStore.count = 1;
skillsStore.fetchSkill.mockResolvedValue(detail({ canEdit: true, canDelete: false }));
const { getByText } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByText('research-search'));
await new Promise(process.nextTick);
expect(lastModalData().readOnly).toBe(false);
expect(lastModalData().scopeLockedReason).toBe(
'You need permission to delete this skill to move it, because moving removes it from where it is now.',
);
});
it('moves an unused skill to the scope the user picks', async () => {
skillsStore.skills = [skill()];
skillsStore.count = 1;

View File

@ -2,14 +2,8 @@
import { computed, onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useDebounceFn } from '@vueuse/core';
import {
SKILL_NAME_TAKEN_MESSAGE,
type AgentSkill,
type SkillListItem,
type SkillScope,
} from '@n8n/api-types';
import type { AgentSkill, SkillListItem, SkillScope } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
import { ResponseError } from '@n8n/rest-api-client';
import { useToast } from '@n8n/composables/useToast';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import {
@ -31,6 +25,7 @@ import { DEBOUNCE_TIME, MODAL_CONFIRM, VIEWS } from '@/app/constants';
import { useUIStore } from '@/app/stores/ui.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { AGENT_SKILL_MODAL_KEY } from '@/features/agents/constants';
import { isSkillNameTakenError } from '@/features/agents/utils/agentSkill';
import type {
AgentSkillConfirmFailure,
AgentSkillModalData,
@ -221,11 +216,7 @@ function confirmIfHidden(skillId: string, key: 'created' | 'saved') {
* shows on the name field, any other error in a toast.
*/
function saveFailed(error: unknown): AgentSkillConfirmFailure {
if (
error instanceof ResponseError &&
error.httpStatusCode === 409 &&
error.message === SKILL_NAME_TAKEN_MESSAGE
) {
if (isSkillNameTakenError(error)) {
return { saved: false, nameError: i18n.baseText('settings.context.skills.error.nameTaken') };
}
showError(error, i18n.baseText('settings.context.skills.error.save'));
@ -254,6 +245,28 @@ function scopeValueOf(skill: Pick<SkillListItem, 'scope' | 'projectId'>): string
return skill.scope;
}
/**
* Why the scope of a saved skill cannot change, if it cannot. The backend refuses a move
* while an agent uses the skill, and a move needs the delete permission where the skill is,
* because it takes the skill out of that scope.
*/
function scopeLock(
detail: Pick<SkillListItem, 'usedByAgents' | 'canDelete'>,
): Pick<AgentSkillModalData, 'scopeLockedReason'> {
if (detail.usedByAgents > 0) {
return {
scopeLockedReason: i18n.baseText('settings.context.skills.modal.scope.inUse', {
interpolate: { count: detail.usedByAgents },
adjustToNumber: detail.usedByAgents,
}),
};
}
if (!detail.canDelete) {
return { scopeLockedReason: i18n.baseText('settings.context.skills.modal.scope.noDelete') };
}
return {};
}
async function openEditModal(item: Pick<SkillListItem, 'id'>) {
try {
const detail = await skillsStore.fetchSkill(item.id);
@ -263,22 +276,13 @@ async function openEditModal(item: Pick<SkillListItem, 'id'>) {
const options = scopeOptions.value.some((option) => option.value === currentValue)
? scopeOptions.value
: [{ value: currentValue, ...scopeDisplay(detail) }, ...scopeOptions.value];
// The backend refuses to move a skill while an agent uses it.
const inUse = detail.usedByAgents > 0;
openModal({
skill: detail.skill,
skillId: detail.id,
readOnly: !detail.canEdit,
scopeOptions: options,
initialScope: currentValue,
...(inUse
? {
scopeLockedReason: i18n.baseText('settings.context.skills.modal.scope.inUse', {
interpolate: { count: detail.usedByAgents },
adjustToNumber: detail.usedByAgents,
}),
}
: {}),
...scopeLock(detail),
existingSkillNames: [],
onConfirm: async ({ skill, scope }) =>
await onSave(

View File

@ -64,9 +64,9 @@ export class CanvasComposer {
/**
* Switch between editor and workflow list and back
*/
async switchBetweenEditorAndWorkflowList(): Promise<void> {
async switchBetweenEditorAndWorkflowList(workflowName: string): Promise<void> {
await this.n8n.sideBar.clickHomeButton();
await this.n8n.workflows.cards.getWorkflows().first().click();
await this.n8n.workflows.cards.getWorkflow(workflowName).click();
// Wait for the editor's loading overlay to clear before subsequent header
// interactions; nodes can report visible while the overlay still blocks clicks.
await this.n8n.canvas.waitForCanvasReady();

View File

@ -6,16 +6,18 @@ test.describe(
annotation: [{ type: 'owner', description: 'Catalysts' }],
},
() => {
test.beforeEach(async ({ n8n }) => {
await n8n.api.enableFeature('debugInEditor');
await n8n.api.enableFeature('workflowHistory');
await n8n.start.fromImportedWorkflow('Lots_of_nodes.json');
});
test('should maintain zoom functionality after switching between Editor and Workflow history and Workflow list', async ({
n8n,
}) => {
// ~10 full canvas reloads of a large workflow. Under CI load each reload can take
// 5-15s, so the default 60s budget times out at whichever step is running.
test.setTimeout(180_000);
await n8n.api.enableFeature('debugInEditor');
await n8n.api.enableFeature('workflowHistory');
const { createdWorkflow } = await n8n.start.fromImportedWorkflow('Lots_of_nodes.json');
const workflowName = createdWorkflow.name;
await expect(n8n.canvas.getCanvasNodes().first()).toBeVisible();
const initialNodeCount = await n8n.canvas.getCanvasNodes().count();
expect(initialNodeCount).toBeGreaterThan(0);
@ -27,15 +29,15 @@ test.describe(
await n8n.canvasComposer.switchBetweenEditorAndHistory();
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList(workflowName);
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList(workflowName);
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList(workflowName);
await n8n.canvasComposer.zoomInAndCheckNodes();
await n8n.canvasComposer.switchBetweenEditorAndHistory();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList();
await n8n.canvasComposer.switchBetweenEditorAndWorkflowList(workflowName);
});
},
);

View File

@ -1621,6 +1621,9 @@ importers:
'@n8n/permissions':
specifier: workspace:*
version: link:../permissions
'@n8n/utils':
specifier: workspace:*
version: link:../utils
'@n8n_io/ai-assistant-sdk':
specifier: 'catalog:'
version: 1.27.0
@ -4789,6 +4792,9 @@ importers:
'@grpc/grpc-js':
specifier: 1.14.5
version: 1.14.5
'@iconify-json/lucide':
specifier: 'catalog:'
version: 1.2.114
'@joplin/turndown-plugin-gfm':
specifier: 'catalog:'
version: 1.0.64
@ -4957,6 +4963,9 @@ importers:
'@qdrant/js-client-rest':
specifier: 'catalog:'
version: 1.19.0(@typescript/typescript6@6.0.2)
'@resvg/resvg-wasm':
specifier: 2.6.2
version: 2.6.2
'@rudderstack/rudder-sdk-node':
specifier: 'catalog:'
version: 3.0.5(debug@4.4.3(supports-color@8.1.1))(supports-color@8.1.1)
@ -13369,6 +13378,10 @@ packages:
'@codemirror/state': ^6.0.0
'@codemirror/view': ^6.0.0
'@resvg/resvg-wasm@2.6.2':
resolution: {integrity: sha512-FqALmHI8D4o6lk/LRWDnhw95z5eO+eAa6ORjVg09YRR7BkcM6oPHU9uyC0gtQG5vpFLvgpeU4+zEAz2H8APHNw==}
engines: {node: '>= 10'}
'@rolldown/binding-android-arm64@1.0.0-beta.50':
resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -31306,6 +31319,8 @@ snapshots:
'@codemirror/state': 6.5.3
'@codemirror/view': 6.39.8
'@resvg/resvg-wasm@2.6.2': {}
'@rolldown/binding-android-arm64@1.0.0-beta.50':
optional: true