feat(editor): Add the Skills page to Context settings (no-changelog) (#41031)

This commit is contained in:
Ricardo Espinoza 2026-10-15 15:35:20 +00:00 committed by GitHub
parent deed9a2f32
commit 860f1273eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
46 changed files with 3747 additions and 113 deletions

View File

@ -2,6 +2,7 @@ import {
CreateSkillDto,
ListSkillsQueryDto,
SaveSkillDto,
SkillNewNameQueryDto,
SKILLS_MAX_PAGE_SIZE,
} from '../skills.schema';
@ -81,3 +82,15 @@ describe('SaveSkillDto', () => {
},
);
});
describe('SkillNewNameQueryDto', () => {
it('trims the requested name', () => {
expect(SkillNewNameQueryDto.parse({ scope: 'instance', name: ' New skill ' }).name).toBe(
'New skill',
);
});
it('refuses a name of only spaces', () => {
expect(SkillNewNameQueryDto.safeParse({ scope: 'instance', name: ' ' }).success).toBe(false);
});
});

View File

@ -63,8 +63,10 @@ const agentSkillReferencesSchema = z
* config as `{ type: 'skill', id }` refs (mirroring tasks), so it is
* intentionally not part of the body.
*/
export const AGENT_SKILL_NAME_MAX_LENGTH = 128;
export const agentSkillShape = {
name: z.string().min(1).max(128),
name: z.string().min(1).max(AGENT_SKILL_NAME_MAX_LENGTH),
description: z.string().min(1).max(512),
// Measured in characters like the name, description and reference limits,
// so the editor can show the user the same count the server enforces.

View File

@ -1,7 +1,12 @@
import { z } from 'zod';
import { agentSkillSchema, agentSkillShape } from './agent-skill.schema';
import {
AGENT_SKILL_NAME_MAX_LENGTH,
agentSkillSchema,
agentSkillShape,
} from './agent-skill.schema';
import type { AgentSkill } from './types';
import type { ProjectType } from '../schemas/project.schema';
import { paginationSchema } from '../dto/pagination/pagination.dto';
import { Z } from '../zod-class';
@ -24,6 +29,14 @@ export type SkillScope = z.infer<typeof skillScopeSchema>;
export type SkillSource = 'ui' | 'agent';
/** The user who created a skill, as the settings list names them. */
export type SkillUserDto = {
id: string;
email: string | null;
firstName: string | null;
lastName: string | null;
};
/** One row of the skills list. Name and description come from the latest version. */
export type SkillListItem = {
id: string;
@ -33,10 +46,14 @@ export type SkillListItem = {
/** The project of a project skill, or the project of the agent of an agent skill. */
projectId: string | null;
projectName: string | null;
/** Lets the editor name another member's personal project, which it does not list. */
projectType: ProjectType | null;
/** Set for an agent skill: the only agent that can use it. */
agentId: string | null;
agentName: string | null;
source: SkillSource;
/** Null when the creator is unknown or the user no longer exists. */
createdBy: SkillUserDto | null;
/** Number of the latest version, the one following agents run. */
latestVersion: number;
/** Distinct agents that use the skill: draft refs plus published pins. */
@ -100,6 +117,25 @@ export class ListSkillsQueryDto extends Z.class({
attachableTo: z.string().max(36).optional(),
}) {}
/**
* 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.
*/
export class SkillNewNameQueryDto extends Z.class({
scope: skillScopeSchema,
/** Required for a project skill, absent otherwise. */
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),
}) {}
export type SkillNewNameResponse = { name: string };
/** The 409 message for a skill name that its scope already holds. The editor matches it. */
export const SKILL_NAME_TAKEN_MESSAGE = 'That name is taken, choose another';
export class CreateSkillDto extends Z.class({
scope: skillScopeSchema,
/** Required for a project skill, absent otherwise. */
@ -120,4 +156,11 @@ export class SaveSkillDto extends Z.class({
instructions: agentSkillShape.instructions.optional(),
references: agentSkillShape.references.optional(),
baseSkillHash: z.string().min(1),
/**
* Moves the skill to the instance or to a project. Refused with 409 while an agent
* uses it. An agent skill is made from inside its agent, so `agent` is not a choice.
*/
scope: z.enum(['project', 'instance']).optional(),
/** Required when `scope` is `project`, absent otherwise. */
projectId: z.string().max(36).optional(),
}) {}

View File

@ -463,7 +463,7 @@ describe('AgentSkillRefsService', () => {
service.createSkills(agent, [body], 'user-1', availableTo, ctx),
).resolves.toEqual(['skill_created']);
expect(skillService.create).toHaveBeenCalledWith(
{ target, skill: body, source: 'agent', createdById: 'user-1' },
{ target, skill: body, source: 'agent', createdById: 'user-1', checkNameTaken: true },
ctx,
);
});
@ -478,6 +478,19 @@ describe('AgentSkillRefsService', () => {
{ projectId: null, agentId: 'agent-1' },
'agent-1',
ctx,
undefined,
);
});
it('checks the name that the same save gives the skill', async () => {
await service.setAvailability(agent, 'skill_a', 'project', ctx, 'Condense');
expect(skillService.changeTarget).toHaveBeenCalledWith(
'skill_a',
{ projectId: agent.projectId, agentId: null },
'agent-1',
ctx,
'Condense',
);
});
});

View File

@ -49,6 +49,7 @@ import { BUILDER_TOOLS } from '../builder/builder-tool-names';
import type { Agent } from '../entities/agent.entity';
import type { AgentSecureRuntime } from '../runtime/agent-secure-runtime';
import { getAgentConfigHash } from '../utils/agent-config-hash';
import { SkillNameTakenError } from '../skills/skill-name-taken.error';
import * as checkAccess from '@/permissions.ee/check-access';
import type { InstanceAiCredentialService } from '@n8n/instance-ai';
import { createAgentSkillRefsService } from './test-utils/stored-agent-config';
@ -1844,6 +1845,42 @@ describe('AgentsBuilderToolsService', () => {
});
});
it('names a taken skill name instead of asking for a retry', async () => {
const { service, agentsService } = makeService();
agentsService.createAndAttachSkills.mockRejectedValue(
new SkillNameTakenError('Summarize Meetings'),
);
const result = await getCreateSkillsTool(service).handler!(
{ skills: [makeSkillInput('Summarize Meetings')] },
ctx,
);
expect(result).toEqual({
ok: false,
errors: [
{
message: expect.stringContaining(
'The skill name "Summarize Meetings" is taken by another skill in the same scope',
),
},
],
});
});
it('tells the model that names are unique in the project', () => {
const { service } = makeService();
const tool = getCreateSkillsTool(service);
const nameSchema = (
tool.inputSchema as unknown as {
shape: { skills: { element: { shape: { name: { description?: string } } } } };
}
).shape.skills.element.shape.name;
expect(nameSchema.description).toContain('unique in the project');
});
it('rejects an empty skills array via the input schema', () => {
const { service } = makeService();
@ -2012,6 +2049,30 @@ describe('AgentsBuilderToolsService', () => {
});
});
it('names a taken skill name on a rename instead of the stale skill error', async () => {
const { service, agentsService } = makeService();
agentsService.updateSkill.mockRejectedValue(new SkillNameTakenError('Create tickets'));
const result = await getUpdateSkillTool(service).handler!(
{
skillId: 'skill_create_tickets',
baseSkillHash: 'hash',
updates: { name: 'Create tickets' },
},
ctx,
);
expect(result).toEqual({
ok: false,
errors: [
{
message: expect.stringContaining('The skill name "Create tickets" is taken'),
},
],
});
expect(JSON.stringify(result)).not.toContain('Skill changed');
});
it('rejects instructions and instructionEdits together', () => {
const { service } = makeService();
const inputSchema = getUpdateSkillTool(service).inputSchema as unknown as {

View File

@ -100,6 +100,8 @@ describe('SkillService', () => {
vi.resetAllMocks();
txRunner.run.mockImplementation(async (_ctx, fn) => await fn(TX_CTX));
skills.findLatestSaved.mockResolvedValue(new Map());
skills.findLatestNamesInTarget.mockResolvedValue([]);
skills.findLatestSummaries.mockResolvedValue(new Map());
skills.findVersionsByIds.mockResolvedValue(new Map());
skills.findDependencies.mockResolvedValue(new Map());
skills.findByIds.mockResolvedValue([]);
@ -231,6 +233,75 @@ describe('SkillService', () => {
).rejects.toThrow(UserError);
expect(skills.createSkill).not.toHaveBeenCalled();
});
it.each(['Brand voice', 'brand-voice', ' BRAND_voice '])(
'refuses "%s" when another skill in the same scope has a name like it',
async (name) => {
skills.findLatestNamesInTarget.mockResolvedValue([
{ skillId: 'skill_x', name: 'Brand voice' },
]);
await expect(
service.create({
target: { projectId: PROJECT, agentId: null },
skill: { name, description: 'd', instructions: 'i' },
source: 'ui',
createdById: user.id,
checkNameTaken: true,
}),
).rejects.toThrow('That name is taken, choose another');
expect(skills.findLatestNamesInTarget).toHaveBeenCalledWith(
{ projectId: PROJECT, agentId: null },
expect.anything(),
);
expect(skills.createSkill).not.toHaveBeenCalled();
},
);
it('lets a copy keep the name of the skill it copies', async () => {
skills.findLatestNamesInTarget.mockResolvedValue([
{ skillId: 'skill_x', name: 'Brand voice' },
]);
await service.create({
target: { projectId: null, agentId: 'agent-2' },
skill: { name: 'Brand voice', description: 'd', instructions: 'i' },
source: 'agent',
createdById: user.id,
});
expect(skills.findLatestNamesInTarget).not.toHaveBeenCalled();
expect(skills.createSkill).toHaveBeenCalled();
});
});
describe('freeName', () => {
const target = { projectId: PROJECT, agentId: null };
it('keeps a name that the scope does not hold', async () => {
expect(await service.freeName('New skill', target)).toBe('New skill');
expect(skills.findLatestNamesInTarget).toHaveBeenCalledWith(target, {});
});
it('adds the first free number, by the same rule as the name check', async () => {
skills.findLatestNamesInTarget.mockResolvedValue([
{ skillId: 'a', name: 'new-skill' },
{ skillId: 'b', name: 'New skill 2' },
{ skillId: 'c', name: 'New skill 4' },
]);
expect(await service.freeName('New skill', target)).toBe('New skill 3');
});
it('shortens a long name so the number fits the length limit', async () => {
const long = 'x'.repeat(128);
skills.findLatestNamesInTarget.mockResolvedValue([{ skillId: 'a', name: long }]);
const name = await service.freeName(long, target);
expect(name).toHaveLength(128);
expect(name.endsWith(' 2')).toBe(true);
});
});
describe('save', () => {
@ -291,6 +362,31 @@ describe('SkillService', () => {
);
});
it('refuses a rename to a name that another skill in its scope has', async () => {
const baseSkillHash = givenLatest(versionRow(skill, 2));
skills.findLatestNamesInTarget.mockResolvedValue([
{ skillId: 'skill_a', name: 'Brand voice' },
{ skillId: 'skill_x', name: 'Code review' },
]);
await expect(
service.save('skill_a', { baseSkillHash, name: 'code-review' }, user.id),
).rejects.toThrow('That name is taken, choose another');
expect(skills.findLatestNamesInTarget).toHaveBeenCalledWith(
{ projectId: PROJECT, agentId: null },
TX_CTX,
);
expect(skills.insertSavedVersion).not.toHaveBeenCalled();
});
it('does not check the name when the save keeps it', async () => {
const baseSkillHash = givenLatest(versionRow(skill, 2));
await service.save('skill_a', { baseSkillHash, instructions: 'new' }, user.id);
expect(skills.findLatestNamesInTarget).not.toHaveBeenCalled();
});
it('removes references set to an empty list', async () => {
const baseSkillHash = givenLatest(
versionRow(skill, 2, {}, [{ path: 'references/a.md', content: 'a' }]),
@ -511,6 +607,65 @@ describe('SkillService', () => {
).rejects.toThrow('Other agents use this skill.');
expect(skills.updateTarget).not.toHaveBeenCalled();
});
it('refuses to move a skill into a scope that holds a name like it', async () => {
skills.findUsingAgentIds.mockResolvedValue([]);
skills.findLatestSummaries.mockResolvedValue(
new Map([['skill_a', { name: 'Brand voice', description: 'd', version: 1 }]]),
);
skills.findLatestNamesInTarget.mockResolvedValue([
{ skillId: 'skill_x', name: 'brand_voice' },
]);
await expect(
service.changeTarget('skill_a', { projectId: null, agentId: null }, undefined),
).rejects.toThrow('That name is taken, choose another');
expect(skills.findLatestNamesInTarget).toHaveBeenCalledWith(
{ projectId: null, agentId: null },
TX_CTX,
);
expect(skills.updateTarget).not.toHaveBeenCalled();
});
it('checks the name that a rename in the same request gives the skill', async () => {
skills.findUsingAgentIds.mockResolvedValue([]);
await service.changeTarget(
'skill_a',
{ projectId: null, agentId: null },
undefined,
{},
'Fresh name',
);
expect(skills.findLatestSummaries).not.toHaveBeenCalled();
expect(skills.findLatestNamesInTarget).toHaveBeenCalledWith(
{ projectId: null, agentId: null },
TX_CTX,
);
expect(skills.updateTarget).toHaveBeenCalled();
});
it('refuses to move a skill that any agent uses when no agent is kept', async () => {
skills.findUsingAgentIds.mockResolvedValue(['agent-1']);
await expect(
service.changeTarget('skill_a', { projectId: null, agentId: null }, undefined),
).rejects.toThrow('Agents use this skill.');
expect(skills.updateTarget).not.toHaveBeenCalled();
});
it('moves an unused skill when no agent is kept', async () => {
skills.findUsingAgentIds.mockResolvedValue([]);
await service.changeTarget('skill_a', { projectId: 'p1', agentId: null }, undefined);
expect(skills.updateTarget).toHaveBeenCalledWith(
'skill_a',
{ projectId: 'p1', agentId: null },
TX_CTX,
);
});
});
describe('publish', () => {

View File

@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/unbound-method -- mock-based tests reference unbound methods */
import type { CreateSkillDto, ListSkillsQueryDto, SaveSkillDto } from '@n8n/api-types';
import type { ProjectScopeService } from '@n8n/backend-services';
import type { User } from '@n8n/db';
import type { OperationContext, TransactionRunner, User, UserRepository } from '@n8n/db';
import { ForbiddenError, NotFoundError, UserError } from '@n8n/errors';
import { hasGlobalScope } from '@n8n/permissions';
import { mock } from 'vitest-mock-extended';
@ -25,12 +25,16 @@ vi.mock('@n8n/permissions', async (importOriginal) => ({
const user = mock<User>({ id: 'user-1' });
const DATE = new Date('2026-10-01T00:00:00.000Z');
function skill(id: string, target: Partial<Pick<Skill, 'projectId' | 'agentId'>> = {}): Skill {
function skill(
id: string,
target: Partial<Pick<Skill, 'projectId' | 'agentId' | 'createdById'>> = {},
): Skill {
return mock<Skill>({
id,
projectId: target.projectId ?? null,
agentId: target.agentId ?? null,
source: 'ui',
createdById: target.createdById ?? null,
createdAt: DATE,
updatedAt: DATE,
});
@ -71,11 +75,21 @@ describe('SkillsApiService', () => {
const repository = mock<SkillRepository>();
const skillService = mock<SkillService>();
const projectScopeService = mock<ProjectScopeService>();
const service = new SkillsApiService(skillService, repository, projectScopeService);
const txRunner = mock<TransactionRunner>();
const userRepository = mock<UserRepository>();
const service = new SkillsApiService(
skillService,
repository,
projectScopeService,
txRunner,
userRepository,
);
const TX_CTX = { trx: {} } as OperationContext;
let visible: Skill[];
beforeEach(() => {
vi.resetAllMocks();
txRunner.run.mockImplementation(async (_ctx, fn) => await fn(TX_CTX));
visible = Object.keys(NAMES).map((id) => skill(id));
projectScopeService.getProjectIds.mockResolvedValue(['project-1']);
vi.mocked(hasGlobalScope).mockReturnValue(false);
@ -102,8 +116,8 @@ describe('SkillsApiService', () => {
new Map(visible.filter((s) => ids.includes(s.id)).map((s) => [s.id, row(s, 2)] as const)),
);
repository.countUsingAgents.mockResolvedValue(new Map());
repository.findProjectNames.mockImplementation(
async (ids) => new Map(ids.map((id) => [id, 'Team'] as const)),
repository.findProjects.mockImplementation(
async (ids) => new Map(ids.map((id) => [id, { name: 'Team', type: 'team' as const }])),
);
repository.findUsage.mockResolvedValue({ drafts: [], pins: [], hiddenAgents: 0 });
repository.findAgents.mockImplementation(
@ -119,6 +133,39 @@ describe('SkillsApiService', () => {
});
describe('list', () => {
it('names the creator of each skill, with one user query for the page', async () => {
visible = [
skill('skill_a', { createdById: 'user-2' }),
skill('skill_b', { createdById: 'user-2' }),
skill('skill_c', { createdById: 'user-gone' }),
];
userRepository.findManyByIds.mockResolvedValue([
mock<User>({
id: 'user-2',
email: 'ada@example.com',
firstName: 'Ada',
lastName: 'Lovelace',
}),
]);
const result = await service.list(user, query());
expect(userRepository.findManyByIds).toHaveBeenCalledTimes(1);
expect(userRepository.findManyByIds).toHaveBeenCalledWith(['user-2', 'user-gone']);
expect(result.data.map((item) => item.createdBy)).toEqual([
{ id: 'user-2', email: 'ada@example.com', firstName: 'Ada', lastName: 'Lovelace' },
{ id: 'user-2', email: 'ada@example.com', firstName: 'Ada', lastName: 'Lovelace' },
null,
]);
});
it('skips the user query when no skill has a creator', async () => {
const result = await service.list(user, query());
expect(userRepository.findManyByIds).not.toHaveBeenCalled();
expect(result.data.every((item) => item.createdBy === null)).toBe(true);
});
it('passes the filters and the page to the repository', async () => {
const result = await service.list(
user,
@ -193,7 +240,9 @@ describe('SkillsApiService', () => {
it('describes each skill', async () => {
visible = [skill('skill_a', { projectId: 'project-1' })];
repository.countUsingAgents.mockResolvedValue(new Map([['skill_a', 2]]));
repository.findProjectNames.mockResolvedValue(new Map([['project-1', 'Team']]));
repository.findProjects.mockResolvedValue(
new Map([['project-1', { name: 'Team', type: 'team' as const }]]),
);
skillService.accessCheck.mockImplementation(async (_user, op) => () => op === 'update');
const [item] = (await service.list(user, query())).data;
@ -205,9 +254,11 @@ describe('SkillsApiService', () => {
scope: 'project',
projectId: 'project-1',
projectName: 'Team',
projectType: 'team',
agentId: null,
agentName: null,
source: 'ui',
createdBy: null,
latestVersion: 2,
usedByAgents: 2,
canEdit: true,
@ -219,7 +270,9 @@ describe('SkillsApiService', () => {
it('describes an agent skill with its agent and the project of the agent', async () => {
visible = [skill('skill_a', { agentId: 'agent-1' })];
repository.findProjectNames.mockResolvedValue(new Map([['project-1', 'Team']]));
repository.findProjects.mockResolvedValue(
new Map([['project-1', { name: 'Team', type: 'team' as const }]]),
);
const check = vi.fn().mockReturnValue(true);
skillService.accessCheck.mockResolvedValue(check);
@ -332,6 +385,7 @@ describe('SkillsApiService', () => {
projectId: 'project-1',
});
expect(skillService.create).toHaveBeenCalledWith({
checkNameTaken: true,
target: { projectId: null, agentId: 'agent-1' },
skill: body,
source: 'ui',
@ -393,12 +447,12 @@ describe('SkillsApiService', () => {
// A global projectSkill:create passes the scope check for any project id.
it('refuses a project skill for a project that does not exist', async () => {
vi.mocked(userHasScopes).mockResolvedValue(true);
repository.findProjectNames.mockResolvedValue(new Map());
repository.findProjects.mockResolvedValue(new Map());
await expect(
service.create(user, payload({ scope: 'project', projectId: 'project-gone' })),
).rejects.toThrow(NotFoundError);
expect(repository.findProjectNames).toHaveBeenCalledWith(['project-gone']);
expect(repository.findProjects).toHaveBeenCalledWith(['project-gone']);
expect(skillService.create).not.toHaveBeenCalled();
});
});
@ -452,6 +506,63 @@ describe('SkillsApiService', () => {
service.update(user, 'skill_a', update({ instructions: 'New.' })),
).rejects.toThrow(NotFoundError);
});
it('moves the skill and saves it in one transaction, then refreshes after the commit', async () => {
vi.mocked(userHasScopes).mockResolvedValue(true);
repository.findProjects.mockResolvedValue(
new Map([['project-1', { name: 'Team', type: 'team' as const }]]),
);
skillService.save.mockResolvedValue(saved);
await service.update(
user,
'skill_a',
update({ scope: 'project', projectId: 'project-1', baseSkillHash: 'base' }),
);
expect(skillService.changeTarget).toHaveBeenCalledWith(
'skill_a',
{ projectId: 'project-1', agentId: null },
undefined,
TX_CTX,
undefined,
);
expect(skillService.save).toHaveBeenCalledWith(
'skill_a',
{ baseSkillHash: 'base' },
user.id,
TX_CTX,
);
expect(skillService.refreshFollowingAgents).toHaveBeenCalledWith(['agent-1']);
// The move runs first, so the save checks a rename in the new scope.
expect(skillService.changeTarget.mock.invocationCallOrder[0]).toBeLessThan(
skillService.save.mock.invocationCallOrder[0],
);
});
it('does not move a skill that is already in the requested scope', async () => {
vi.mocked(hasGlobalScope).mockReturnValue(true);
skillService.save.mockResolvedValue(saved);
await service.update(user, 'skill_a', update({ scope: 'instance', baseSkillHash: 'base' }));
expect(skillService.changeTarget).not.toHaveBeenCalled();
expect(txRunner.run).not.toHaveBeenCalled();
});
it('refuses a move to a place where the user may not create skills', async () => {
vi.mocked(userHasScopes).mockResolvedValue(false);
await expect(
service.update(
user,
'skill_a',
update({ scope: 'project', projectId: 'project-1', baseSkillHash: 'base' }),
),
).rejects.toThrow(ForbiddenError);
expect(skillService.changeTarget).not.toHaveBeenCalled();
expect(skillService.save).not.toHaveBeenCalled();
});
});
describe('delete', () => {

View File

@ -4,10 +4,11 @@ import { Container } from '@n8n/di';
import { SkillsController } from '../skills.controller';
/**
* The skills routes carry no scope decorator: `SkillsApiService` checks each skill row.
* The skills routes carry no scope decorator: `SkillsApiService` checks each skill row,
* or for `newName` the create permission in the requested scope, as `create` does.
* A new route must add that check on purpose, so the set of routes is pinned here.
*/
const ROW_CHECKED_HANDLERS = ['create', 'delete', 'get', 'list', 'update'];
const ROW_CHECKED_HANDLERS = ['create', 'delete', 'get', 'list', 'newName', 'update'];
describe('SkillsController routes', () => {
const metadata = Container.get(ControllerRegistryMetadata).getControllerMetadata(

View File

@ -359,6 +359,7 @@ export class AgentSkillRefsService {
skill,
source: 'agent',
createdById: creatorId,
checkNameTaken: true,
},
ctx,
),
@ -446,8 +447,16 @@ export class AgentSkillRefsService {
skillId: string,
availableTo: AgentSkillAvailability,
ctx: OperationContext,
/** The name the skill has after this request, when the same request renames it. */
nameAfterMove?: string,
): Promise<void> {
await this.skillService.changeTarget(skillId, targetFor(agent, availableTo), agent.id, ctx);
await this.skillService.changeTarget(
skillId,
targetFor(agent, availableTo),
agent.id,
ctx,
nameAfterMove,
);
}
private async writeLegacyDraft(

View File

@ -277,7 +277,14 @@ export class AgentSkillsService {
if (pinned) markAgentDraftDirty(entity);
const saved = await this.transactionRunner.run({}, async (ctx) => {
if (nextAvailability) {
await this.agentSkillRefs.setAvailability(entity, skillId, nextAvailability, ctx);
// The move checks the name the skill has after the save, not the old one.
await this.agentSkillRefs.setAvailability(
entity,
skillId,
nextAvailability,
ctx,
updated.name,
);
}
if (pinned) {
await saveAgentDraftFenced(this.agentRepository, entity, ctx);

View File

@ -95,6 +95,7 @@ import { composeJsonConfig } from '../json-config/agent-config-composition';
import { 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';
const STALE_CONFIG_ERROR: ConfigValidationError = {
path: '(root)',
@ -181,6 +182,12 @@ type EditorLockFailure = { ok: false; errors: ConfigValidationError[] };
const STALE_SKILL_ERROR_MESSAGE =
'Skill changed. Call agent-context with type "skill", then retry with its skill and skillHash.';
/** A taken name is a conflict too, but a retry with a fresh hash would fail the same way. */
function updateSkillErrorMessage(error: unknown, message: string): string {
if (error instanceof SkillNameTakenError) return skillNameTakenToolMessage(error.skillName);
return error instanceof ConflictError ? STALE_SKILL_ERROR_MESSAGE : message;
}
/** LLM-facing follow-up guidance for this builder surface (CLI skill-based tools). */
const CLI_AGENT_CONFIG_MESSAGES: AgentConfigValidationMessages = {
emptyInstructionsFollowUp: 'saving the config again.',
@ -191,7 +198,10 @@ const CLI_AGENT_CONFIG_MESSAGES: AgentConfigValidationMessages = {
const createSkillInputSchema = z
.object({
name: agentSkillSchema.shape.name.describe('Human-readable skill name'),
name: agentSkillSchema.shape.name.describe(
'Human-readable skill name. It must be unique in the project: no other skill of this ' +
'project may have a name like it (case, spaces, hyphens and underscores do not count).',
),
description: agentSkillSchema.shape.description.describe(SKILL_DESCRIPTION_RULE),
instructions: agentSkillSchema.shape.instructions.describe(SKILL_BODY_GUIDANCE),
references: agentSkillSchema.shape.references
@ -1179,10 +1189,7 @@ export class AgentsBuilderToolsService {
};
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return {
ok: false,
errors: [{ message: e instanceof ConflictError ? STALE_SKILL_ERROR_MESSAGE : message }],
};
return { ok: false, errors: [{ message: updateSkillErrorMessage(e, message) }] };
}
})
.build();
@ -1241,10 +1248,13 @@ export class AgentsBuilderToolsService {
skills: created.skills.map(({ id, skill }) => ({ id, name: skill.name })),
};
} catch (e) {
return {
ok: false,
errors: [{ message: e instanceof Error ? e.message : String(e) }],
};
const message =
e instanceof SkillNameTakenError
? `${skillNameTakenToolMessage(e.skillName)} The batch is all-or-nothing, so send every skill again.`
: e instanceof Error
? e.message
: String(e);
return { ok: false, errors: [{ message }] };
}
})
.build();

View File

@ -204,6 +204,30 @@ export class SkillRepository extends BaseRepository<Skill> {
);
}
/**
* The name of the latest version of every skill in one target: the same agent, the
* same project (its agent skills not included) or the instance. For the name check.
*/
async findLatestNamesInTarget(
target: SkillTarget,
ctx: OperationContext = {},
): Promise<Array<{ skillId: string; name: string }>> {
const query = highestVersionsQuery(this.managerFor(ctx))
.innerJoin(Skill, 'skill', 'skill.id = version.skillId')
.select(['version.skillId', 'version.name']);
if (target.agentId) {
query.andWhere('skill.agentId = :agentId', { agentId: target.agentId });
} else if (target.projectId) {
query
.andWhere('skill.projectId = :projectId', { projectId: target.projectId })
.andWhere('skill.agentId IS NULL');
} else {
query.andWhere('skill.projectId IS NULL').andWhere('skill.agentId IS NULL');
}
const versions = await query.getMany();
return versions.map(({ skillId, name }) => ({ skillId, name }));
}
/** Version rows by id, keyed by version id. A pinned ref reads them. */
async findVersionsByIds(
versionIds: string[],
@ -648,17 +672,23 @@ export class SkillRepository extends BaseRepository<Skill> {
return new Map([...agentsBySkill].map(([skillId, agents]) => [skillId, agents.size]));
}
async findProjectNames(
/** Name and type of each project, keyed by project id. A missing project is absent. */
async findProjects(
projectIds: string[],
ctx: OperationContext = {},
): Promise<Map<string, string>> {
): Promise<Map<string, Pick<Project, 'name' | 'type'>>> {
const manager = this.managerFor(ctx);
const projects = await inChunks(
projectIds,
async (chunk) =>
await manager.find(Project, { where: { id: In(chunk) }, select: ['id', 'name'] }),
await manager.find(Project, {
where: { id: In(chunk) },
select: ['id', 'name', 'type'],
}),
);
return new Map(
projects.map((project) => [project.id, { name: project.name, type: project.type }]),
);
return new Map(projects.map((project) => [project.id, project.name]));
}
/** Name and project of each agent, keyed by agent id. An agent skill takes its project from them. */
@ -784,20 +814,24 @@ async function inChunks<T>(ids: string[], read: (chunk: string[]) => Promise<T[]
return results;
}
/** The version rows that have the highest number of their skill. */
function highestVersionsQuery(manager: EntityManager) {
return manager.createQueryBuilder(SkillVersion, 'version').where((qb) => {
const highest = qb
.subQuery()
.select('MAX(later.version)')
.from(SkillVersion, 'later')
.where('later.skillId = version.skillId')
.getQuery();
return `version.version = ${highest}`;
});
}
/** The version rows of these skills that have the highest number of their skill. */
function latestVersionsQuery(manager: EntityManager, skillIds: string[]) {
return manager
.createQueryBuilder(SkillVersion, 'version')
.where('version.skillId IN (:...skillIds)', { skillIds })
.andWhere((qb) => {
const highest = qb
.subQuery()
.select('MAX(later.version)')
.from(SkillVersion, 'later')
.where('later.skillId = version.skillId')
.getQuery();
return `version.version = ${highest}`;
});
return highestVersionsQuery(manager).andWhere('version.skillId IN (:...skillIds)', {
skillIds,
});
}
async function insertVersion(

View File

@ -1,4 +1,9 @@
import { CreateSkillDto, ListSkillsQueryDto, SaveSkillDto } from '@n8n/api-types';
import {
CreateSkillDto,
ListSkillsQueryDto,
SaveSkillDto,
SkillNewNameQueryDto,
} from '@n8n/api-types';
import type { AuthenticatedRequest } from '@n8n/db';
import {
Body,
@ -42,6 +47,12 @@ export class SkillsController {
return await this.skillsApi.list(req.user, query);
}
/** Registered before `/:skillId`, which would take `new-name` as an id. */
@Get('/new-name')
async newName(req: AuthenticatedRequest, _res: Response, @Query query: SkillNewNameQueryDto) {
return await this.skillsApi.newName(req.user, query);
}
@Get('/:skillId')
async get(req: AuthenticatedRequest, _res: Response, @Param('skillId') skillId: string) {
return await this.skillsApi.get(req.user, skillId);

View File

@ -0,0 +1,21 @@
import { SKILL_NAME_TAKEN_MESSAGE } from '@n8n/api-types';
import { ConflictError } from '@n8n/errors';
/**
* A skill name is taken in its scope. It is a conflict like a stale save, but AI tools
* must not report it as one: a retry with the same name fails again. So the error
* carries the name, and each tool tells the model which name to change.
*/
export class SkillNameTakenError extends ConflictError {
constructor(readonly skillName: string) {
super(SKILL_NAME_TAKEN_MESSAGE);
}
}
/** The message AI tools return for a taken name. Nothing is saved when it happens. */
export function skillNameTakenToolMessage(skillName: string): string {
return (
`The skill name "${skillName}" is taken by another skill in the same scope, so nothing ` +
'was saved. Choose a different name and retry.'
);
}

View File

@ -1,4 +1,4 @@
import type { AgentSkill } from '@n8n/api-types';
import { AGENT_SKILL_NAME_MAX_LENGTH, skillNameKey, type AgentSkill } from '@n8n/api-types';
import { Logger } from '@n8n/backend-common';
import { ProjectScopeService } from '@n8n/backend-services';
import { TransactionRunner, type OperationContext, type User } from '@n8n/db';
@ -20,6 +20,7 @@ import {
} from '../repositories/skill.repository';
import { getAgentSkillHash } from '../utils/agent-config-hash';
import { skillContentHash, type SkillContent } from './skill-content-hash';
import { SkillNameTakenError } from './skill-name-taken.error';
import { findIntroducedNameClashes, renameIntroducesClash } from './skill-names';
/** A skill ref in an agent config. A ref with `versionId` runs that version. */
@ -161,11 +162,24 @@ export class SkillService {
// ---------------------------------------------------------------- writes
/** Creates a skill with its v1. Returns the new id. */
/**
* `checkNameTaken` is for a name that a user or the builder chose. A copy of an agent
* skill for a new agent keeps the name of the skill it copies, so it skips the check.
*/
async create(
input: { target: SkillTarget; skill: AgentSkill; source: SkillSource; createdById: string },
input: {
target: SkillTarget;
skill: AgentSkill;
source: SkillSource;
createdById: string;
checkNameTaken?: boolean;
},
ctx: OperationContext = {},
): Promise<string> {
assertHasInstructions(input.skill);
if (input.checkNameTaken) {
await this.assertNameFree(input.skill.name, input.target, undefined, ctx);
}
const id = `skill_${generateNanoId()}`;
await this.skillRepository.createSkill(
{ id, target: input.target, source: input.source, createdById: input.createdById },
@ -213,7 +227,10 @@ export class SkillService {
const result = { ...unchanged, created: false, skill: current, followingAgentIds: [] };
return { saved: result, following: [] };
}
if (skill.name !== latest.version.name) {
// A rename that only changes case or separators keeps the name key, so it can
// clash with nothing new. The legacy rule skipped it too.
if (skillNameKey(skill.name) !== skillNameKey(latest.version.name)) {
await this.assertNameFree(skill.name, latest.skill, skillId, txCtx);
await this.assertRenameKeepsNamesApart(skillId, latest.version.name, skill.name, txCtx);
}
const version = await this.skillRepository.nextVersionNumber(skillId, txCtx);
@ -257,6 +274,50 @@ export class SkillService {
}
}
/**
* The requested name if its scope has no name like it, else the name with the first
* free number after it ("New skill 2"), as workflow names get.
*/
async freeName(requested: string, target: SkillTarget, ctx: OperationContext = {}) {
const taken = new Set(
(
await this.skillRepository.findLatestNamesInTarget(
{ projectId: target.projectId, agentId: target.agentId },
ctx,
)
).map(({ name }) => skillNameKey(name)),
);
const base = requested.trim();
if (!taken.has(skillNameKey(base))) return base;
for (let n = 2; ; n++) {
const suffix = ` ${n}`;
const candidate = `${base.slice(0, AGENT_SKILL_NAME_MAX_LENGTH - suffix.length)}${suffix}`;
if (!taken.has(skillNameKey(candidate))) return candidate;
}
}
/**
* Refuses a name that is hard to tell apart from another skill's name in the same
* scope: one agent, one project or the instance, by the same rule as inside one agent.
*/
private async assertNameFree(
name: string,
target: SkillTarget,
exceptSkillId: string | undefined,
ctx: OperationContext,
): Promise<void> {
const key = skillNameKey(name);
const names = await this.skillRepository.findLatestNamesInTarget(
{ projectId: target.projectId, agentId: target.agentId },
ctx,
);
if (
names.some((other) => other.skillId !== exceptSkillId && skillNameKey(other.name) === key)
) {
throw new SkillNameTakenError(name);
}
}
private async assertRenameKeepsNamesApart(
skillId: string,
oldName: string,
@ -416,14 +477,17 @@ export class SkillService {
/**
* Moves a skill to a project, one agent or the instance. Refused while an agent other
* than `keepAgentId` uses it. The edit lock makes a concurrent attach wait, so the check
* and the move see the same agents.
* than `keepAgentId` uses it, or while any agent uses it when there is no `keepAgentId`.
* The edit lock makes a concurrent attach wait, so the check and the move see the same
* agents.
*/
async changeTarget(
skillId: string,
target: SkillTarget,
keepAgentId: string,
keepAgentId: string | undefined,
ctx: OperationContext = {},
/** The name the skill has after this request, when the same request renames it. */
nameAfterMove?: string,
): Promise<void> {
await this.txRunner.run(ctx, async (txCtx) => {
await this.skillRepository.lockForEdit([skillId], txCtx);
@ -432,9 +496,16 @@ export class SkillService {
);
if (others.length > 0) {
throw new ConflictError(
'Other agents use this skill. Detach it from those agents first, then change where it can be added.',
keepAgentId
? 'Other agents use this skill. Detach it from those agents first, then change where it can be added.'
: 'Agents use this skill. Detach it from every agent first, then change where it can be added.',
);
}
// The new scope must not already hold a name like the one the skill has after the move.
const name =
nameAfterMove ??
(await this.skillRepository.findLatestSummaries([skillId], txCtx)).get(skillId)?.name;
if (name) await this.assertNameFree(name, target, skillId, txCtx);
await this.skillRepository.updateTarget(skillId, target, txCtx);
});
}

View File

@ -7,9 +7,12 @@ import type {
SkillSaveResponse,
SaveSkillDto,
SkillScope,
SkillUserDto,
SkillNewNameQueryDto,
SkillNewNameResponse,
} from '@n8n/api-types';
import { ProjectScopeService } from '@n8n/backend-services';
import type { User } from '@n8n/db';
import { TransactionRunner, UserRepository, type User } from '@n8n/db';
import { Service } from '@n8n/di';
import { ForbiddenError, NotFoundError, UserError } from '@n8n/errors';
import { hasGlobalScope } from '@n8n/permissions';
@ -38,6 +41,8 @@ export class SkillsApiService {
private readonly skillService: SkillService,
private readonly skillRepository: SkillRepository,
private readonly projectScopeService: ProjectScopeService,
private readonly txRunner: TransactionRunner,
private readonly userRepository: UserRepository,
) {}
async list(user: User, query: ListSkillsQueryDto): Promise<SkillListResponse> {
@ -64,9 +69,16 @@ export class SkillsApiService {
return await this.toDetail(user, await this.requireAccess(user, skillId, 'read'));
}
/** A free name for a new skill. Needs the create permission there, so it shows no other scope. */
async newName(user: User, query: SkillNewNameQueryDto): Promise<SkillNewNameResponse> {
const target = await this.targetForCreate(user, query);
return { name: await this.skillService.freeName(query.name, target) };
}
async create(user: User, payload: CreateSkillDto): Promise<SkillDetail> {
const target = await this.targetForCreate(user, payload);
const id = await this.skillService.create({
checkNameTaken: true,
target,
skill: payload.skill,
source: 'ui',
@ -80,12 +92,37 @@ export class SkillsApiService {
/** Saves the changes as the next version. Unchanged content creates nothing. */
async update(user: User, skillId: string, payload: SaveSkillDto): Promise<SkillSaveResponse> {
await this.requireAccess(user, skillId, 'update');
const { followingAgentIds: _following, ...saved } = await this.skillService.save(
skillId,
{ ...payload },
user.id,
);
const skill = await this.requireAccess(user, skillId, 'update');
const { scope, projectId, ...update } = payload;
const target = scope ? await this.targetForCreate(user, { scope, projectId }) : undefined;
const moves =
target !== undefined &&
(target.projectId !== skill.projectId || target.agentId !== skill.agentId);
// A move takes the skill out of its scope, as a delete does, so it needs both rights
// there. A custom role can grant update without delete.
if (moves && !(await this.skillService.canAccess(user, skill, 'delete'))) {
throw new ForbiddenError('You are not allowed to move this skill out of its scope.');
}
if (!moves) {
const { followingAgentIds: _following, ...saved } = await this.skillService.save(
skillId,
update,
user.id,
);
return { id: skillId, ...saved, skillHash: getAgentSkillHash(saved.skill) };
}
// One transaction, so a refused save does not leave the skill moved. The move runs
// first and checks the final name in the new scope only: a name that only the old
// scope holds does not block it. The save then checks a rename in the new scope too.
const { followingAgentIds, ...saved } = await this.txRunner.run({}, async (ctx) => {
await this.skillService.changeTarget(skillId, target, undefined, ctx, update.name);
return await this.skillService.save(skillId, update, user.id, ctx);
});
// `save` leaves the refresh to the caller when it joins a transaction.
await this.skillService.refreshFollowingAgents(followingAgentIds);
return { id: skillId, ...saved, skillHash: getAgentSkillHash(saved.skill) };
}
@ -146,7 +183,11 @@ export class SkillsApiService {
return { agentId, projectId: agent.projectId };
}
private async targetForCreate(user: User, payload: CreateSkillDto): Promise<SkillTarget> {
/** Where a create or a move puts the skill. The user needs the create permission there. */
private async targetForCreate(
user: User,
payload: Pick<CreateSkillDto, 'scope' | 'projectId' | 'agentId'>,
): Promise<SkillTarget> {
switch (payload.scope) {
case 'instance':
if (payload.projectId || payload.agentId) {
@ -162,7 +203,7 @@ export class SkillsApiService {
if (payload.agentId) throw new UserError('A project skill has no agent.');
await this.assertCanCreateIn(user, projectId);
// A global scope passes without looking at the project, so check that it exists.
if (!(await this.skillRepository.findProjectNames([projectId])).has(projectId)) {
if (!(await this.skillRepository.findProjects([projectId])).has(projectId)) {
throw new NotFoundError('Project not found');
}
// A personal project keeps its own skills, as a team project does.
@ -195,10 +236,11 @@ export class SkillsApiService {
);
const projectOf = (skill: Skill) =>
skill.projectId ?? (skill.agentId ? (agents.get(skill.agentId)?.projectId ?? null) : null);
const [latest, usage, projects, canEdit, canDelete] = await Promise.all([
const [latest, usage, projects, creators, canEdit, canDelete] = await Promise.all([
this.skillRepository.findLatestSummaries(ids),
this.skillRepository.countUsingAgents(ids),
this.skillRepository.findProjectNames(skills.flatMap((skill) => projectOf(skill) ?? [])),
this.skillRepository.findProjects(skills.flatMap((skill) => projectOf(skill) ?? [])),
this.findCreators(skills),
this.skillService.accessCheck(user, 'update'),
this.skillService.accessCheck(user, 'delete'),
]);
@ -213,10 +255,12 @@ export class SkillsApiService {
description: summary.description,
scope: scopeOf(skill),
projectId,
projectName: projectId ? (projects.get(projectId) ?? null) : null,
projectName: projectId ? (projects.get(projectId)?.name ?? null) : null,
projectType: projectId ? (projects.get(projectId)?.type ?? null) : null,
agentId: skill.agentId,
agentName: skill.agentId ? (agents.get(skill.agentId)?.name ?? null) : null,
source: skill.source,
createdBy: skill.createdById ? (creators.get(skill.createdById) ?? null) : null,
latestVersion: summary.version,
usedByAgents: usage.get(skill.id) ?? 0,
canEdit: canEdit(skill, projectId),
@ -227,4 +271,22 @@ export class SkillsApiService {
}
return items;
}
/** The creators of a page of skills, in one query. */
private async findCreators(skills: Skill[]): Promise<Map<string, SkillUserDto>> {
const ids = [...new Set(skills.flatMap((skill) => skill.createdById ?? []))];
if (ids.length === 0) return new Map();
const users = await this.userRepository.findManyByIds(ids);
return new Map(
users.map((creator) => [
creator.id,
{
id: creator.id,
email: creator.email ?? null,
firstName: creator.firstName ?? null,
lastName: creator.lastName ?? null,
},
]),
);
}
}

View File

@ -30,6 +30,8 @@ vi.mock('@/modules/agents/json-config/mcp-client-factory', () => ({
import { CredentialsService } from '@/credentials/credentials.service';
import { ConflictError } from '@n8n/errors';
import { SkillNameTakenError } from '@/modules/agents/skills/skill-name-taken.error';
import { AgentConfigPreparationService } from '@/modules/agents/agent-config-preparation.service';
import { AgentConfigService } from '@/modules/agents/agent-config.service';
import { AgentCustomToolsService } from '@/modules/agents/agent-custom-tools.service';
@ -725,6 +727,26 @@ describe('McpAgentToolsService', () => {
});
});
it('names a taken skill name instead of reporting a stale config', async () => {
agentSkillsService.createAndAttachSkill.mockRejectedValue(new SkillNameTakenError('Skill'));
// Another edit changed the agent while the rejected create ran.
const latestConfig = { ...composedConfig, instructions: 'Newer work' };
agentConfigService.getConfig.mockResolvedValue(latestConfig);
const result = await callTool(
'mutate_agent',
mutateInput({ type: 'skill.upsert', skill: { name: 'Skill', body: 'do it' } }),
);
expect(result.structuredContent).toMatchObject({
ok: false,
code: 'skill_name_taken',
agentId: 'agent-1',
configHash: getAgentConfigHash(latestConfig),
message: expect.stringContaining('The skill name "Skill" is taken'),
});
});
it('updates an existing skill when skillId is given', async () => {
agentSkillsService.updateSkill.mockResolvedValue({ id: 'skill-1' } as never);

View File

@ -37,6 +37,10 @@ import { AgentModelCatalogService } from '@/modules/agents/agent-model-catalog.s
import { AgentPublishService } from '@/modules/agents/agent-publish.service';
import { AgentSkillRefsService } from '@/modules/agents/agent-skill-refs.service';
import { AgentSkillsService } from '@/modules/agents/agent-skills.service';
import {
SkillNameTakenError,
skillNameTakenToolMessage,
} from '@/modules/agents/skills/skill-name-taken.error';
import { AgentTaskService } from '@/modules/agents/agent-task.service';
import {
AgentTestRunService,
@ -703,6 +707,17 @@ export class McpAgentToolsService {
try {
mutation = await this.applyMutation(user, input, config, projectId, configHash);
} catch (error) {
// A taken name is a conflict too, but a retry with a fresh config would fail the same way.
if (error instanceof SkillNameTakenError) {
return {
ok: false,
code: 'skill_name_taken',
agentId: input.agentId,
// The agent may have changed meanwhile, so send the hash the next call needs.
configHash: await this.fetchConfigHash(projectId, input.agentId),
message: skillNameTakenToolMessage(error.skillName),
};
}
if (!(error instanceof ConflictError)) throw error;
const latestConfigHash = await this.fetchConfigHash(projectId, input.agentId);
if (latestConfigHash === configHash) throw error;

View File

@ -246,6 +246,32 @@ describe('Agent skills in the skills hub', () => {
).rejects.toThrow('This agent uses an older version of this skill.');
});
it('refuses a skill from the agent builder whose name a skill of its project has', async () => {
await createSkill(body('Triage'));
const agent = await createAgent([]);
await expect(
agentSkillsService.createAndAttachSkill(agent.id, project.id, body('TRIAGE'), {
user: editor,
modifiedBy: 'builder',
}),
).rejects.toThrow('That name is taken, choose another');
});
it('lets the agent builder use a name that only an instance skill has', async () => {
await createSkill(body('Triage'), { projectId: null });
const agent = await createAgent([]);
const created = await agentSkillsService.createAndAttachSkill(
agent.id,
project.id,
body('Triage'),
{ user: editor, modifiedBy: 'builder' },
);
expect(created.id).toBeDefined();
});
it('creates, updates and detaches a skill from the agent builder', async () => {
const agent = await createAgent([]);
const context = { user: editor, modifiedBy: 'builder' as const };
@ -411,6 +437,39 @@ describe('Agent skills in the skills hub', () => {
await expect(update('agent')).rejects.toThrow('Other agents use this skill.');
});
it('renames and moves a skill in one save to a name that only the old scope holds', async () => {
const agent = await createAgent([]);
const context = { user: editor, modifiedBy: 'user' as const };
const created = await agentSkillsService.createAndAttachSkill(
agent.id,
project.id,
body('Summarize'),
context,
);
const update = async (updates: Partial<AgentSkill>, availableTo: 'project' | 'agent') =>
await agentSkillsService.updateSkill(
agent.id,
project.id,
created.id,
updates,
context,
undefined,
undefined,
availableTo,
);
await update({}, 'agent');
await createSkill(body('summarize'));
await update({ name: 'Condense' }, 'project');
expect((await Container.get(SkillRepository).findByIds([created.id]))[0]).toMatchObject({
projectId: project.id,
agentId: null,
});
const { skills } = await skillRefs.readDraft(await reload(agent), {});
expect(skills[created.id]?.name).toBe('Condense');
});
it('shares the skills of a duplicated agent', async () => {
const skill = await createSkill(body('Shared'));
const original = await createAgent([skill]);

View File

@ -1,4 +1,9 @@
import { createTeamProject, testDb, testModules } from '@n8n/backend-test-utils';
import {
createTeamProject,
getPersonalProject,
testDb,
testModules,
} from '@n8n/backend-test-utils';
import { TransactionRunner, type Project, type User } from '@n8n/db';
import { Container } from '@n8n/di';
import { randomUUID } from 'node:crypto';
@ -177,6 +182,27 @@ describe('SkillRepository', () => {
});
});
describe('findLatestNamesInTarget', () => {
it('returns the latest name of each skill in one target only', async () => {
const instance = await createSkill({}, content({ name: 'Old name' }));
await skills.insertSavedVersion(instance, 2, content({ name: 'New name' }), null);
const inProject = await createSkill({ projectId: project.id }, content({ name: 'P' }));
const agent = await createAgent('Agent', { projectId: project.id });
const ofAgent = await createSkill({ agentId: agent.id }, content({ name: 'A' }));
expect(await skills.findLatestNamesInTarget({ projectId: null, agentId: null })).toEqual([
{ skillId: instance, name: 'New name' },
]);
// The agent skills of a project are not project skills.
expect(
await skills.findLatestNamesInTarget({ projectId: project.id, agentId: null }),
).toEqual([{ skillId: inProject, name: 'P' }]);
expect(await skills.findLatestNamesInTarget({ projectId: null, agentId: agent.id })).toEqual([
{ skillId: ofAgent, name: 'A' },
]);
});
});
describe('findVisiblePage', () => {
const firstPage = { skip: 0 };
const ids = (page: { skills: Array<{ id: string }> }) => page.skills.map((skill) => skill.id);
@ -666,14 +692,22 @@ describe('SkillRepository', () => {
});
});
describe('findProjectNames', () => {
it('maps project ids to names', async () => {
const names = await skills.findProjectNames([project.id, project.id, otherProject.id]);
describe('findProjects', () => {
it('maps project ids to names and types', async () => {
const personal = await getPersonalProject(user);
expect(names).toEqual(
const projects = await skills.findProjects([
project.id,
project.id,
otherProject.id,
personal.id,
]);
expect(projects).toEqual(
new Map([
[project.id, 'Team'],
[otherProject.id, 'Other team'],
[project.id, { name: 'Team', type: 'team' }],
[otherProject.id, { name: 'Other team', type: 'team' }],
[personal.id, { name: personal.name, type: 'personal' }],
]),
);
});

View File

@ -7,8 +7,10 @@ import { setSkillsHubFlag } from '@/modules/agents/__tests__/test-utils/skills-h
import { AgentHistoryRepository } from '@/modules/agents/repositories/agent-history.repository';
import { AgentRepository } from '@/modules/agents/repositories/agent.repository';
import { SkillRepository } from '@/modules/agents/repositories/skill.repository';
import { SkillService } from '@/modules/agents/skills/skill.service';
import { createMember, createOwner } from '../shared/db/users';
import { createCustomRoleWithScopeSlugs } from '../shared/db/roles';
import { createMember, createOwner, createUser } from '../shared/db/users';
import type { SuperAgentTest } from '../shared/types';
import { setupTestServer } from '../shared/utils';
@ -129,6 +131,7 @@ describe('SkillsController', () => {
agentName: 'Support bot',
projectId: project.id,
projectName: 'Team',
projectType: 'team',
latestVersion: 1,
canEdit: true,
canDelete: true,
@ -223,6 +226,17 @@ describe('SkillsController', () => {
});
describe('list', () => {
it('names the user who created each skill', async () => {
await create(owner, { scope: 'instance', skill: body });
const response = await owner.get('/skills').expect(200);
expect(response.body.data.data[0].createdBy).toMatchObject({
id: ownerUser.id,
email: ownerUser.email,
});
});
it('lists what the user may see, searched and paged on the server', async () => {
const agent = await newAgent('Support bot', project.id);
await create(editor, { scope: 'agent', agentId: agent.id, skill: { ...body, name: 'Mine' } });
@ -388,6 +402,296 @@ describe('SkillsController', () => {
});
});
describe('unique names', () => {
it('refuses a name like one that another skill in the same scope has', async () => {
await create(editor, { scope: 'project', projectId: project.id, skill: body });
const response = await editor
.post('/skills')
.send({ scope: 'project', projectId: project.id, skill: { ...body, name: 'brand-voice' } })
.expect(409);
expect(response.body.message).toBe('That name is taken, choose another');
});
it('allows the same name in another scope', async () => {
await create(editor, { scope: 'project', projectId: project.id, skill: body });
await create(owner, { scope: 'instance', skill: { ...body, name: 'brand-voice' } });
});
it('allows the same name in two team projects', async () => {
const other = await createTeamProject('Other team');
await create(owner, { scope: 'project', projectId: project.id, skill: body });
await create(owner, { scope: 'project', projectId: other.id, skill: body });
});
it('allows a rename that only changes case, even when another skill shares the name', async () => {
// The startup copy can leave two skills with one name key in a scope.
const { id, skillHash } = await create(owner, { scope: 'instance', skill: body });
await Container.get(SkillService).create({
target: { projectId: null, agentId: null },
skill: { ...body, name: 'brand voice' },
source: 'ui',
createdById: ownerUser.id,
});
await owner
.patch(`/skills/${id}`)
.send({ name: 'BRAND VOICE', baseSkillHash: skillHash })
.expect(200);
});
it('renames and moves a skill to a name that only the old scope holds', async () => {
await create(owner, { scope: 'project', projectId: project.id, skill: body });
const { id, skillHash } = await create(owner, {
scope: 'project',
projectId: project.id,
skill: { ...body, name: 'Code review' },
});
await owner
.patch(`/skills/${id}`)
.send({ name: 'brand-voice', scope: 'instance', baseSkillHash: skillHash })
.expect(200);
const read = await owner.get(`/skills/${id}`).expect(200);
expect(read.body.data).toMatchObject({ name: 'brand-voice', scope: 'instance' });
});
it('refuses to move a skill into a scope that holds a name like it', async () => {
await create(owner, { scope: 'instance', skill: body });
const { id, skillHash } = await create(owner, {
scope: 'project',
projectId: project.id,
skill: { ...body, name: 'BRAND VOICE' },
});
await owner
.patch(`/skills/${id}`)
.send({ scope: 'instance', baseSkillHash: skillHash })
.expect(409);
const read = await owner.get(`/skills/${id}`).expect(200);
expect(read.body.data).toMatchObject({ scope: 'project', projectId: project.id });
});
it('refuses a rename to a taken name and allows a save that keeps the name', async () => {
await create(owner, { scope: 'instance', skill: { ...body, name: 'Code review' } });
const { id, skillHash } = await create(owner, { scope: 'instance', skill: body });
await owner
.patch(`/skills/${id}`)
.send({ name: 'Code Review', baseSkillHash: skillHash })
.expect(409);
await owner
.patch(`/skills/${id}`)
.send({ instructions: 'Be very brief.', baseSkillHash: skillHash })
.expect(200);
});
});
describe('new name', () => {
const newName = async (agent: SuperAgentTest, query: Record<string, string>, status = 200) =>
(await agent.get('/skills/new-name').query(query).expect(status)).body.data as {
name: string;
};
it('numbers a default name that its scope already holds', async () => {
await create(editor, {
scope: 'project',
projectId: project.id,
skill: { ...body, name: 'New skill' },
});
await create(editor, {
scope: 'project',
projectId: project.id,
skill: { ...body, name: 'New skill 2' },
});
const free = await newName(editor, {
scope: 'project',
projectId: project.id,
name: 'New skill',
});
expect(free).toEqual({ name: 'New skill 3' });
});
it('looks at the requested scope only', async () => {
await create(owner, { scope: 'instance', skill: { ...body, name: 'New skill' } });
const free = await newName(editor, {
scope: 'project',
projectId: project.id,
name: 'New skill',
});
expect(free).toEqual({ name: 'New skill' });
});
it('ignores the names of another project', async () => {
const other = await createTeamProject('Other team');
await create(owner, {
scope: 'project',
projectId: other.id,
skill: { ...body, name: 'New skill' },
});
const free = await newName(editor, {
scope: 'project',
projectId: project.id,
name: 'New skill',
});
expect(free).toEqual({ name: 'New skill' });
});
it('needs the create permission in that scope', async () => {
await newName(viewer, { scope: 'project', projectId: project.id, name: 'New skill' }, 403);
await newName(member, { scope: 'instance', name: 'New skill' }, 403);
});
it('refuses a user outside the project, for a project and for an agent', async () => {
const agent = await newAgent('Support bot', project.id);
await newName(outsider, { scope: 'project', projectId: project.id, name: 'New skill' }, 403);
await newName(outsider, { scope: 'agent', agentId: agent.id, name: 'New skill' }, 403);
});
});
describe('change scope', () => {
it('moves an unused skill and saves its content in the same request', async () => {
const { id, skillHash } = await create(editor, {
scope: 'project',
projectId: project.id,
skill: body,
});
const saved = await owner
.patch(`/skills/${id}`)
.send({ instructions: 'Be very brief.', scope: 'instance', baseSkillHash: skillHash })
.expect(200);
const read = await owner.get(`/skills/${id}`).expect(200);
expect(saved.body.data).toMatchObject({ version: 2, created: true });
expect(read.body.data).toMatchObject({ scope: 'instance', projectId: null });
});
it('moves a skill without a content change', async () => {
const other = await createTeamProject('Other team');
const { id, skillHash } = await create(owner, {
scope: 'project',
projectId: project.id,
skill: body,
});
const saved = await owner
.patch(`/skills/${id}`)
.send({ scope: 'project', projectId: other.id, baseSkillHash: skillHash })
.expect(200);
const read = await owner.get(`/skills/${id}`).expect(200);
expect(saved.body.data).toMatchObject({ version: 1, created: false });
expect(read.body.data).toMatchObject({ scope: 'project', projectId: other.id });
});
it('needs the create permission where the skill goes', async () => {
const { id, skillHash } = await create(editor, {
scope: 'project',
projectId: project.id,
skill: body,
});
await editor
.patch(`/skills/${id}`)
.send({ scope: 'instance', baseSkillHash: skillHash })
.expect(403);
const read = await editor.get(`/skills/${id}`).expect(200);
expect(read.body.data).toMatchObject({ scope: 'project', projectId: project.id });
});
it('needs a role in the team project where the skill goes', async () => {
const other = await createTeamProject('Other team');
const { id, skillHash } = await create(editor, {
scope: 'project',
projectId: project.id,
skill: body,
});
await editor
.patch(`/skills/${id}`)
.send({ scope: 'project', projectId: other.id, baseSkillHash: skillHash })
.expect(403);
const read = await editor.get(`/skills/${id}`).expect(200);
expect(read.body.data).toMatchObject({ scope: 'project', projectId: project.id });
});
it('needs the delete permission where the skill comes from', async () => {
// A custom role that may create and edit skills everywhere but delete none.
const role = await createCustomRoleWithScopeSlugs(
[
'skill:create',
'skill:read',
'skill:update',
'projectSkill:create',
'projectSkill:read',
'projectSkill:update',
],
{ roleType: 'global' },
);
const noDelete = server.authAgentFor(await createUser({ role }));
const { id, skillHash } = await create(owner, { scope: 'instance', skill: body });
await noDelete
.patch(`/skills/${id}`)
.send({ scope: 'project', projectId: project.id, baseSkillHash: skillHash })
.expect(403);
await noDelete
.patch(`/skills/${id}`)
.send({ instructions: 'Be very brief.', baseSkillHash: skillHash })
.expect(200);
});
it('answers 409 while an agent uses the skill and saves nothing', async () => {
const { id, skillHash } = await create(editor, {
scope: 'project',
projectId: project.id,
skill: body,
});
await attachToNewAgent(id, 'Support bot', project.id);
await owner
.patch(`/skills/${id}`)
.send({ instructions: 'Be very brief.', scope: 'instance', baseSkillHash: skillHash })
.expect(409);
const read = await owner.get(`/skills/${id}`).expect(200);
expect(read.body.data).toMatchObject({ scope: 'project', latestVersion: 1 });
});
it('needs a project id to move a skill to a project', async () => {
const { id, skillHash } = await create(owner, { scope: 'instance', skill: body });
await owner
.patch(`/skills/${id}`)
.send({ scope: 'project', baseSkillHash: skillHash })
.expect(400);
});
it('does not move a skill to one agent', async () => {
const { id, skillHash } = await create(owner, { scope: 'instance', skill: body });
await owner
.patch(`/skills/${id}`)
.send({ scope: 'agent', baseSkillHash: skillHash })
.expect(400);
});
});
describe('delete', () => {
it('deletes an unused skill', async () => {
const { id } = await create(editor, { scope: 'project', projectId: project.id, skill: body });

View File

@ -98,5 +98,6 @@ export enum VIEWS {
RESOLVERS = 'Resolvers',
SETTINGS_CONTEXT = 'SettingsContext',
SETTINGS_CONTEXT_PREFERENCES = 'SettingsContextPreferences',
SETTINGS_CONTEXT_SKILLS = 'SettingsContextSkills',
RESOURCE_CENTER = 'ResourceCenter',
}

View File

@ -3434,6 +3434,53 @@
"settings.context.description": "A centralized place to manage reusable information and resources. Applied across n8n assistant, workflows, agents, and connected AI tools",
"settings.context.comingSoon": "Coming soon",
"settings.context.skills.title": "Skills",
"settings.context.skills.subtitle": "Teach the assistant and agents how to work",
"settings.context.skills.description": "Give the n8n assistant and your agents a shared way to handle specific tasks.",
"settings.context.skills.back": "Back to Context",
"settings.context.skills.count": "{count} skill | {count} skills",
"settings.context.skills.search.placeholder": "Search skills by name...",
"settings.context.skills.filter.label": "Filter by scope",
"settings.context.skills.filter.all": "All scopes",
"settings.context.skills.filter.instance": "Global",
"settings.context.skills.filter.project": "Projects",
"settings.context.skills.filter.agent": "Agents",
"settings.context.skills.columns.skill": "Skill",
"settings.context.skills.columns.scope": "Scope",
"settings.context.skills.columns.agents": "Agents",
"settings.context.skills.columns.createdBy": "Created by",
"settings.context.skills.columns.updatedAt": "Last updated",
"settings.context.skills.scope.instance": "Global",
"settings.context.skills.scope.personalProject": "Personal project",
"settings.context.skills.scope.project": "Project",
"settings.context.skills.scope.agent": "{name} only",
"settings.context.skills.scope.agentFallback": "One agent only",
"settings.context.skills.agents.label": "Show the agents that use this skill",
"settings.context.skills.agents.hidden": "{count} agent in other projects | {count} agents in other projects",
"settings.context.skills.actions.create": "Create skill",
"settings.context.skills.actions.edit": "Edit",
"settings.context.skills.actions.view": "View",
"settings.context.skills.actions.delete": "Delete",
"settings.context.skills.readOnly": "You can use but not change this skill",
"settings.context.skills.empty.title": "No skills yet",
"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.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?",
"settings.context.skills.delete.confirm.message": "You cannot undo this.",
"settings.context.skills.delete.confirm.button": "Delete",
"settings.context.skills.delete.cancel": "Cancel",
"settings.context.skills.delete.success": "Skill deleted",
"settings.context.skills.save.noChanges": "Nothing to save",
"settings.context.skills.create.hidden": "Skill created. Your search or filter hides it.",
"settings.context.skills.save.hidden": "Skill saved. Your search or filter hides it.",
"settings.context.skills.error.load": "Could not load skills",
"settings.context.skills.error.loadOne": "Could not load the skill",
"settings.context.skills.error.agents": "Could not load the agents",
"settings.context.skills.error.save": "Could not save the skill",
"settings.context.skills.error.nameTaken": "That name is taken, choose another",
"settings.context.skills.error.delete": "Could not delete the skill",
"settings.context.sources.title": "Sources",
"settings.context.preferences.title": "Preferences",
"settings.context.preferences.subtitle": "Make AI work more like you do",

View File

@ -17,7 +17,7 @@ const settingsSidebarItems = ref<Array<{ id: string; available: boolean; order?:
const activeModules = ref<string[]>([]);
const promotionsFlag = ref('false');
const canUserAccessRouteByName = vi.hoisted(() => vi.fn<(name: string) => boolean>(() => true));
const contextPreferencesEnabled = vi.hoisted(() => ({ value: true }));
const contextEnabled = vi.hoisted(() => ({ value: true }));
const openTopUpMock = vi.hoisted(() => vi.fn());
vi.mock('vue-router', () => ({ useRouter: vi.fn(() => ({})) }));
@ -25,7 +25,7 @@ vi.mock('./useUserHelpers', () => ({
useUserHelpers: vi.fn(() => ({ canUserAccessRouteByName })),
}));
vi.mock('@/features/settings/context/context.utils', () => ({
isContextPreferencesEnabled: () => contextPreferencesEnabled.value,
isContextEnabled: () => contextEnabled.value,
}));
vi.mock('./useAiGateway', () => ({
useAiGateway: vi.fn(() => ({ balance: computed(() => balance.value) })),
@ -83,7 +83,7 @@ describe('useSettingsItems', () => {
activeModules.value = [];
promotionsFlag.value = 'false';
canUserAccessRouteByName.mockReturnValue(true);
contextPreferencesEnabled.value = true;
contextEnabled.value = true;
});
describe('sidebar order', () => {
@ -186,7 +186,7 @@ describe('useSettingsItems', () => {
});
it('is hidden when the flag is off, because the route guard does not run here', () => {
contextPreferencesEnabled.value = false;
contextEnabled.value = false;
expect(idsOf()).not.toContain('settings-context');
});

View File

@ -6,7 +6,7 @@ import { computed } from 'vue';
import type { IMenuSettingItem } from '@n8n/frontend-module-sdk';
import { useI18n } from '@n8n/i18n';
import { VIEWS } from '../constants';
import { isContextPreferencesEnabled } from '@/features/settings/context/context.utils';
import { isContextEnabled } from '@/features/settings/context/context.utils';
import { useUIStore } from '../stores/ui.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { hasPermission } from '../utils/rbac/permissions';
@ -218,8 +218,7 @@ export function useSettingsItems() {
icon: 'brain',
label: i18n.baseText('settings.context.title'),
position: 'top',
available:
isContextPreferencesEnabled() && canUserAccessRouteByName(VIEWS.SETTINGS_CONTEXT),
available: isContextEnabled() && canUserAccessRouteByName(VIEWS.SETTINGS_CONTEXT),
route: { to: { name: VIEWS.SETTINGS_CONTEXT } },
preview: true,
},

View File

@ -5,6 +5,7 @@ import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
import { useUIStore } from '@/app/stores/ui.store';
import { configure, fireEvent, waitFor } from '@testing-library/vue';
import userEvent from '@testing-library/user-event';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
@ -85,12 +86,18 @@ function renderModal({
skill,
skillId,
existingSkillNames,
settings,
...hubOptions
}: {
onConfirm?: AgentSkillModalData['onConfirm'];
skill?: AgentSkill;
skillId?: string;
existingSkillNames?: string[];
/** Opens the modal as Settings > Context > Skills does: no agent, plus these fields. */
settings?: Pick<
AgentSkillModalData,
'readOnly' | 'scopeOptions' | 'initialScope' | 'scopeLockedReason' | 'getDefaultName'
>;
} & Pick<
AgentSkillModalData,
| 'hubSkillsEnabled'
@ -126,8 +133,7 @@ function renderModal({
props: {
modalName: MODAL_NAME,
data: {
projectId: 'p1',
agentId: 'a1',
...(settings ?? { projectId: 'p1', agentId: 'a1' }),
skill,
skillId,
existingSkillNames,
@ -138,6 +144,12 @@ function renderModal({
});
}
const SAVED_SKILL: AgentSkill = {
name: 'brand-voice',
description: 'Write clear customer-facing copy.',
instructions: 'Keep it short.',
};
describe('AgentSkillModal', () => {
let uiStore: ReturnType<typeof mockedStore<typeof useUIStore>>;
@ -432,6 +444,90 @@ describe('AgentSkillModal', () => {
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
});
it('focuses the name when a new skill is started by hand', async () => {
const { getByTestId } = renderModal();
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await waitFor(() => expect(getByTestId('agent-modal-title-input')).toHaveFocus());
});
it('stays open with the edits and the name error when the save is refused', async () => {
const onConfirm = vi.fn().mockResolvedValue({ saved: false, nameError: 'Name taken' });
const { getByTestId, queryByTestId } = renderModal({
onConfirm,
skill: SAVED_SKILL,
skillId: 's1',
});
await fireEvent.update(getByTestId('agent-modal-title-input'), 'Summarize');
await fireEvent.click(getByTestId('agent-skill-create-save'));
await waitFor(() =>
expect(getByTestId('agent-modal-title-error')).toHaveTextContent('Name taken'),
);
expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(getByTestId('agent-modal-title-input')).toHaveValue('Summarize');
expect(getByTestId('agent-modal-title-input')).toHaveFocus();
await fireEvent.update(getByTestId('agent-modal-title-input'), 'Condense');
expect(queryByTestId('agent-modal-title-error')).not.toBeInTheDocument();
});
it('locks the editor while the save is pending', async () => {
let finish: (value?: undefined) => void = () => {};
const onConfirm = vi.fn(
async () => await new Promise<undefined>((resolve) => (finish = resolve)),
);
const { getByTestId, queryByTestId } = renderModal({
onConfirm,
skill: SAVED_SKILL,
skillId: 's1',
});
await fireEvent.update(getByTestId('agent-modal-title-input'), 'Summarize');
await fireEvent.click(getByTestId('agent-skill-create-save'));
await waitFor(() => expect(getByTestId('agent-modal-title-input')).toBeDisabled());
expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
finish();
await waitFor(() => expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME));
});
it('clears a refused name when the user goes back and starts again', async () => {
const onConfirm = vi.fn().mockResolvedValue({ saved: false, nameError: 'Name taken' });
const { getByTestId, queryByTestId } = renderModal({ onConfirm });
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.update(
getByTestId('agent-skill-description-input').querySelector('input')!,
'Use for research',
);
await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Read the guide');
await fireEvent.click(getByTestId('agent-skill-create-save'));
await waitFor(() => expect(getByTestId('agent-modal-title-error')).toBeInTheDocument());
await fireEvent.click(getByTestId('agent-modal-back'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
expect(queryByTestId('agent-modal-title-error')).not.toBeInTheDocument();
});
it('stays open when the save fails, and closes once it succeeds', async () => {
const onConfirm = vi
.fn()
.mockRejectedValueOnce(new Error('Network down'))
.mockResolvedValueOnce(undefined);
const { getByTestId } = renderModal({ onConfirm, skill: SAVED_SKILL, skillId: 's1' });
await fireEvent.update(getByTestId('agent-modal-title-input'), 'Summarize');
await fireEvent.click(getByTestId('agent-skill-create-save'));
await waitFor(() => expect(onConfirm).toHaveBeenCalledTimes(1));
expect(uiStore.closeModal).not.toHaveBeenCalled();
await fireEvent.click(getByTestId('agent-skill-create-save'));
await waitFor(() => expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME));
});
it('creates a skill on the agent without the picker or the skills API when the skills hub is off', async () => {
const onConfirm = vi.fn();
const { container, getByTestId, queryByTestId } = renderModal({
@ -990,6 +1086,169 @@ describe('AgentSkillModal', () => {
});
});
describe('AgentSkillModal from Settings', () => {
beforeEach(() => {
vi.clearAllMocks();
createTestingPinia({ stubActions: false });
const uiStore = mockedStore(useUIStore);
uiStore.openModal(MODAL_NAME);
uiStore.closeModal = vi.fn();
});
it('shows a read-only skill without edit controls', () => {
const { getByTestId, queryByTestId } = renderModal({
skillId: 's1',
skill: SAVED_SKILL,
settings: { readOnly: true },
});
expect(getByTestId('agent-skill-read-only-callout')).toHaveTextContent(
'settings.context.skills.readOnly',
);
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
});
it('returns the chosen scope with a new skill', async () => {
const onConfirm = vi.fn();
const { getByTestId } = renderModal({
onConfirm,
settings: {
scopeOptions: [
{ value: 'project:personal-1', label: 'Personal project', icon: 'user' },
{ value: 'project:team-1', label: 'Marketing', icon: 'layers' },
],
initialScope: 'project:team-1',
},
});
await fireEvent.change(getByTestId('agent-skill-skill-md-file-input'), {
target: {
files: [new File(['---\nname: Research\ndescription: Use it\n---\nBody'], 'SKILL.md')],
},
});
await waitFor(() => expect(getByTestId('agent-skill-viewer')).toBeInTheDocument());
// Pick the option that is not the initial one, so the test fails if the choice is lost.
await userEvent.click(getByTestId('agent-skill-scope-select'));
const option = await waitFor(() => {
const match = [...document.querySelectorAll('.el-select-dropdown__item')].find((item) =>
item.textContent?.includes('Personal project'),
);
if (!match) throw new Error('Expected the Personal project option');
return match;
});
await userEvent.click(option);
await fireEvent.click(getByTestId('agent-skill-create-save'));
expect(onConfirm).toHaveBeenCalledWith({
id: undefined,
skill: { name: 'Research', description: 'Use it', instructions: 'Body' },
scope: 'project:personal-1',
});
// Settings has no agent, so the builder's import event is not sent.
expect(trackImportedSkill).not.toHaveBeenCalled();
});
it('shows the scope of a saved skill without letting the user change it', () => {
const { getByTestId } = renderModal({
skillId: 's1',
skill: SAVED_SKILL,
settings: {
scopeOptions: [{ value: 'agent', label: 'FirstClassDarwin only', icon: 'bot' }],
initialScope: 'agent',
scopeLockedReason: 'You cannot change it.',
},
});
expect(getByTestId('agent-skill-scope')).toBeInTheDocument();
expect(getByTestId('agent-skill-scope-select').querySelector('input')).toBeDisabled();
});
it('asks the server for a free default name when a skill is started by hand', async () => {
const getDefaultName = vi.fn().mockResolvedValue('New skill 3');
const { getByTestId } = renderModal({
settings: {
scopeOptions: [{ value: 'instance', label: 'Global', icon: 'globe' }],
initialScope: 'instance',
getDefaultName,
},
});
await fireEvent.click(getByTestId('agent-skill-add-manually'));
expect(getDefaultName).toHaveBeenCalledWith('instance');
await waitFor(() => expect(getTitleInput(document.body)).toHaveValue('New skill 3'));
});
it('blocks Save until the default name lookup answers', async () => {
let answer: (name: string) => void = () => {};
const { getByTestId } = renderModal({
settings: {
scopeOptions: [{ value: 'instance', label: 'Global', icon: 'globe' }],
getDefaultName: vi.fn(
async () => await new Promise<string>((resolve) => (answer = resolve)),
),
},
});
await fireEvent.click(getByTestId('agent-skill-add-manually'));
expect(getByTestId('agent-skill-create-save')).toBeDisabled();
answer('New skill 2');
await waitFor(() => expect(getByTestId('agent-skill-create-save')).not.toBeDisabled());
});
it('ignores an answer that a newer lookup replaced', async () => {
const answers: Array<(name: string) => void> = [];
const { getByTestId } = renderModal({
settings: {
scopeOptions: [{ value: 'instance', label: 'Global', icon: 'globe' }],
getDefaultName: vi.fn(
async () => await new Promise<string>((resolve) => answers.push(resolve)),
),
},
});
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.click(getByTestId('agent-modal-back'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
answers[1]('New skill 3');
await waitFor(() => expect(getTitleInput(document.body)).toHaveValue('New skill 3'));
answers[0]('New skill 2');
await new Promise(process.nextTick);
expect(getTitleInput(document.body)).toHaveValue('New skill 3');
});
it('keeps the local default name when the server does not answer', async () => {
const { getByTestId } = renderModal({
settings: {
scopeOptions: [{ value: 'instance', label: 'Global', icon: 'globe' }],
getDefaultName: vi.fn().mockRejectedValue(new Error('offline')),
},
});
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await new Promise(process.nextTick);
expect(getTitleInput(document.body)).toHaveValue('New skill');
});
it('does not ask for a default name when an existing skill is edited', () => {
const getDefaultName = vi.fn();
renderModal({ skillId: 's1', skill: SAVED_SKILL, settings: { getDefaultName } });
expect(getDefaultName).not.toHaveBeenCalled();
});
it('keeps the scope field out of the agent builder', () => {
const { queryByTestId } = renderModal({ skillId: 's1', skill: SAVED_SKILL });
expect(queryByTestId('agent-skill-scope')).not.toBeInTheDocument();
});
});
function makeListItem(
overrides: Pick<SkillListItem, 'id' | 'name' | 'scope'> & Partial<SkillListItem>,
): SkillListItem {
@ -997,9 +1256,11 @@ function makeListItem(
description: `${overrides.name} description`,
projectId: overrides.scope === 'project' ? 'p1' : null,
projectName: overrides.scope === 'project' ? 'Marketing' : null,
projectType: overrides.scope === 'project' ? 'team' : null,
agentId: null,
agentName: null,
source: 'ui',
createdBy: null,
latestVersion: 1,
usedByAgents: 0,
canEdit: true,

View File

@ -632,9 +632,11 @@ describe('useAgentCapabilitiesActions — skills hub', () => {
scope: 'instance',
projectId: null,
projectName: null,
projectType: null,
agentId: null,
agentName: null,
source: 'ui',
createdBy: null,
latestVersion: 1,
usedByAgents: 0,
canEdit: false,

View File

@ -19,6 +19,14 @@ export const AgentModalTestStub = defineComponent({
disableOutsidePointerEvents: { type: Boolean, default: true },
},
emits: ['update:open', 'update:title', 'back', 'interactOutside'],
methods: {
/** Mirrors `AgentModal.focusTitle`. */
focusTitle() {
(this.$el as HTMLElement | null)
?.querySelector<HTMLInputElement>('[data-testid="agent-modal-title-input"]')
?.focus();
},
},
template: `
<section
v-if="open"

View File

@ -1,7 +1,7 @@
<script setup lang="ts">
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, ref, useTemplateRef, watch } from 'vue';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
@ -21,6 +21,7 @@ import {
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import type { IconName } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
@ -42,9 +43,18 @@ import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
const SKILL_FILE = 'SKILL.md';
export type AgentSkillModalScopeOption = { value: string; label: string; icon: IconName };
/** What a failed `onConfirm` returns. The modal stays open and shows `nameError` on the name. */
export type AgentSkillConfirmFailure = { saved: false; nameError?: string };
/** `undefined` closes the modal. A callback without a `return` gives it. */
export type AgentSkillConfirmResult = AgentSkillConfirmFailure | undefined;
export type AgentSkillModalData = {
projectId: string;
agentId: string;
/** Set when opened from an agent. Absent when opened from Settings > Context > Skills. */
projectId?: string;
agentId?: string;
skill?: AgentSkill;
skillId?: string;
/**
@ -56,11 +66,31 @@ export type AgentSkillModalData = {
getAttachedSkills?: () => Array<{ id: string; name: string }>;
projectLabel?: string;
canCreateInstanceSkills?: boolean;
/**
* Settings only: where the skill can be added. On create the user picks one and the
* value comes back in `onConfirm`. The agent builder uses `availableTo` instead.
*/
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.
*/
getDefaultName?: (scope: string) => Promise<string>;
/** Locks the scope field and explains why in a tooltip. */
scopeLockedReason?: string;
/** Shows the skill without edit controls, for a user who can use but not change it. */
readOnly?: boolean;
/**
* Called on Save. A promise keeps the modal open until it settles. It closes unless the
* result is an `AgentSkillConfirmFailure`, so a refused save keeps what the user typed.
*/
onConfirm: (payload: {
id?: string;
skill: AgentSkill;
availableTo?: AgentSkillAvailability;
}) => void;
scope?: string;
}) => AgentSkillConfirmResult | Promise<AgentSkillConfirmResult>;
outdated?: boolean;
onUseLatestVersion?: (id: string) => void;
onAttach?: (skill: SkillListItem) => Promise<void>;
@ -78,6 +108,7 @@ const rootStore = useRootStore();
const { showError } = useToast();
const agentTelemetry = useAgentTelemetry();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const agentModal = useTemplateRef<InstanceType<typeof AgentModalMultiStep>>('agentModal');
const existingNameKeys = computed(() => {
const names = props.data.getAttachedSkills
@ -111,6 +142,55 @@ const step = ref<'select' | 'preview' | 'upload' | 'manual' | 'edit'>(
props.data.skill || props.data.skillId ? 'edit' : hasPicker ? 'select' : 'upload',
);
const isImporting = ref(false);
const isSaving = ref(false);
/** A name error from the server, such as a taken name. Typing a new name clears it. */
const serverNameError = ref('');
const scope = ref(props.data.initialScope ?? props.data.scopeOptions?.[0]?.value ?? '');
/** The last default name the server gave. A name the user typed is never replaced. */
const serverDefaultName = ref<string | null>(null);
/** Save waits for the lookup, so a taken local default cannot fail after the modal closes. */
const defaultNameLoading = ref(false);
let latestNameRequest = 0;
async function refreshDefaultName() {
if (!props.data.getDefaultName || props.data.skillId) return;
const current = skill.value.name;
if (current !== getDefaultSkillName() && current !== serverDefaultName.value) return;
const request = ++latestNameRequest;
const requestedScope = scope.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 (skill.value.name !== current) return;
serverDefaultName.value = name;
skill.value = { ...skill.value, name };
} catch {
// Keep the local default. The save still refuses a taken name.
} finally {
if (request === latestNameRequest) defaultNameLoading.value = false;
}
}
function onAddManually() {
step.value = 'manual';
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,
);
const selectedScopeIcon = computed(
() => props.data.scopeOptions?.find((option) => option.value === scope.value)?.icon,
);
const availableTo = ref<AgentSkillAvailability>('project');
const librarySkills = ref<SkillListItem[]>([]);
@ -168,12 +248,17 @@ const skillAccessFailed = ref(false);
const isOutdated = computed(() => isEditing.value && !!props.data.outdated);
const isReadOnly = computed(
() =>
props.data.readOnly === true ||
skillAccessLoading.value ||
skillAccessFailed.value ||
skillAccess.value?.canEdit === false ||
isOutdated.value,
);
const cannotEdit = computed(() => skillAccess.value?.canEdit === false || isOutdated.value);
const cannotEdit = computed(
() => props.data.readOnly === true || skillAccess.value?.canEdit === false || isOutdated.value,
);
/** Save sends a snapshot, so the editor is locked until the answer comes back. */
const editorLocked = computed(() => isReadOnly.value || isSaving.value);
const savedTarget = computed<AgentSkillAvailability | undefined>(() => {
if (!skillAccess.value) return undefined;
if (skillAccess.value.agentId) return 'agent';
@ -279,8 +364,8 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
const visibleErrors = computed(() =>
submitted.value || openedWithMissingContent.value ? validationErrors.value : {},
);
const visibleNameError = computed(() =>
submitted.value ? (validationErrors.value.name ?? '') : '',
const visibleNameError = computed(
() => (submitted.value ? validationErrors.value.name : undefined) ?? serverNameError.value,
);
const canSave = computed(
() => formIsValid.value && !validationErrors.value.name && !isReadOnly.value,
@ -289,7 +374,8 @@ const hasChanges = computed(
() =>
!isEditing.value ||
!isEqual(skill.value, initialSkill) ||
(showAvailabilitySelect.value && availableTo.value !== savedTarget.value),
(showAvailabilitySelect.value && availableTo.value !== savedTarget.value) ||
(canChangeScope.value && scope.value !== props.data.initialScope),
);
function getSimilarNameReason(name: string): string | undefined {
@ -309,6 +395,8 @@ function getPickerCategory(librarySkill: SkillListItem): 'project' | 'this-agent
}
async function loadLibrarySkills() {
// The picker lists what one agent may attach. Settings opens the modal without an agent.
if (!props.data.agentId) return;
libraryLoading.value = true;
try {
librarySkills.value = await listAttachableSkills(rootStore.restApiContext, props.data.agentId);
@ -394,6 +482,7 @@ onMounted(() => {
function onSkillUploaded(importedSkill: AgentSkill) {
skill.value = normalizeSkill(importedSkill);
serverNameError.value = '';
selectedPath.value = SKILL_FILE;
submitted.value = false;
step.value = 'edit';
@ -405,6 +494,7 @@ function onBack() {
return;
}
skill.value = { name: getDefaultSkillName(), description: '', instructions: '' };
serverNameError.value = '';
selectedPath.value = SKILL_FILE;
submitted.value = false;
formIsValid.value = false;
@ -413,6 +503,7 @@ function onBack() {
function onSkillUpdate(updates: Partial<AgentSkill>) {
if (isReadOnly.value) return;
if (updates.name !== undefined && updates.name !== skill.value.name) serverNameError.value = '';
skill.value = normalizeSkill({ ...skill.value, ...updates });
if (
selectedPath.value !== SKILL_FILE &&
@ -468,6 +559,8 @@ function onImportSkill(payload: {
referenceCount?: number;
error?: string;
}) {
// The import event belongs to the agent builder. Settings has no agent to name.
if (!props.data.agentId) return;
agentTelemetry.trackImportedSkill({
agentId: props.data.agentId,
...payload,
@ -478,7 +571,8 @@ function closeModal() {
uiStore.closeModal(props.modalName);
}
function onSave() {
async function onSave() {
if (defaultNameLoading.value || isSaving.value) return;
submitted.value = true;
if (!canSave.value) return;
@ -490,14 +584,43 @@ function onSave() {
});
const targetChanged = !isEditing.value || availableTo.value !== savedTarget.value;
props.data.onConfirm({
id: props.data.skillId,
skill: payload,
...(showAvailabilitySelect.value && targetChanged ? { availableTo: availableTo.value } : {}),
});
isSaving.value = true;
let result: AgentSkillConfirmResult;
try {
result = await props.data.onConfirm({
id: props.data.skillId,
skill: payload,
...(showAvailabilitySelect.value && targetChanged ? { availableTo: availableTo.value } : {}),
...(canChangeScope.value ? { scope: scope.value } : {}),
});
} catch {
// The caller reports its own errors. Keep the modal open with the user's edits.
return;
} finally {
isSaving.value = false;
}
if (result?.saved === false) {
if (result.nameError) {
serverNameError.value = result.nameError;
await focusName();
}
return;
}
closeModal();
}
async function focusName() {
await nextTick();
agentModal.value?.focusTitle();
}
// A new skill gets its name first, so the name takes the focus when it can be edited.
watch(step, async (next) => {
if ((next === 'manual' || next === 'edit') && !isEditing.value && !isReadOnly.value) {
await focusName();
}
});
function onUseLatestVersion() {
if (!props.data.skillId) return;
const skillId = props.data.skillId;
@ -519,6 +642,7 @@ function onRemove() {
<template>
<AgentModalMultiStep
ref="agentModal"
:open="modalOpen"
:step="step"
:title="modalTitle"
@ -529,7 +653,7 @@ function onRemove() {
:show-back="step === 'manual' || step === 'preview' || (step === 'upload' && hasPicker)"
:show-footer="step === 'manual' || step === 'edit' || step === 'preview'"
:body-flush="step !== 'select'"
:busy="isImporting || previewLoading"
:busy="isImporting || previewLoading || isSaving"
size="fit"
data-testid="agent-skill-modal"
@update:open="!$event && closeModal()"
@ -584,6 +708,14 @@ function onRemove() {
/>
</div>
<template v-else-if="step !== 'select'">
<N8nCallout
v-if="data.readOnly"
theme="info"
:class="$style.callout"
data-testid="agent-skill-read-only-callout"
>
{{ i18n.baseText('settings.context.skills.readOnly') }}
</N8nCallout>
<N8nCallout
v-if="openedWithMissingContent && !isReadOnly"
theme="warning"
@ -597,7 +729,7 @@ function onRemove() {
v-if="step === 'upload'"
v-model:busy="isImporting"
@uploaded="onSkillUploaded"
@add-manually="step = 'manual'"
@add-manually="onAddManually"
@import:skill="onImportSkill"
/>
<template v-else>
@ -605,7 +737,7 @@ function onRemove() {
:skill="skill"
:selected-path="selectedPath"
:add-reference-disabled="!canAddReference"
:read-only="isReadOnly"
:read-only="editorLocked"
@add-reference="onAddReference"
@remove-reference="onRemoveReference"
@select="selectedPath = $event"
@ -613,7 +745,7 @@ function onRemove() {
<AgentSkillViewer
:skill="skill"
:selected-path="selectedPath"
:disabled="isReadOnly"
:disabled="editorLocked"
:read-only="cannotEdit"
:errors="visibleErrors"
:show-validation-warnings="submitted || openedWithMissingContent"
@ -621,7 +753,47 @@ function onRemove() {
@update:skill="onSkillUpdate"
@update:valid="onValidUpdate"
>
<template v-if="showAvailabilitySelect" #fields-start>
<template v-if="showScope" #fields-start>
<N8nInputLabel
:label="i18n.baseText('settings.context.skills.modal.scope.label')"
:tooltip-text="i18n.baseText('settings.context.skills.modal.scope.tooltip')"
size="small"
data-testid="agent-skill-scope"
>
<N8nTooltip
:disabled="!data.scopeLockedReason"
:content="data.scopeLockedReason"
placement="top"
>
<!-- A disabled select drops pointer events, so the tooltip needs its own target. -->
<div>
<N8nSelect
v-model="scope"
size="large"
:disabled="!canChangeScope || isSaving"
:teleported="false"
data-testid="agent-skill-scope-select"
>
<template v-if="selectedScopeIcon" #prefix>
<N8nIcon :icon="selectedScopeIcon" />
</template>
<N8nOption
v-for="option in data.scopeOptions"
:key="option.value"
:value="option.value"
:label="option.label"
>
<span :class="$style.availabilityOption">
<N8nIcon :icon="option.icon" />
<span>{{ option.label }}</span>
</span>
</N8nOption>
</N8nSelect>
</div>
</N8nTooltip>
</N8nInputLabel>
</template>
<template v-else-if="showAvailabilitySelect" #fields-start>
<N8nInputLabel
:label="i18n.baseText('agents.builder.skills.availableTo.label')"
:tooltip-text="i18n.baseText('agents.builder.skills.availableTo.tooltip')"
@ -704,7 +876,8 @@ function onRemove() {
<N8nButton
v-else
variant="solid"
:disabled="!hasChanges"
:disabled="!hasChanges || defaultNameLoading"
:loading="isSaving"
data-testid="agent-skill-create-save"
@click="onSave"
>

View File

@ -66,6 +66,7 @@ const emit = defineEmits<{
const slots = useSlots();
const i18n = useI18n();
const body = useTemplateRef<HTMLElement>('body');
const titleEdit = useTemplateRef<InstanceType<typeof N8nInlineTextEdit>>('titleEdit');
const parameterDialogs = ref(new Set<string>());
provide(ParameterInputModalContextKey, { openDialogs: parameterDialogs, appendTo: 'body' });
const trapFocus = computed(() => props.trapFocus && parameterDialogs.value.size === 0);
@ -100,6 +101,13 @@ function onInteractOutside(event: Event) {
emit('interactOutside', event);
}
/** Puts the editable title in edit mode. */
function focusTitle() {
titleEdit.value?.forceFocus();
}
defineExpose({ focusTitle });
function onOpenAutoFocus(event: Event) {
const autofocusTarget =
body.value?.querySelector<HTMLElement>(
@ -153,6 +161,7 @@ function onOpenAutoFocus(event: Event) {
<div :class="$style.titleGroup">
<N8nInlineTextEdit
v-if="props.editableTitle"
ref="titleEdit"
:model-value="props.title"
:max-length="props.titleMaxLength"
:max-width="'100%'"

View File

@ -1,6 +1,6 @@
<script setup lang="ts">
import type { DialogSize } from '@n8n/design-system';
import { computed, nextTick, ref, watch } from 'vue';
import { computed, nextTick, ref, useTemplateRef, watch } from 'vue';
import AgentModal from './AgentModal.vue';
@ -49,6 +49,15 @@ const emit = defineEmits<{
interactOutside: [event: Event];
}>();
const agentModal = useTemplateRef<InstanceType<typeof AgentModal>>('agentModal');
/** Puts the editable title in edit mode. */
function focusTitle() {
agentModal.value?.focusTitle();
}
defineExpose({ focusTitle });
const isPickerStep = computed(() => props.step === 'select' || props.step === 'list');
const stepChanged = ref(false);
watch(
@ -63,6 +72,7 @@ watch(
<template>
<AgentModal
ref="agentModal"
:open="open"
:title="title"
:editable-title="editableTitle"

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useRouter } from 'vue-router';
import type { SkillListItem, SkillUsage } from '@n8n/api-types';
import { N8nDropdownMenu, N8nIcon, N8nText } from '@n8n/design-system';
import type { DropdownMenuItemProps } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import { AGENT_BUILDER_VIEW } from '@/features/agents/constants';
import { useSkillsStore } from '../skills.store';
const HIDDEN_AGENTS_ITEM = 'hidden-agents';
const props = defineProps<{
skill: Pick<SkillListItem, 'id' | 'usedByAgents'>;
}>();
const i18n = useI18n();
const router = useRouter();
const skillsStore = useSkillsStore();
const { showError } = useToast();
const usage = ref<SkillUsage | null>(null);
const loading = ref(false);
/** A draft and a published version of the same agent count once, as `usedByAgents` does. */
const agents = computed(() => {
const byId = new Map<string, { agentName: string; projectId: string }>();
for (const agent of [...(usage.value?.drafts ?? []), ...(usage.value?.pins ?? [])]) {
if (!byId.has(agent.agentId)) byId.set(agent.agentId, agent);
}
return byId;
});
const items = computed<Array<DropdownMenuItemProps<string>>>(() => {
const list = [...agents.value].map(
([id, agent]): DropdownMenuItemProps<string> => ({
id,
label: agent.agentName,
icon: { type: 'icon', value: 'bot' },
}),
);
const hidden = usage.value?.hiddenAgents ?? 0;
if (hidden > 0) {
list.push({
id: HIDDEN_AGENTS_ITEM,
label: i18n.baseText('settings.context.skills.agents.hidden', {
interpolate: { count: hidden },
adjustToNumber: hidden,
}),
disabled: true,
});
}
return list;
});
/** The list row has the count only, so the names load when the menu first opens. */
async function onOpenChange(open: boolean) {
if (!open || usage.value || loading.value) return;
loading.value = true;
try {
usage.value = (await skillsStore.fetchSkill(props.skill.id)).usedBy;
} catch (error) {
showError(error, i18n.baseText('settings.context.skills.error.agents'));
} finally {
loading.value = false;
}
}
async function onSelect(agentId: string) {
const agent = agents.value.get(agentId);
if (!agent) return;
await router.push({
name: AGENT_BUILDER_VIEW,
params: { projectId: agent.projectId, agentId },
});
}
</script>
<template>
<N8nText
v-if="props.skill.usedByAgents === 0"
size="small"
color="text-light"
data-test-id="skill-agents-none"
>
—
</N8nText>
<!-- The row opens the skill on click, so the menu keeps its clicks to itself. -->
<span v-else @click.stop>
<N8nDropdownMenu
:items="items"
:loading="loading"
:loading-item-count="Math.min(props.skill.usedByAgents, 3)"
:modal="false"
placement="bottom-start"
content-test-id="skill-agents-menu"
@update:model-value="onOpenChange"
@select="onSelect"
>
<template #trigger>
<button
type="button"
:class="$style.trigger"
:aria-label="i18n.baseText('settings.context.skills.agents.label')"
data-test-id="skill-agents-trigger"
>
<N8nText size="small" color="text-base">{{ props.skill.usedByAgents }}</N8nText>
<N8nIcon icon="chevron-down" size="small" color="text-light" />
</button>
</template>
</N8nDropdownMenu>
</span>
</template>
<style lang="scss" module>
.trigger {
display: inline-flex;
align-items: center;
gap: var(--spacing--4xs);
padding: var(--spacing--4xs) var(--spacing--3xs);
border: none;
border-radius: var(--radius);
background: none;
cursor: pointer;
&:hover {
background-color: var(--background--hover);
}
&[aria-expanded='true'] {
background-color: var(--background--active);
}
}
</style>

View File

@ -0,0 +1,44 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { SkillListItem } from '@n8n/api-types';
import { N8nBadge, N8nIcon } from '@n8n/design-system';
import { useSkillScopeDisplay } from '../skills.utils';
const props = defineProps<{
skill: Pick<SkillListItem, 'scope' | 'projectId' | 'projectName' | 'projectType' | 'agentName'>;
}>();
const { scopeDisplay } = useSkillScopeDisplay();
const display = computed(() => scopeDisplay(props.skill));
</script>
<template>
<N8nBadge variant="outline" :class="$style.badge" data-test-id="skill-scope-badge">
<!-- The badge wraps its slot in a text span, so the flex row has to live inside the slot. -->
<span :class="$style.content">
<N8nIcon :icon="display.icon" size="small" />
<span :class="$style.label">{{ display.label }}</span>
</span>
</N8nBadge>
</template>
<style lang="scss" module>
.badge {
max-width: 100%;
}
.content {
display: flex;
align-items: center;
gap: var(--spacing--4xs);
min-width: 0;
}
.label {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -0,0 +1,201 @@
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import type { SkillDetail, SkillListItem } from '@n8n/api-types';
import { STORES } from '@n8n/stores';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue';
import SkillsTable from './SkillsTable.vue';
import { useSkillsStore } from '../skills.store';
const push = vi.fn();
vi.mock('vue-router', () => ({
useRouter: () => ({ push }),
useRoute: () => ({ params: {}, query: {} }),
RouterLink: vi.fn(),
}));
function skill(overrides: Partial<SkillListItem> = {}): SkillListItem {
return {
id: 's1',
name: 'research-search',
description: 'Find research and cite the evidence.',
scope: 'instance',
projectId: null,
projectName: null,
projectType: null,
agentId: null,
agentName: null,
source: 'ui',
createdBy: null,
latestVersion: 1,
usedByAgents: 0,
canEdit: true,
canDelete: true,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
...overrides,
};
}
const renderTable = createComponentRenderer(SkillsTable);
function render(skills: SkillListItem[]) {
return renderTable({
props: { skills, itemsLength: skills.length, loading: false },
});
}
async function openActions(row: HTMLElement) {
await userEvent.click(within(row).getByTestId('skill-actions').querySelector('button')!);
}
describe('SkillsTable', () => {
beforeEach(() => {
createTestingPinia({
initialState: {
[STORES.PROJECTS]: {
personalProject: { id: 'personal-1', name: 'Me <me@n8n.io>', type: 'personal' },
},
},
});
push.mockClear();
});
it('shows the name and the description of each skill', () => {
const { getByText } = render([skill()]);
expect(getByText('research-search')).toBeInTheDocument();
expect(getByText('Find research and cite the evidence.')).toBeInTheDocument();
});
it.each([
['Global', skill({ scope: 'instance' })],
[
'Personal project',
skill({
scope: 'project',
projectId: 'personal-1',
projectName: 'Me',
projectType: 'personal',
}),
],
[
'Marketing',
skill({
scope: 'project',
projectId: 'team-1',
projectName: 'Marketing',
projectType: 'team',
}),
],
// Another member's personal project: the editor does not list it, the row names its type.
[
'Ada Lovelace',
skill({
scope: 'project',
projectId: 'personal-2',
projectName: 'Ada Lovelace <ada@example.com>',
projectType: 'personal',
}),
],
['FirstClassDarwin only', skill({ scope: 'agent', agentName: 'FirstClassDarwin' })],
])('labels the scope "%s"', (label, row) => {
const { getByTestId } = render([row]);
expect(getByTestId('skill-scope-badge')).toHaveTextContent(label);
});
it('shows the creator with the user cell of the users table', () => {
const { getByTestId } = render([
skill({
createdBy: { id: 'u1', email: 'ada@example.com', firstName: 'Ada', lastName: 'Lovelace' },
}),
]);
const cell = getByTestId('skill-created-by');
expect(cell).toHaveTextContent('Ada Lovelace');
expect(cell).toHaveTextContent('ada@example.com');
});
it('shows a dash when the creator is gone', () => {
const { getByTestId, queryByTestId } = render([skill({ createdBy: null })]);
expect(getByTestId('skill-created-by-none')).toHaveTextContent('—');
expect(queryByTestId('skill-created-by')).not.toBeInTheDocument();
});
it('shows a dash for a skill that no agent uses', () => {
const { getByTestId, queryByTestId } = render([skill({ usedByAgents: 0 })]);
expect(getByTestId('skill-agents-none')).toHaveTextContent('—');
expect(queryByTestId('skill-agents-trigger')).not.toBeInTheDocument();
});
it('lists the agents that use the skill when the count is opened', async () => {
const skillsStore = mockedStore(useSkillsStore);
skillsStore.fetchSkill.mockResolvedValue({
usedBy: {
drafts: [{ agentId: 'a1', agentName: 'FirstClassDarwin', projectId: 'p1' }],
pins: [{ agentId: 'a1', agentName: 'FirstClassDarwin', projectId: 'p1', version: 1 }],
hiddenAgents: 0,
},
} as SkillDetail);
const { getByTestId, findAllByText } = render([skill({ usedByAgents: 1 })]);
expect(getByTestId('skill-agents-trigger')).toHaveTextContent('1');
await userEvent.click(getByTestId('skill-agents-trigger'));
expect(skillsStore.fetchSkill).toHaveBeenCalledWith('s1');
// A draft and a published version of one agent show as one entry.
expect(await findAllByText('FirstClassDarwin')).toHaveLength(1);
});
it('emits open when a row is clicked', async () => {
const row = skill();
const { getByText, emitted } = render([row]);
await userEvent.click(getByText('research-search'));
expect(emitted('open')).toEqual([[row]]);
});
it('offers View instead of Edit for a skill the user cannot change', async () => {
const { getByTestId, findByText, queryAllByText } = render([
skill({ canEdit: false, canDelete: false }),
]);
await openActions(getByTestId('skills-table'));
expect(await findByText('View')).toBeInTheDocument();
expect(document.querySelector('[data-test-id="action-edit"]')).toHaveTextContent('View');
expect(queryAllByText('Edit')).toHaveLength(0);
});
it('disables Delete while agents use the skill', async () => {
const { getByTestId } = render([skill({ usedByAgents: 2 })]);
await openActions(getByTestId('skills-table'));
await waitFor(() =>
expect(document.querySelector('[data-test-id="action-delete"]')).toHaveAttribute(
'data-disabled',
),
);
});
it('emits delete for a skill that no agent uses', async () => {
const row = skill();
const { getByTestId, emitted } = render([row]);
await openActions(getByTestId('skills-table'));
await waitFor(() =>
expect(document.querySelector('[data-test-id="action-delete"]')).toBeInTheDocument(),
);
await userEvent.click(document.querySelector('[data-test-id="action-delete"]')!);
expect(emitted('delete')).toEqual([[row]]);
});
});

View File

@ -0,0 +1,238 @@
<script setup lang="ts">
import { computed } from 'vue';
import type { SkillListItem } from '@n8n/api-types';
import { N8nActionToggle, N8nDataTableServer, N8nText, N8nUserInfo } from '@n8n/design-system';
import type { TableHeader, TableOptions } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useUsersStore } from '@n8n/stores/users.store';
import TimeAgo from '@/app/components/TimeAgo.vue';
import SkillAgentsCell from './SkillAgentsCell.vue';
import SkillScopeBadge from './SkillScopeBadge.vue';
import { PREFERENCES_PAGE_SIZES } from '../context.constants';
type SkillAction = 'edit' | 'delete';
/** The fields of the action toggle item that this menu uses. */
type SkillActionItem = {
value: SkillAction;
label: string;
disabled?: boolean;
tooltip?: string;
destructive?: boolean;
};
const props = defineProps<{
skills: SkillListItem[];
itemsLength: number;
loading?: boolean;
/** The table renders its cover row whenever the slot exists, so the slot is withheld. */
showEmpty?: boolean;
}>();
const emit = defineEmits<{
open: [skill: SkillListItem];
delete: [skill: SkillListItem];
'update:options': [payload: TableOptions];
}>();
const tableOptions = defineModel<TableOptions>('tableOptions', { default: () => ({}) });
const i18n = useI18n();
const usersStore = useUsersStore();
/** The backend refuses to delete a skill that an agent uses, so the action is off before the user tries. */
function canDeleteNow(skill: SkillListItem) {
return skill.canDelete && skill.usedByAgents === 0;
}
function deleteHint(skill: SkillListItem): string | undefined {
if (canDeleteNow(skill)) return undefined;
if (!skill.canDelete) return i18n.baseText('settings.context.skills.readOnly');
return i18n.baseText('settings.context.skills.delete.inUse', {
interpolate: { count: skill.usedByAgents },
adjustToNumber: skill.usedByAgents,
});
}
function actionsFor(skill: SkillListItem): SkillActionItem[] {
return [
{
value: 'edit',
// A read-only skill still opens, to read it.
label: i18n.baseText(
skill.canEdit
? 'settings.context.skills.actions.edit'
: 'settings.context.skills.actions.view',
),
},
{
value: 'delete',
label: i18n.baseText('settings.context.skills.actions.delete'),
disabled: !canDeleteNow(skill),
tooltip: deleteHint(skill),
destructive: true,
},
];
}
function onAction(skill: SkillListItem, action: SkillAction) {
if (action === 'edit') emit('open', skill);
else emit('delete', skill);
}
function onRowClick(_event: MouseEvent, payload: { item: SkillListItem }) {
emit('open', payload.item);
}
// Sorting is off until the endpoint supports ORDER BY. Pixel widths sum to the table's
// minimum width, so a new column takes its room from the others, not from the page.
const headers = computed<Array<TableHeader<SkillListItem>>>(() => [
{
title: i18n.baseText('settings.context.skills.columns.skill'),
key: 'name',
width: 300,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.context.skills.columns.scope'),
key: 'scope',
width: 220,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.context.skills.columns.agents'),
key: 'usedByAgents',
width: 110,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.context.skills.columns.createdBy'),
key: 'createdBy',
width: 220,
disableSort: true,
resize: false,
},
{
title: i18n.baseText('settings.context.skills.columns.updatedAt'),
key: 'updatedAt',
width: 130,
disableSort: true,
resize: false,
},
{
title: '',
key: 'actions',
align: 'end',
width: 64,
disableSort: true,
resize: false,
value: () => undefined,
},
]);
</script>
<template>
<div data-test-id="skills-table">
<N8nDataTableServer
v-model:page="tableOptions.page"
v-model:items-per-page="tableOptions.itemsPerPage"
:headers="headers"
:items="props.skills"
:items-length="props.itemsLength"
:loading="props.loading"
:page-sizes="PREFERENCES_PAGE_SIZES"
:row-props="{ class: $style.row }"
@update:options="emit('update:options', $event)"
@click:row="onRowClick"
>
<template v-if="props.showEmpty" #cover>
<slot name="empty" />
</template>
<template #[`item.name`]="{ item }">
<div :class="$style.skillCell">
<N8nText size="medium" color="text-dark" data-test-id="skill-name">
{{ item.name }}
</N8nText>
<N8nText size="small" color="text-light" :class="$style.description">
{{ item.description }}
</N8nText>
</div>
</template>
<template #[`item.scope`]="{ item }">
<SkillScopeBadge :skill="item" />
</template>
<template #[`item.usedByAgents`]="{ item }">
<SkillAgentsCell :skill="item" />
</template>
<template #[`item.createdBy`]="{ item }">
<!-- The same person cell as the users table. -->
<div v-if="item.createdBy" :class="$style.creator" data-test-id="skill-created-by">
<N8nUserInfo
:first-name="item.createdBy.firstName"
:last-name="item.createdBy.lastName"
:email="item.createdBy.email"
:is-current-user="item.createdBy.id === usersStore.currentUserId"
/>
</div>
<N8nText v-else size="small" color="text-light" data-test-id="skill-created-by-none">
—
</N8nText>
</template>
<template #[`item.updatedAt`]="{ item }">
<N8nText size="small" color="text-light" data-test-id="skill-updated-at">
<TimeAgo :date="item.updatedAt" />
</N8nText>
</template>
<template #[`item.actions`]="{ item }">
<div :class="$style.actions" @click.stop>
<N8nActionToggle
:actions="actionsFor(item)"
icon-orientation="vertical"
data-test-id="skill-actions"
@action="(action: SkillAction) => onAction(item, action)"
/>
</div>
</template>
</N8nDataTableServer>
</div>
</template>
<style lang="scss" module>
.skillCell {
display: flex;
flex-direction: column;
gap: var(--spacing--5xs);
min-width: 0;
}
.creator {
padding-block: var(--spacing--2xs);
min-width: 0;
}
.description {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
display: flex;
justify-content: flex-end;
}
.row {
cursor: pointer;
}
</style>

View File

@ -17,6 +17,7 @@ import { ResponseError } from '@n8n/rest-api-client';
import { usePostHog, waitForFeatureFlagsWithTimeout } from '@/app/stores/posthog.store';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { splitName } from '@/features/collaboration/projects/projects.utils';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useUsersStore } from '@n8n/stores/users.store';
import { PREFERENCES_DEFAULT_SORT_BY } from './context.constants';
@ -45,6 +46,23 @@ export async function isContextPreferencesEnabledOnceEvaluated(): Promise<boolea
return isContextPreferencesEnabled();
}
/**
* The Context overview lists preferences and skills, so either flag opens it. Synchronous,
* for the settings menu: it reads the PostHog flags as they are now.
*/
export function isContextEnabled(): boolean {
return isContextPreferencesEnabled() || isContextSkillsEnabled();
}
/**
* `isContextEnabled` for the route guard. A deep link can arrive before PostHog has
* evaluated the flags, so it waits for them instead of sending the user away. The skills
* setting comes from the backend env var and is already known.
*/
export async function isContextEnabledOnceEvaluated(): Promise<boolean> {
return isContextSkillsEnabled() || (await isContextPreferencesEnabledOnceEvaluated());
}
export function preferenceScope(row: Pick<Preference, 'userId' | 'projectId'>): AiPreferenceScope {
return aiPreferenceScopeOf(row);
}
@ -130,6 +148,27 @@ export function canWriteInstanceScope(): boolean {
return getResourcePermissions(currentUser?.globalScopes).aiPreference?.create === true;
}
/* The same two checks for skills, which have their own scopes. */
export function canWriteProjectSkillScope(projectId: string | null | undefined): boolean {
if (!projectId) return false;
const project = useProjectsStore().myProjects.find((candidate) => candidate.id === projectId);
return getResourcePermissions(project?.scopes).projectSkill?.create === true;
}
export function canWriteInstanceSkillScope(): boolean {
const { currentUser } = useUsersStore();
return getResourcePermissions(currentUser?.globalScopes).skill?.create === true;
}
/**
* The backend reads `N8N_AGENTS_SKILLS_HUB_ENABLED` once at startup and reports it here.
* The skills routes answer 404 while it is off, so the editor follows the same value.
*/
export function isContextSkillsEnabled(): boolean {
return useSettingsStore().moduleSettings.agents?.skillsHubEnabled === true;
}
/** The service builds the cap message from the scope and the number, so match the fixed part. */
const SCOPE_FULL_MESSAGE = 'cannot hold more than';

View File

@ -8,6 +8,7 @@ import { createMemoryHistory, createRouter, type RouteRecordRaw } from 'vue-rout
import { VIEWS } from '@/app/constants';
import { usePostHog } from '@/app/stores/posthog.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { ContextModule } from './module.descriptor';
@ -28,9 +29,10 @@ const createTestRouter = () =>
});
/** The flag is multivariate, so only the enabled arm opens the pages. */
const setup = (value?: string | boolean) => {
const setup = (value?: string | boolean, skillsHubEnabled = false) => {
createTestingPinia({ stubActions: false });
if (value !== undefined) usePostHog().overrides[CONTEXT_PREFERENCES_FLAG] = { value };
useSettingsStore().moduleSettings.agents = { skillsHubEnabled } as never;
};
describe('ContextModule routes', () => {
@ -61,6 +63,37 @@ describe('ContextModule routes', () => {
}
});
describe('skills', () => {
it('opens the skills page and the overview when the backend reports the skills hub on', async () => {
setup(undefined, true);
const router = createTestRouter();
await router.push('/settings/context/skills');
expect(router.currentRoute.value.name).toBe(VIEWS.SETTINGS_CONTEXT_SKILLS);
await router.push('/settings/context');
expect(router.currentRoute.value.name).toBe(VIEWS.SETTINGS_CONTEXT);
});
it('redirects to the homepage while the skills hub is off', async () => {
setup(CONTEXT_PREFERENCES_ENABLED_VARIANT, false);
const router = createTestRouter();
await router.push('/settings/context/skills');
expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE);
});
it('keeps the preferences page closed when only the skills hub is on', async () => {
setup(undefined, true);
const router = createTestRouter();
await router.push('/settings/context/preferences');
expect(router.currentRoute.value.name).toBe(VIEWS.HOMEPAGE);
});
});
it('lists no custom middleware, so the initializer does not gate it on a backend module', () => {
for (const route of ContextModule.routes ?? []) {
expect(route.meta?.middleware).toEqual(['authenticated']);

View File

@ -2,17 +2,22 @@ import { type FrontendModuleDescription } from '@n8n/frontend-module-sdk';
import { VIEWS } from '@/app/constants';
import { isContextPreferencesEnabledOnceEvaluated } from './context.utils';
import {
isContextEnabledOnceEvaluated,
isContextPreferencesEnabledOnceEvaluated,
isContextSkillsEnabled,
} from './context.utils';
/**
* No `custom` middleware: the initializer would gate the routes on a backend module,
* which preferences do not have. The flag check runs in `beforeEnter`. The sidebar
* which preferences do not have. The flag checks run in `beforeEnter`. The sidebar
* entry stays with the shell, which hides module pages without a backend module.
*/
export const ContextModule: FrontendModuleDescription = {
id: 'context',
name: 'Context',
description: 'Reusable preferences for the n8n assistant and connected AI tools',
description:
'Reusable preferences and skills for the n8n assistant, agents and connected AI tools',
icon: 'brain',
routes: [
{
@ -25,8 +30,7 @@ export const ContextModule: FrontendModuleDescription = {
pageCategory: 'settings',
},
},
beforeEnter: async () =>
(await isContextPreferencesEnabledOnceEvaluated()) || { name: VIEWS.HOMEPAGE },
beforeEnter: async () => (await isContextEnabledOnceEvaluated()) || { name: VIEWS.HOMEPAGE },
},
{
path: 'context/preferences',
@ -41,5 +45,17 @@ export const ContextModule: FrontendModuleDescription = {
beforeEnter: async () =>
(await isContextPreferencesEnabledOnceEvaluated()) || { name: VIEWS.HOMEPAGE },
},
{
path: 'context/skills',
name: VIEWS.SETTINGS_CONTEXT_SKILLS,
component: async () => await import('./views/SettingsSkillsView.vue'),
meta: {
middleware: ['authenticated'],
telemetry: {
pageCategory: 'settings',
},
},
beforeEnter: () => isContextSkillsEnabled() || { name: VIEWS.HOMEPAGE },
},
],
};

View File

@ -0,0 +1,76 @@
import type {
CreateSkillDto,
SaveSkillDto,
SkillDetail,
SkillListResponse,
SkillNewNameResponse,
SkillSaveResponse,
} from '@n8n/api-types';
import { makeRestApiRequest } from '@n8n/rest-api-client';
import type { IRestApiContext } from '@n8n/rest-api-client';
const ENDPOINT = '/skills';
/** The request side of `ListSkillsQueryDto`, with numbers as the client holds them. */
export type SkillListQuery = {
skip?: number;
take?: number;
search?: string;
scope?: CreateSkillDto['scope'];
projectId?: string;
};
export async function getSkills(
context: IRestApiContext,
query: SkillListQuery = {},
): Promise<SkillListResponse> {
return await makeRestApiRequest<SkillListResponse>(context, 'GET', ENDPOINT, { ...query });
}
/** A name that no skill in the scope has yet: the requested name, or the name with a number. */
export async function getNewSkillName(
context: IRestApiContext,
query: {
scope: CreateSkillDto['scope'];
projectId?: string;
agentId?: string;
name: string;
},
): Promise<string> {
const response = await makeRestApiRequest<SkillNewNameResponse>(
context,
'GET',
`${ENDPOINT}/new-name`,
{ ...query },
);
return response.name;
}
export async function getSkill(context: IRestApiContext, id: string): Promise<SkillDetail> {
return await makeRestApiRequest<SkillDetail>(context, 'GET', `${ENDPOINT}/${id}`);
}
export async function createSkill(
context: IRestApiContext,
payload: CreateSkillDto,
): Promise<SkillDetail> {
return await makeRestApiRequest<SkillDetail>(context, 'POST', ENDPOINT, payload);
}
/** Saves the next version. Answers `created: false` when the content matches the latest one. */
export async function saveSkill(
context: IRestApiContext,
id: string,
payload: SaveSkillDto,
): Promise<SkillSaveResponse> {
return await makeRestApiRequest<SkillSaveResponse>(
context,
'PATCH',
`${ENDPOINT}/${id}`,
payload,
);
}
export async function deleteSkill(context: IRestApiContext, id: string): Promise<void> {
await makeRestApiRequest(context, 'DELETE', `${ENDPOINT}/${id}`);
}

View File

@ -0,0 +1,75 @@
import type { SkillListItem, SkillListResponse } from '@n8n/api-types';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useSkillsStore } from './skills.store';
const list = vi.fn();
vi.mock('./skills.api', () => ({
getSkills: async (...args: unknown[]) => await list(...args),
}));
function deferred<T>() {
let settle: (value: T) => void = () => {};
const promise = new Promise<T>((resolve) => {
settle = resolve;
});
return { promise, settle };
}
function page(name: string, count = 1): SkillListResponse {
return { count, data: [{ id: name, name } as SkillListItem] };
}
describe('useSkillsStore', () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ stubActions: false }));
list.mockReset();
});
it('keeps the newest list when an older request answers last', async () => {
const older = deferred<SkillListResponse>();
const newer = deferred<SkillListResponse>();
list.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
const store = useSkillsStore();
const first = store.fetchSkills({ search: 'b' });
const second = store.fetchSkills({ search: 'brand' });
newer.settle(page('brand-voice', 1));
await second;
older.settle(page('bulk-skill', 9));
await first;
expect(store.skills.map((skill) => skill.name)).toEqual(['brand-voice']);
expect(store.count).toBe(1);
expect(store.loading).toBe(false);
});
it('stays loading until the newest request answers', async () => {
const older = deferred<SkillListResponse>();
const newer = deferred<SkillListResponse>();
list.mockReturnValueOnce(older.promise).mockReturnValueOnce(newer.promise);
const store = useSkillsStore();
const first = store.fetchSkills();
const second = store.fetchSkills();
older.settle(page('a'));
await first;
expect(store.loading).toBe(true);
newer.settle(page('b'));
await second;
expect(store.loading).toBe(false);
});
it('asks for one row for the overview count', async () => {
list.mockResolvedValue(page('a', 7));
const store = useSkillsStore();
await store.fetchSkillCount();
expect(list).toHaveBeenCalledWith(expect.anything(), { take: 1 });
expect(store.count).toBe(7);
});
});

View File

@ -0,0 +1,80 @@
import type {
CreateSkillDto,
SaveSkillDto,
SkillDetail,
SkillListItem,
SkillSaveResponse,
} from '@n8n/api-types';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useRootStore } from '@n8n/stores/useRootStore';
import * as api from './skills.api';
import type { SkillListQuery } from './skills.api';
/** The skills list in Settings > Context > Skills. */
export const useSkillsStore = defineStore('contextSkills', () => {
const rootStore = useRootStore();
const skills = ref<SkillListItem[]>([]);
const count = ref(0);
const loading = ref(false);
// Only the newest read commits, so a slow earlier search cannot overwrite a later one.
let latestRead = 0;
async function fetchSkills(query: SkillListQuery = {}) {
const read = ++latestRead;
loading.value = true;
try {
const response = await api.getSkills(rootStore.restApiContext, query);
if (read === latestRead) {
skills.value = response.data;
count.value = response.count;
}
return response;
} finally {
if (read === latestRead) loading.value = false;
}
}
/** The overview card needs the total only, so it asks for a single row. */
async function fetchSkillCount() {
await fetchSkills({ take: 1 });
}
/** A default name that no skill in the scope has yet. */
async function fetchNewSkillName(query: Parameters<typeof api.getNewSkillName>[1]) {
return await api.getNewSkillName(rootStore.restApiContext, query);
}
async function fetchSkill(id: string): Promise<SkillDetail> {
return await api.getSkill(rootStore.restApiContext, id);
}
async function createSkill(payload: CreateSkillDto): Promise<SkillDetail> {
return await api.createSkill(rootStore.restApiContext, payload);
}
async function saveSkill(id: string, payload: SaveSkillDto): Promise<SkillSaveResponse> {
return await api.saveSkill(rootStore.restApiContext, id, payload);
}
async function deleteSkill(id: string): Promise<void> {
await api.deleteSkill(rootStore.restApiContext, id);
}
return {
skills,
count,
loading,
fetchSkills,
fetchSkillCount,
fetchSkill,
fetchNewSkillName,
createSkill,
saveSkill,
deleteSkill,
};
});

View File

@ -0,0 +1,59 @@
import type { SkillListItem } from '@n8n/api-types';
import type { IconName } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import { splitName } from '@/features/collaboration/projects/projects.utils';
export type SkillScopeDisplay = { icon: IconName; label: string };
/** How the badge and the modal name the scope of a skill. */
export function useSkillScopeDisplay() {
const i18n = useI18n();
const projectsStore = useProjectsStore();
function projectLabel(
projectId: string,
projectName: string | null,
projectType: SkillListItem['projectType'],
) {
if (projectId === projectsStore.personalProject?.id) {
return i18n.baseText('settings.context.skills.scope.personalProject');
}
if (projectType === 'personal' && projectName) {
// A personal project is named `First Last <email>`; the owner is the name part.
const { name, email } = splitName(projectName);
return name ?? email ?? projectName;
}
// A project the viewer cannot read is still listed, so fall back to a neutral label.
return projectName ?? i18n.baseText('settings.context.skills.scope.project');
}
function scopeDisplay(
skill: Pick<SkillListItem, 'scope' | 'projectId' | 'projectName' | 'projectType' | 'agentName'>,
): SkillScopeDisplay {
switch (skill.scope) {
case 'instance':
return { icon: 'globe', label: i18n.baseText('settings.context.skills.scope.instance') };
case 'agent':
return {
icon: 'bot',
label: skill.agentName
? i18n.baseText('settings.context.skills.scope.agent', {
interpolate: { name: skill.agentName },
})
: i18n.baseText('settings.context.skills.scope.agentFallback'),
};
case 'project':
if (!skill.projectId) {
return { icon: 'layers', label: i18n.baseText('settings.context.skills.scope.project') };
}
return {
icon: skill.projectType === 'personal' ? 'user' : 'layers',
label: projectLabel(skill.projectId, skill.projectName, skill.projectType),
};
}
}
return { scopeDisplay };
}

View File

@ -5,8 +5,15 @@ import userEvent from '@testing-library/user-event';
import SettingsContextView from './SettingsContextView.vue';
import { useContextStore } from '../context.store';
import { useSkillsStore } from '../skills.store';
const push = vi.fn();
const flags = vi.hoisted(() => ({ preferences: true, skills: false }));
vi.mock('../context.utils', () => ({
isContextPreferencesEnabled: () => flags.preferences,
isContextSkillsEnabled: () => flags.skills,
}));
const showErrorMock = vi.fn();
vi.mock('@n8n/composables/useToast', () => ({
@ -24,6 +31,8 @@ const renderView = createComponentRenderer(SettingsContextView);
describe('SettingsContextView', () => {
beforeEach(() => {
createTestingPinia();
flags.preferences = true;
flags.skills = false;
push.mockClear();
showErrorMock.mockClear();
});
@ -52,7 +61,7 @@ describe('SettingsContextView', () => {
expect(getByTestId('settings-context-sources-row')).toBeInTheDocument();
});
it('marks Skills and Sources as unavailable', () => {
it('marks Skills and Sources as unavailable while the skills hub is off', () => {
const { getByTestId } = renderView();
expect(getByTestId('settings-context-skills-row')).toHaveTextContent('Coming soon');
@ -79,4 +88,42 @@ describe('SettingsContextView', () => {
expect(push).toHaveBeenCalledWith({ name: 'SettingsContextPreferences' });
});
describe('with the skills hub on', () => {
beforeEach(() => {
flags.skills = true;
});
it('shows the skill count and opens the skills list', async () => {
const skillsStore = mockedStore(useSkillsStore);
skillsStore.count = 7;
const { getByTestId } = renderView();
await new Promise(process.nextTick);
expect(skillsStore.fetchSkillCount).toHaveBeenCalled();
expect(getByTestId('settings-context-skills-row')).toHaveTextContent('7 skills');
await userEvent.click(getByTestId('settings-context-skills-row'));
expect(push).toHaveBeenCalledWith({ name: 'SettingsContextSkills' });
});
it('reports a failed skill count', async () => {
mockedStore(useSkillsStore).fetchSkillCount.mockRejectedValueOnce(new Error('offline'));
renderView();
await new Promise(process.nextTick);
expect(showErrorMock).toHaveBeenCalled();
});
it('hides Preferences when only the skills hub is on', () => {
flags.preferences = false;
const { queryByTestId } = renderView();
expect(queryByTestId('settings-context-preferences-row')).not.toBeInTheDocument();
expect(mockedStore(useContextStore).fetchPreferenceCount).not.toHaveBeenCalled();
});
});
});

View File

@ -1,7 +1,7 @@
<script lang="ts" setup>
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from '@n8n/i18n';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useToast } from '@n8n/composables/useToast';
import {
N8nPreviewBadge,
@ -16,11 +16,17 @@ import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { VIEWS } from '@/app/constants';
import { useContextStore } from '../context.store';
import { isContextPreferencesEnabled, isContextSkillsEnabled } from '../context.utils';
import { useSkillsStore } from '../skills.store';
const i18n = useI18n();
const router = useRouter();
const documentTitle = useDocumentTitle();
const contextStore = useContextStore();
const skillsStore = useSkillsStore();
// The overview opens when either flag is on, so each row follows its own flag.
const preferencesEnabled = isContextPreferencesEnabled();
const skillsEnabled = isContextSkillsEnabled();
const { showError } = useToast();
const preferenceCount = computed(() =>
@ -30,18 +36,44 @@ const preferenceCount = computed(() =>
}),
);
const skillCount = computed(() =>
i18n.baseText('settings.context.skills.count', {
interpolate: { count: skillsStore.count },
adjustToNumber: skillsStore.count,
}),
);
async function openSkills() {
await router.push({ name: VIEWS.SETTINGS_CONTEXT_SKILLS });
}
async function openPreferences() {
await router.push({ name: VIEWS.SETTINGS_CONTEXT_PREFERENCES });
}
/** Each row reports its own failure: it would otherwise show a zero as though it were real. */
async function loadCount(fetchCount: () => Promise<unknown>, errorKey: BaseTextKey) {
try {
await fetchCount();
} catch (error) {
showError(error, i18n.baseText(errorKey));
}
}
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.context.title'));
try {
await contextStore.fetchPreferenceCount();
} catch (error) {
// The row would otherwise sit at "0 preferences" as though the count were real.
showError(error, i18n.baseText('settings.context.preferences.error.load'));
}
await Promise.all([
preferencesEnabled &&
loadCount(
async () => await contextStore.fetchPreferenceCount(),
'settings.context.preferences.error.load',
),
skillsEnabled &&
loadCount(
async () => await skillsStore.fetchSkillCount(),
'settings.context.skills.error.load',
),
]);
});
</script>
@ -59,6 +91,7 @@ onMounted(async () => {
<N8nSettingsRowGroup>
<N8nSettingsRow
v-if="preferencesEnabled"
clickable
:title="i18n.baseText('settings.context.preferences.title')"
:description="i18n.baseText('settings.context.preferences.subtitle')"
@ -71,6 +104,19 @@ onMounted(async () => {
</N8nSettingsRow>
<N8nSettingsRow
v-if="skillsEnabled"
clickable
:title="i18n.baseText('settings.context.skills.title')"
:description="i18n.baseText('settings.context.skills.subtitle')"
data-test-id="settings-context-skills-row"
@click="openSkills"
>
<template #action>
<N8nSettingsRowConfigure :value="skillCount" />
</template>
</N8nSettingsRow>
<N8nSettingsRow
v-else
:title="i18n.baseText('settings.context.skills.title')"
:description="i18n.baseText('settings.context.comingSoon')"
data-test-id="settings-context-skills-row"

View File

@ -0,0 +1,465 @@
import { createComponentRenderer } from '@/__tests__/render';
import { type MockedStore, mockedStore } from '@/__tests__/utils';
import { createTestingPinia } from '@pinia/testing';
import { SKILL_NAME_TAKEN_MESSAGE, type SkillDetail, type SkillListItem } from '@n8n/api-types';
import { ResponseError } from '@n8n/rest-api-client';
import type { IUser } from '@n8n/rest-api-client/api/users';
import { useUsersStore } from '@n8n/stores/users.store';
import userEvent from '@testing-library/user-event';
import { waitFor, within } from '@testing-library/vue';
import { mock } from 'vitest-mock-extended';
import { useUIStore } from '@/app/stores/ui.store';
import type { AgentSkillModalData } from '@/features/agents/components/AgentSkillModal.vue';
import { useProjectsStore } from '@/features/collaboration/projects/projects.store';
import type { ProjectListItem } from '@/features/collaboration/projects/projects.types';
import SettingsSkillsView from './SettingsSkillsView.vue';
import { useSkillsStore } from '../skills.store';
const push = vi.fn();
const routeQuery: Record<string, string> = {};
const confirmMock = vi.fn().mockResolvedValue('confirm');
const showMessageMock = vi.fn();
const showErrorMock = vi.fn();
vi.mock('@/app/composables/useMessage', () => ({
useMessage: () => ({ confirm: confirmMock, alert: vi.fn(), prompt: vi.fn() }),
}));
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showError: showErrorMock, showMessage: showMessageMock }),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({ push }),
useRoute: () => ({ params: {}, query: routeQuery }),
RouterLink: vi.fn(),
}));
function skill(overrides: Partial<SkillListItem> = {}): SkillListItem {
return {
id: 's1',
name: 'research-search',
description: 'Find research and cite the evidence.',
scope: 'instance',
projectId: null,
projectName: null,
projectType: null,
agentId: null,
agentName: null,
source: 'ui',
createdBy: null,
latestVersion: 1,
usedByAgents: 0,
canEdit: true,
canDelete: true,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
...overrides,
};
}
function detail(overrides: Partial<SkillDetail> = {}): SkillDetail {
return {
...skill(),
skill: {
name: 'research-search',
description: 'Find research and cite the evidence.',
instructions: 'Cite every source.',
},
skillHash: 'hash-1',
usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
...overrides,
};
}
const renderView = createComponentRenderer(SettingsSkillsView);
let skillsStore: MockedStore<typeof useSkillsStore>;
let uiStore: MockedStore<typeof useUIStore>;
/** The data the page handed to the skill modal on its last open. */
function lastModalData(): AgentSkillModalData {
const call = uiStore.openModalWithData.mock.calls.at(-1);
if (!call) throw new Error('The modal was not opened');
return call[0].data as AgentSkillModalData;
}
function setPermissions({
instance = false,
projects = [] as Array<Partial<ProjectListItem>>,
}: { instance?: boolean; projects?: Array<Partial<ProjectListItem>> } = {}) {
mockedStore(useUsersStore).currentUser = mock<IUser>({
id: 'user-1',
globalScopes: instance ? ['skill:create'] : [],
});
const projectsStore = mockedStore(useProjectsStore);
projectsStore.personalProject = {
id: 'personal-1',
name: 'Me <me@n8n.io>',
type: 'personal',
scopes: ['projectSkill:create'],
} as never;
projectsStore.myProjects = [
{ id: 'personal-1', name: 'Me <me@n8n.io>', type: 'personal', scopes: ['projectSkill:create'] },
...projects,
] as ProjectListItem[];
projectsStore.getMyProjects.mockResolvedValue(undefined);
}
describe('SettingsSkillsView', () => {
beforeEach(() => {
createTestingPinia();
skillsStore = mockedStore(useSkillsStore);
skillsStore.loading = false;
skillsStore.skills = [];
skillsStore.count = 0;
uiStore = mockedStore(useUIStore);
setPermissions();
for (const key of Object.keys(routeQuery)) delete routeQuery[key];
push.mockClear();
confirmMock.mockClear();
confirmMock.mockResolvedValue('confirm');
showMessageMock.mockClear();
showErrorMock.mockClear();
});
it('loads the first page on mount', async () => {
renderView();
await new Promise(process.nextTick);
expect(skillsStore.fetchSkills).toHaveBeenCalledWith({ skip: 0, take: 50 });
});
it('shows the empty state when there are no skills', async () => {
const { findByTestId } = renderView();
expect(await findByTestId('skills-empty-state')).toHaveTextContent('No skills yet');
});
it('does not show the empty state when the load fails', async () => {
skillsStore.fetchSkills.mockRejectedValueOnce(new Error('offline'));
const { queryByTestId } = renderView();
await new Promise(process.nextTick);
expect(showErrorMock).toHaveBeenCalled();
expect(queryByTestId('skills-empty-state')).not.toBeInTheDocument();
});
it('searches on the server and shows an empty table, not the empty state, for no matches', async () => {
vi.useFakeTimers({ shouldAdvanceTime: true });
const { getByTestId, queryByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.type(getByTestId('skills-search'), 'brand');
await vi.runAllTimersAsync();
expect(skillsStore.fetchSkills).toHaveBeenLastCalledWith({
skip: 0,
take: 50,
search: 'brand',
});
expect(queryByTestId('skills-empty-state')).not.toBeInTheDocument();
vi.useRealTimers();
});
it('filters by scope on the server', async () => {
const { getByTestId, findByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-scope-filter'));
const menu = await findByTestId('skills-scope-filter-menu');
await userEvent.click(within(menu).getByText('Agents'));
await waitFor(() =>
expect(skillsStore.fetchSkills).toHaveBeenLastCalledWith({
skip: 0,
take: 50,
scope: 'agent',
}),
);
expect(getByTestId('skills-scope-filter-active')).toBeInTheDocument();
});
describe('create', () => {
it('offers the personal project and the team projects the user can write to', async () => {
setPermissions({
projects: [
{ id: 'team-1', name: 'Marketing', type: 'team', scopes: ['projectSkill:create'] },
{ id: 'team-2', name: 'Read only', type: 'team', scopes: ['projectSkill:read'] },
],
});
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-create-button'));
expect(lastModalData().scopeOptions).toEqual([
{ value: 'project:personal-1', label: 'Personal project', icon: 'user' },
{ value: 'project:team-1', label: 'Marketing', icon: 'layers' },
]);
});
it('offers Global only to users who may create instance skills', async () => {
setPermissions({ instance: true });
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-create-button'));
expect(lastModalData().scopeOptions?.[0]).toEqual({
value: 'instance',
label: 'Global',
icon: 'globe',
});
});
it('asks for a free default name in the scope the user picks', async () => {
skillsStore.fetchNewSkillName.mockResolvedValue('New skill 2');
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-create-button'));
const name = await lastModalData().getDefaultName?.('project:personal-1');
expect(name).toBe('New skill 2');
expect(skillsStore.fetchNewSkillName).toHaveBeenCalledWith({
scope: 'project',
projectId: 'personal-1',
name: 'New skill',
});
});
it('creates the skill in the chosen scope', async () => {
skillsStore.createSkill.mockResolvedValue(detail({ id: 's-new' }));
// The reloaded list holds the new skill.
skillsStore.fetchSkills.mockImplementation(async () => {
skillsStore.skills = [skill({ id: 's-new' })];
skillsStore.count = 1;
return { count: 1, data: skillsStore.skills };
});
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-create-button'));
const content = {
name: 'brand-voice',
description: 'Write copy.',
instructions: 'Be brief.',
};
lastModalData().onConfirm({ skill: content, scope: 'project:personal-1' });
await new Promise(process.nextTick);
expect(skillsStore.createSkill).toHaveBeenCalledWith({
scope: 'project',
projectId: 'personal-1',
skill: content,
});
// The new row is the feedback, as on the preferences page.
expect(showMessageMock).not.toHaveBeenCalled();
});
it('keeps the modal open with the error on the name when the name is taken', async () => {
skillsStore.createSkill.mockRejectedValue(
new ResponseError(SKILL_NAME_TAKEN_MESSAGE, { httpStatusCode: 409 }),
);
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-create-button'));
const result = await lastModalData().onConfirm({
skill: { name: 'brand-voice', description: 'Write copy.', instructions: 'Be brief.' },
scope: 'project:personal-1',
});
expect(result).toEqual({ saved: false, nameError: SKILL_NAME_TAKEN_MESSAGE });
expect(showErrorMock).not.toHaveBeenCalled();
});
it('says so when the search or the filter hides the new skill', async () => {
skillsStore.createSkill.mockResolvedValue(detail({ id: 's-new' }));
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skills-create-button'));
lastModalData().onConfirm({
skill: { name: 'brand-voice', description: 'd', instructions: 'i' },
scope: 'project:personal-1',
});
await new Promise(process.nextTick);
expect(showMessageMock).toHaveBeenCalledWith({
title: 'Skill created. Your search or filter hides it.',
type: 'success',
});
});
});
describe('edit', () => {
it('opens a skill read-only when the user cannot change it', async () => {
skillsStore.skills = [skill({ canEdit: false })];
skillsStore.count = 1;
skillsStore.fetchSkill.mockResolvedValue(detail({ canEdit: false }));
const { getByText } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByText('research-search'));
await new Promise(process.nextTick);
expect(lastModalData().readOnly).toBe(true);
});
it('locks the scope while agents use the skill', async () => {
skillsStore.skills = [skill({ scope: 'agent', agentName: 'FirstClassDarwin' })];
skillsStore.count = 1;
skillsStore.fetchSkill.mockResolvedValue(
detail({ scope: 'agent', agentId: 'a1', agentName: 'FirstClassDarwin', usedByAgents: 1 }),
);
const { getByText } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByText('research-search'));
await new Promise(process.nextTick);
// The agent scope is listed first because it is not a choice in Settings.
expect(lastModalData().scopeOptions?.[0]).toEqual({
value: 'agent',
label: 'FirstClassDarwin only',
icon: 'bot',
});
expect(lastModalData().initialScope).toBe('agent');
expect(lastModalData().scopeLockedReason).toBe(
'Used by 1 agent. Remove the skill from it to change where it is available.',
);
});
it('moves an unused skill to the scope the user picks', async () => {
skillsStore.skills = [skill()];
skillsStore.count = 1;
skillsStore.fetchSkill.mockResolvedValue(detail({ scope: 'instance', usedByAgents: 0 }));
skillsStore.saveSkill.mockResolvedValue({
id: 's1',
versionId: 'v1',
version: 1,
created: false,
skill: detail().skill,
skillHash: 'hash-1',
});
const { getByText } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByText('research-search'));
await new Promise(process.nextTick);
expect(lastModalData().scopeLockedReason).toBeUndefined();
lastModalData().onConfirm({ id: 's1', skill: detail().skill, scope: 'project:personal-1' });
await new Promise(process.nextTick);
expect(skillsStore.saveSkill).toHaveBeenCalledWith(
's1',
expect.objectContaining({ scope: 'project', projectId: 'personal-1' }),
);
// A move is a change even when the content stays the same: no "Nothing to save", and the list reloads.
expect(showMessageMock).not.toHaveBeenCalled();
expect(skillsStore.fetchSkills).toHaveBeenCalledTimes(2);
});
it('says "Nothing to save" when the content matches the latest version', async () => {
skillsStore.skills = [skill()];
skillsStore.count = 1;
skillsStore.fetchSkill.mockResolvedValue(detail());
skillsStore.saveSkill.mockResolvedValue({
id: 's1',
versionId: 'v1',
version: 1,
created: false,
skill: detail().skill,
skillHash: 'hash-1',
});
const { getByText } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByText('research-search'));
await new Promise(process.nextTick);
lastModalData().onConfirm({ id: 's1', skill: detail().skill });
await new Promise(process.nextTick);
expect(skillsStore.saveSkill).toHaveBeenCalledWith('s1', {
...detail().skill,
references: [],
baseSkillHash: 'hash-1',
});
expect(showMessageMock).toHaveBeenCalledWith({ title: 'Nothing to save', type: 'info' });
});
});
it('keeps the modal open and shows a toast when a save fails for another reason', async () => {
skillsStore.skills = [skill()];
skillsStore.count = 1;
skillsStore.fetchSkill.mockResolvedValue(detail());
skillsStore.saveSkill.mockRejectedValue(
new ResponseError('The skill was changed somewhere else.', { httpStatusCode: 409 }),
);
const { getByText } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByText('research-search'));
await new Promise(process.nextTick);
const result = await lastModalData().onConfirm({ id: 's1', skill: detail().skill });
expect(result).toEqual({ saved: false });
expect(showErrorMock).toHaveBeenCalledWith(
expect.any(ResponseError),
'Could not save the skill',
);
});
it('deletes a skill after confirmation', async () => {
skillsStore.skills = [skill()];
skillsStore.count = 1;
const { getByTestId } = renderView();
await new Promise(process.nextTick);
await userEvent.click(getByTestId('skill-actions').querySelector('button')!);
await waitFor(() =>
expect(document.querySelector('[data-test-id="action-delete"]')).toBeInTheDocument(),
);
await userEvent.click(document.querySelector('[data-test-id="action-delete"]')!);
await new Promise(process.nextTick);
expect(confirmMock).toHaveBeenCalled();
expect(skillsStore.deleteSkill).toHaveBeenCalledWith('s1');
});
describe('deep links', () => {
it('opens the skill named by ?skillId', async () => {
routeQuery.skillId = 's9';
skillsStore.fetchSkill.mockResolvedValue(detail({ id: 's9' }));
renderView();
await waitFor(() => expect(uiStore.openModalWithData).toHaveBeenCalled());
expect(skillsStore.fetchSkill).toHaveBeenCalledWith('s9');
expect(lastModalData().skillId).toBe('s9');
});
it('starts a new skill for ?create=true', async () => {
routeQuery.create = 'true';
renderView();
await waitFor(() => expect(uiStore.openModalWithData).toHaveBeenCalled());
expect(lastModalData().skillId).toBeUndefined();
expect(lastModalData().scopeOptions).not.toHaveLength(0);
});
});
it('goes back to the Context overview', async () => {
const { getByText } = renderView();
await userEvent.click(getByText('Back to Context'));
expect(push).toHaveBeenCalledWith({ name: 'SettingsContext' });
});
});

View File

@ -0,0 +1,510 @@
<script lang="ts" setup>
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 { useI18n } from '@n8n/i18n';
import { ResponseError } from '@n8n/rest-api-client';
import { useToast } from '@n8n/composables/useToast';
import { getDebounceTime } from '@n8n/composables/useDebounce';
import {
N8nButton,
N8nDropdownMenu,
N8nHeading,
N8nIcon,
N8nIconButton,
N8nInput,
N8nSettingsLayout,
N8nSettingsPageHeader,
N8nText,
} from '@n8n/design-system';
import type { DropdownMenuItemProps, TableOptions } from '@n8n/design-system';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
import { useMessage } from '@/app/composables/useMessage';
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 type {
AgentSkillConfirmFailure,
AgentSkillModalData,
AgentSkillModalScopeOption,
} from '@/features/agents/components/AgentSkillModal.vue';
import SkillsTable from '../components/SkillsTable.vue';
import { PREFERENCES_DEFAULT_PAGE_SIZE } from '../context.constants';
import { canWriteInstanceSkillScope, canWriteProjectSkillScope } from '../context.utils';
import type { SkillListQuery } from '../skills.api';
import { useSkillsStore } from '../skills.store';
import { useSkillScopeDisplay } from '../skills.utils';
const INSTANCE_SCOPE = 'instance';
const PROJECT_SCOPE_PREFIX = 'project:';
const ALL_SCOPES = 'all';
type ScopeFilter = SkillScope | typeof ALL_SCOPES;
const i18n = useI18n();
const router = useRouter();
const route = useRoute();
const documentTitle = useDocumentTitle();
const message = useMessage();
const uiStore = useUIStore();
const projectsStore = useProjectsStore();
const skillsStore = useSkillsStore();
const { showError, showMessage } = useToast();
const { scopeDisplay } = useSkillScopeDisplay();
const tableOptions = ref<TableOptions>({
page: 0,
itemsPerPage: PREFERENCES_DEFAULT_PAGE_SIZE,
sortBy: [],
});
const search = ref('');
const scopeFilter = ref<ScopeFilter>(ALL_SCOPES);
const loadFailed = ref(false);
// Paging, search and the scope filter run on the server: the count and the page both
// describe the matching skills, and the page is the only slice the browser holds.
const isFiltered = computed(() => search.value.trim() !== '' || scopeFilter.value !== ALL_SCOPES);
// A failed load also reports zero rows, so the empty state must not stand in for it. A
// filter that matches nothing shows an empty table, not "No skills yet".
const showEmptyState = computed(
() => !skillsStore.loading && !loadFailed.value && !isFiltered.value && skillsStore.count === 0,
);
const scopeFilterItems = computed<Array<DropdownMenuItemProps<ScopeFilter>>>(() =>
(
[
[ALL_SCOPES, 'settings.context.skills.filter.all'],
['instance', 'settings.context.skills.filter.instance'],
['project', 'settings.context.skills.filter.project'],
['agent', 'settings.context.skills.filter.agent'],
] as const
).map(([id, key]) => ({
id,
label: i18n.baseText(key),
checked: scopeFilter.value === id,
})),
);
function listQuery(page: number, itemsPerPage: number): SkillListQuery {
const term = search.value.trim();
return {
skip: page * itemsPerPage,
take: itemsPerPage,
...(term ? { search: term } : {}),
...(scopeFilter.value !== ALL_SCOPES ? { scope: scopeFilter.value } : {}),
};
}
/**
* Where a new skill can live: the instance, the personal project and the team projects
* that the user may write to. An agent skill is made from inside its agent, not here.
*/
const scopeOptions = computed<AgentSkillModalScopeOption[]>(() => {
const options: AgentSkillModalScopeOption[] = [];
if (canWriteInstanceSkillScope()) {
options.push({
value: INSTANCE_SCOPE,
label: i18n.baseText('settings.context.skills.scope.instance'),
icon: 'globe',
});
}
const personalProject = projectsStore.personalProject;
if (personalProject && canWriteProjectSkillScope(personalProject.id)) {
options.push({
value: `${PROJECT_SCOPE_PREFIX}${personalProject.id}`,
label: i18n.baseText('settings.context.skills.scope.personalProject'),
icon: 'user',
});
}
for (const project of projectsStore.myProjects) {
if (project.type !== 'team' || !canWriteProjectSkillScope(project.id)) continue;
options.push({
value: `${PROJECT_SCOPE_PREFIX}${project.id}`,
label: project.name ?? project.id,
icon: 'layers',
});
}
return options;
});
/** Settings creates and moves skills only to a project or the instance, never to one agent. */
function parseScope(value: string): { scope: 'project' | 'instance'; projectId?: string } {
if (value.startsWith(PROJECT_SCOPE_PREFIX)) {
return { scope: 'project', projectId: value.slice(PROJECT_SCOPE_PREFIX.length) };
}
return { scope: 'instance' };
}
async function load() {
const { page = 0, itemsPerPage = PREFERENCES_DEFAULT_PAGE_SIZE } = tableOptions.value;
try {
await skillsStore.fetchSkills(listQuery(page, itemsPerPage));
loadFailed.value = false;
// Deleting the last rows of a page can leave the page past the end.
const lastPage = Math.max(0, Math.ceil(skillsStore.count / itemsPerPage) - 1);
if (page > lastPage) {
tableOptions.value = { ...tableOptions.value, page: lastPage };
await skillsStore.fetchSkills(listQuery(lastPage, itemsPerPage));
}
} catch (error) {
loadFailed.value = true;
showError(error, i18n.baseText('settings.context.skills.error.load'));
}
}
async function onOptionsUpdate(options: TableOptions) {
tableOptions.value = options;
await load();
}
/** A new filter starts at the first page. */
async function resetPage() {
tableOptions.value = { ...tableOptions.value, page: 0 };
await load();
}
const onSearch = useDebounceFn(resetPage, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
async function onScopeFilter(value: ScopeFilter) {
scopeFilter.value = value;
await resetPage();
}
function openModal(data: AgentSkillModalData) {
uiStore.openModalWithData({ name: AGENT_SKILL_MODAL_KEY, data });
}
/** Starts at the SKILL.md upload step, which also offers "Add manually". */
function openCreateModal() {
const options = scopeOptions.value;
if (options.length === 0) return;
openModal({
scopeOptions: options,
initialScope: options[0].value,
existingSkillNames: [],
getDefaultName: async (scopeValue) =>
await skillsStore.fetchNewSkillName({
...parseScope(scopeValue),
name: i18n.baseText('agents.builder.skills.defaultName'),
}),
onConfirm: async ({ skill, scope }) => await onCreate(skill, scope ?? options[0].value),
});
}
/**
* The row is the feedback for a create or a save, as on the preferences page. The list
* keeps the search and the scope filter, so a skill they hide gets a message instead.
*/
function confirmIfHidden(skillId: string, key: 'created' | 'saved') {
if (skillsStore.skills.some((item) => item.id === skillId)) return;
showMessage({
title: i18n.baseText(
key === 'created'
? 'settings.context.skills.create.hidden'
: 'settings.context.skills.save.hidden',
),
type: 'success',
});
}
/**
* A refused save keeps the modal open, so the user keeps what they typed. A taken name
* 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
) {
return { saved: false, nameError: i18n.baseText('settings.context.skills.error.nameTaken') };
}
showError(error, i18n.baseText('settings.context.skills.error.save'));
return { saved: false };
}
async function onCreate(
skill: AgentSkill,
scopeValue: string,
): Promise<AgentSkillConfirmFailure | undefined> {
let createdId: string;
try {
createdId = (await skillsStore.createSkill({ ...parseScope(scopeValue), skill })).id;
} catch (error) {
return saveFailed(error);
}
await load();
confirmIfHidden(createdId, 'created');
return undefined;
}
/** The value of the scope picker for a saved skill. An agent skill has no picker value of its own. */
function scopeValueOf(skill: Pick<SkillListItem, 'scope' | 'projectId'>): string {
if (skill.scope === 'project' && skill.projectId)
return `${PROJECT_SCOPE_PREFIX}${skill.projectId}`;
return skill.scope;
}
async function openEditModal(item: Pick<SkillListItem, 'id'>) {
try {
const detail = await skillsStore.fetchSkill(item.id);
const currentValue = scopeValueOf(detail);
// The current scope is listed even when the user could not create a skill there,
// such as an agent skill, which is only made from inside its agent.
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,
}),
}
: {}),
existingSkillNames: [],
onConfirm: async ({ skill, scope }) =>
await onSave(
detail.id,
skill,
detail.skillHash,
scope !== currentValue ? scope : undefined,
),
});
} catch (error) {
showError(error, i18n.baseText('settings.context.skills.error.loadOne'));
}
}
/** Saves the next version. There are no drafts: what the modal shows is what gets saved. */
/** `newScope` is set when the user picked another scope: the save also moves the skill. */
async function onSave(
id: string,
skill: AgentSkill,
baseSkillHash: string,
newScope?: string,
): Promise<AgentSkillConfirmFailure | undefined> {
let created: boolean;
try {
({ created } = await skillsStore.saveSkill(id, {
name: skill.name,
description: skill.description,
instructions: skill.instructions,
// An empty list clears the references, where an absent one would keep them.
references: skill.references ?? [],
baseSkillHash,
...(newScope ? parseScope(newScope) : {}),
}));
} catch (error) {
return saveFailed(error);
}
// A move is a change even when the content stays the same. Only "Nothing to save"
// gets a message: a real change shows in the table.
if (created || newScope !== undefined) {
await load();
confirmIfHidden(id, 'saved');
} else {
showMessage({ title: i18n.baseText('settings.context.skills.save.noChanges'), type: 'info' });
}
return undefined;
}
async function onDelete(skill: SkillListItem) {
const confirmed = await message.confirm(
i18n.baseText('settings.context.skills.delete.confirm.message'),
i18n.baseText('settings.context.skills.delete.confirm.title'),
{
type: 'warning',
confirmButtonText: i18n.baseText('settings.context.skills.delete.confirm.button'),
cancelButtonText: i18n.baseText('settings.context.skills.delete.cancel'),
},
);
if (confirmed !== MODAL_CONFIRM) return;
try {
await skillsStore.deleteSkill(skill.id);
showMessage({
title: i18n.baseText('settings.context.skills.delete.success'),
type: 'success',
});
} catch (error) {
// An agent may have attached the skill since the list loaded: that answers 409.
showError(error, i18n.baseText('settings.context.skills.error.delete'));
}
await load();
}
async function goBack() {
await router.push({ name: VIEWS.SETTINGS_CONTEXT });
}
onMounted(async () => {
documentTitle.set(i18n.baseText('settings.context.skills.title'));
await Promise.all([load(), projectsStore.getMyProjects().catch(() => undefined)]);
// Deep links: one skill to open, or a new one to start. The skill may be on another
// page, so it is read by id rather than from the list.
const { skillId, create } = route.query;
if (typeof skillId === 'string') {
await openEditModal({ id: skillId });
} else if (create === 'true') {
openCreateModal();
}
});
</script>
<template>
<N8nSettingsLayout
:class="$style.layout"
full-width
show-back
:back-label="i18n.baseText('settings.context.skills.back')"
data-test-id="settings-skills-view"
@back="goBack"
>
<N8nSettingsPageHeader
:title="i18n.baseText('settings.context.skills.title')"
:description="i18n.baseText('settings.context.skills.description')"
:show-docs-link="false"
/>
<div :class="$style.toolbar">
<N8nInput
v-model="search"
:class="$style.search"
:placeholder="i18n.baseText('settings.context.skills.search.placeholder')"
clearable
data-test-id="skills-search"
@update:model-value="onSearch"
>
<template #prefix>
<N8nIcon icon="search" />
</template>
</N8nInput>
<N8nDropdownMenu
:items="scopeFilterItems"
placement="bottom-start"
content-test-id="skills-scope-filter-menu"
@select="onScopeFilter"
>
<template #trigger>
<span :class="$style.filterTrigger">
<N8nIconButton
variant="outline"
icon="funnel"
:aria-label="i18n.baseText('settings.context.skills.filter.label')"
data-test-id="skills-scope-filter"
/>
<span
v-if="scopeFilter !== 'all'"
:class="$style.filterDot"
data-test-id="skills-scope-filter-active"
/>
</span>
</template>
</N8nDropdownMenu>
<span :class="$style.spacer" />
<N8nButton
v-if="scopeOptions.length > 0"
:label="i18n.baseText('settings.context.skills.actions.create')"
data-test-id="skills-create-button"
@click="openCreateModal"
/>
</div>
<SkillsTable
v-model:table-options="tableOptions"
:skills="skillsStore.skills"
:items-length="skillsStore.count"
:loading="skillsStore.loading"
:show-empty="showEmptyState"
@open="openEditModal"
@delete="onDelete"
@update:options="onOptionsUpdate"
>
<template #empty>
<div :class="$style.empty" data-test-id="skills-empty-state">
<N8nHeading tag="h2" size="medium" bold>
{{ i18n.baseText('settings.context.skills.empty.title') }}
</N8nHeading>
<N8nText color="text-light">
{{ i18n.baseText('settings.context.skills.empty.description') }}
</N8nText>
<N8nButton
v-if="scopeOptions.length > 0"
:label="i18n.baseText('settings.context.skills.actions.create')"
data-test-id="skills-empty-create-button"
@click="openCreateModal"
/>
</div>
</template>
</SkillsTable>
</N8nSettingsLayout>
</template>
<style lang="scss" module>
/* The settings shell insets the page; the header is pulled to the table's left edge. */
.layout {
padding: 0;
header {
margin-inline: 0 auto;
}
}
.toolbar {
display: flex;
align-items: center;
gap: var(--spacing--xs);
width: 100%;
}
.spacer {
flex: 1;
}
.search {
max-width: 20rem;
}
.filterTrigger {
position: relative;
display: inline-flex;
}
/* Marks an active scope filter, since the button itself has no label. */
.filterDot {
position: absolute;
top: calc(-1 * var(--spacing--5xs));
right: calc(-1 * var(--spacing--5xs));
width: var(--spacing--2xs);
height: var(--spacing--2xs);
border-radius: 50%;
background-color: var(--color--primary);
pointer-events: none;
}
.empty {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--2xl) var(--spacing--md);
text-align: center;
}
</style>