feat(editor): Attach skills hub skills from a picker in the agent builder (no-changelog) (#41104)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Svetoslav Dekov 2026-10-15 14:14:18 +00:00 committed by GitHub
parent 952b1e73b2
commit b2b2a8641a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 2224 additions and 180 deletions

View File

@ -0,0 +1,17 @@
import { skillNameKey } from '../skill-names';
describe('skillNameKey', () => {
it.each([
['Brand voice', 'brand-voice'],
['Brand voice', 'brand_voice'],
['Brand voice', ' BRAND voice '],
['Brand voice', 'brand -_ voice'],
])('treats "%s" and "%s" as the same name', (left, right) => {
expect(skillNameKey(left)).toBe(skillNameKey(right));
});
it('keeps other characters apart', () => {
expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
});
});

View File

@ -8,6 +8,7 @@ export * from './agent-node-tool-operations';
export * from './agent-personalisation';
export * from './agent-skill.schema';
export * from './skills.schema';
export * from './skill-names';
export * from './child-trace';
export type * from './background-job';
export * from './inline-agent-config.schema';

View File

@ -0,0 +1,11 @@
/**
* The rule for two skill names that are hard to tell apart inside one agent: case,
* surrounding spaces, and runs of spaces, hyphens and underscores do not count.
*/
export function skillNameKey(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, ' ')
.trim();
}

View File

@ -2,25 +2,8 @@ import {
findIntroducedNameClashes,
findNameClashGroups,
renameIntroducesClash,
skillNameKey,
} from '../skills/skill-names';
describe('skillNameKey', () => {
it.each([
['Brand voice', 'brand-voice'],
['Brand voice', 'brand_voice'],
['Brand voice', ' BRAND voice '],
['Brand voice', 'brand -_ voice'],
])('treats "%s" and "%s" as the same name', (left, right) => {
expect(skillNameKey(left)).toBe(skillNameKey(right));
});
it('keeps other characters apart', () => {
expect(skillNameKey('Brand voice')).not.toBe(skillNameKey('Brand voices'));
expect(skillNameKey('brand.voice')).not.toBe(skillNameKey('brand voice'));
});
});
describe('findIntroducedNameClashes', () => {
it('returns an added name that clashes with an existing name', () => {
expect(findIntroducedNameClashes(['Brand voice'], ['brand-voice', 'Pricing'])).toEqual([

View File

@ -1,14 +1,4 @@
/**
* The rule for two skill names that are hard to tell apart inside one agent: case,
* surrounding spaces, and runs of spaces, hyphens and underscores do not count.
*/
export function skillNameKey(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, ' ')
.trim();
}
import { skillNameKey } from '@n8n/api-types';
/**
* Names in `added` that clash with a name in `existing` or with an earlier added name.

View File

@ -8851,8 +8851,7 @@
"agents.builder.tools.type.custom": "Custom tool",
"agents.builder.tools.remove": "Remove",
"agents.builder.tools.back": "Back to tools",
"agents.builder.skills.outdated.title": "This agent uses an older version",
"agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. To get the latest version, remove the skill and add it again.",
"agents.builder.skills.outdated.warning": "This agent uses an older version of this skill. Open the skill to use the latest version.",
"agents.builder.skills.title": "Skills",
"agents.builder.skills.description": "How this agent handles specific tasks",
"agents.builder.skills.count": "{count} skill applied | {count} skills applied",
@ -8910,17 +8909,42 @@
"agents.builder.skills.references.characterCount": "{count} / {max} characters",
"agents.builder.skills.create.error": "Could not create skill",
"agents.builder.skills.saveError": "Could not save skill",
"agents.builder.skills.duplicateName.error": "Agent already has a skill named \"{name}\"",
"agents.builder.skills.duplicateName.error": "Agent already has a skill with a name like \"{name}\"",
"agents.builder.skills.validation.nameRequired": "Enter a name.",
"agents.builder.skills.validation.nameMaxLength": "Name must be 128 characters or fewer.",
"agents.builder.skills.validation.nameDuplicate": "Agent already has a skill with this name.",
"agents.builder.skills.validation.nameDuplicate": "Agent already has a skill with a name like this.",
"agents.builder.skills.validation.descriptionRequired": "Enter a description.",
"agents.builder.skills.validation.descriptionMaxLength": "Description must be 512 characters or fewer.",
"agents.builder.skills.validation.instructionsRequired": "Enter instructions.",
"agents.builder.skills.validation.instructionsMaxLength": "Instructions must be {max} characters or fewer.",
"agents.builder.skills.missingContent.callout": "This skill's content is missing. Fill it in and save to recreate it.",
"agents.builder.skills.remove": "Remove skill",
"agents.builder.skills.detach": "Detach",
"agents.builder.skills.back": "Back to skills",
"agents.builder.skills.picker.search.placeholder": "Search skills",
"agents.builder.skills.picker.create": "Create skill",
"agents.builder.skills.picker.create.description": "Write a new skill for this agent",
"agents.builder.skills.picker.add": "Add skill",
"agents.builder.skills.picker.loading": "Loading skills…",
"agents.builder.skills.picker.empty": "No skills to add. Create one, or ask an admin to share one with this project.",
"agents.builder.skills.picker.noResults": "No skills match your search",
"agents.builder.skills.picker.loadError": "Could not load skills",
"agents.builder.skills.picker.previewError": "Could not load the skill",
"agents.builder.skills.accessError": "Could not check if you can edit this skill",
"agents.builder.skills.picker.similarName": "Skills {names} have names that are hard to tell apart. Rename one of them.",
"agents.builder.skills.attach.error": "Could not add skill",
"agents.builder.skills.availableTo.label": "Where can this skill be added?",
"agents.builder.skills.availableTo.tooltip": "This project: agents in this project can add it. This agent only: only this agent can use it. Global: agents in all projects can add it.",
"agents.builder.skills.availableTo.project": "This project",
"agents.builder.skills.availableTo.agent": "This agent only",
"agents.builder.skills.availableTo.instance": "Global",
"agents.builder.skills.availableTo.locked": "Other agents use this skill. Detach it from those agents to change where it can be added. For agents that you cannot open, ask an admin.",
"agents.builder.skills.hint.addToEdit": "Add this skill to the agent to edit it.",
"agents.builder.skills.hint.outdated": "This agent uses an older version of this skill.",
"agents.builder.skills.useLatest": "Use latest version",
"agents.builder.skills.useLatest.error": "Could not switch to the latest version",
"agents.builder.skills.hint.readOnly.instance": "You can't edit this skill. Ask an instance admin to change it.",
"agents.builder.skills.hint.readOnly.project": "You can't edit this skill. Ask a project admin to change it.",
"agents.builder.tasks.title": "Schedules",
"agents.builder.tasks.description": "Objectives the agent runs on a schedule once it's published.",
"agents.builder.tasks.add": "Add schedule",
@ -9432,9 +9456,14 @@
"tools.connection.categories.community": "Community",
"tools.connection.categories.workflows": "Workflows",
"tools.connection.categories.agents": "Agents",
"tools.connection.categories.project": "Project",
"tools.connection.categories.thisAgent": "This agent",
"tools.connection.categories.global": "Global",
"tools.connection.categories.data": "Data",
"tools.connection.action.connect": "Connect",
"tools.connection.action.connected": "Connected",
"tools.connection.action.attached": "Attached",
"tools.connection.action.detached": "Detached",
"tools.connection.action.connecting": "Connecting…",
"tools.connection.action.reconnect": "Reconnect",
"tools.connection.action.configure": "Configure",

View File

@ -5658,6 +5658,7 @@ describe('AgentBuilderView — three-column shell', () => {
'summarize_notes',
updatedSkill,
'skill-hash-1',
undefined,
);
});
@ -5710,6 +5711,7 @@ describe('AgentBuilderView — three-column shell', () => {
instructions: skill.instructions,
},
'skill-hash-1',
undefined,
);
});

View File

@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { createComponentRenderer } from '@/__tests__/render';
import { createTestingPinia } from '@pinia/testing';
import { mockedStore } from '@/__tests__/utils';
@ -7,9 +8,11 @@ import { configure, fireEvent, waitFor } from '@testing-library/vue';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
type SkillDetail,
type SkillListItem,
} from '@n8n/api-types';
import AgentSkillModal from '../components/AgentSkillModal.vue';
import AgentSkillModal, { type AgentSkillModalData } from '../components/AgentSkillModal.vue';
import type { AgentSkill } from '../types';
import { AgentModalTestStub } from './utils/AgentModalTestStub';
@ -23,16 +26,52 @@ vi.mock('@n8n/i18n', () => {
});
const apiCreateSpy = vi.fn();
const { listAttachableSkills, getHubSkill } = vi.hoisted(() => ({
listAttachableSkills: vi.fn(),
getHubSkill: vi.fn(),
}));
vi.mock('../composables/useAgentApi', () => ({
createAgentSkill: (...args: unknown[]) => apiCreateSpy(...args),
listAttachableSkills,
getHubSkill,
}));
const { showMessage, trackImportedSkill } = vi.hoisted(() => ({
const { showMessage, showError, trackImportedSkill } = vi.hoisted(() => ({
showMessage: vi.fn(),
showError: vi.fn(),
trackImportedSkill: vi.fn(),
}));
vi.mock('@n8n/composables/useToast', () => ({
useToast: () => ({ showMessage }),
useToast: () => ({ showMessage, showError }),
}));
vi.mock('@/features/shared/toolsConnection/ToolsConnectionModal.vue', () => ({
default: {
name: 'ToolsConnectionModal',
props: ['items', 'emptyMessage', 'createAction', 'categories', 'categoryLabels'],
emits: ['connect', 'disconnect', 'open-detail', 'create'],
template: `
<div data-testid="agent-skill-picker">
<span data-testid="agent-skill-picker-tabs">{{ categories.join(',') }} {{ categoryLabels?.project }}</span>
<button data-testid="agent-skill-picker-create" @click="$emit('create', 'all')">
{{ createAction.label }}
</button>
<button
v-for="category in categories"
:key="category"
:data-testid="'agent-skill-picker-create-' + category"
@click="$emit('create', category)"
/>
<div v-if="items.length === 0" data-testid="agent-skill-picker-empty">{{ emptyMessage }}</div>
<div v-for="item in items" :key="item.id" data-testid="agent-skill-picker-row">
<button data-testid="agent-skill-picker-row-open" @click="$emit('open-detail', item)">{{ item.title }}</button>
<span data-testid="agent-skill-picker-category">{{ item.category }}</span>
<span v-if="item.disabled" data-testid="agent-skill-picker-disabled">{{ item.disabledReason }}</span>
<button v-else data-testid="agent-skill-picker-toggle" :data-attached="String(item.attached)" @click="$emit(item.attached ? 'disconnect' : 'connect', item)" />
</div>
</div>
`,
},
}));
vi.mock('../composables/useAgentTelemetry', () => ({
@ -46,12 +85,23 @@ function renderModal({
skill,
skillId,
existingSkillNames,
...hubOptions
}: {
onConfirm?: (payload: { id?: string; skill: AgentSkill }) => void;
onConfirm?: AgentSkillModalData['onConfirm'];
skill?: AgentSkill;
skillId?: string;
existingSkillNames?: string[];
} = {}) {
} & Pick<
AgentSkillModalData,
| 'hubSkillsEnabled'
| 'getAttachedSkills'
| 'projectLabel'
| 'canCreateInstanceSkills'
| 'onAttach'
| 'onRemove'
| 'outdated'
| 'onUseLatestVersion'
> = {}) {
const renderComponent = createComponentRenderer(AgentSkillModal, {
global: {
stubs: {
@ -82,6 +132,7 @@ function renderModal({
skillId,
existingSkillNames,
onConfirm,
...hubOptions,
},
},
});
@ -193,7 +244,7 @@ describe('AgentSkillModal', () => {
existingSkillNames: ['Other skill'],
});
await fireEvent.update(getTitleInput(container), ' other SKILL ');
await fireEvent.update(getTitleInput(container), ' other_SKILL ');
expect(queryByTestId('agent-modal-title-error')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-create-save'));
@ -380,8 +431,597 @@ describe('AgentSkillModal', () => {
});
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({
hubSkillsEnabled: false,
onAttach: vi.fn(),
onConfirm,
});
expect(queryByTestId('agent-skill-picker')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-upload')).toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-add-manually'));
expect(queryByTestId('agent-skill-availability-select')).not.toBeInTheDocument();
await fireEvent.update(getTitleInput(container), 'Research');
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'));
expect(onConfirm).toHaveBeenCalledWith(
expect.not.objectContaining({ availableTo: expect.anything() }),
);
expect(listAttachableSkills).not.toHaveBeenCalled();
expect(getHubSkill).not.toHaveBeenCalled();
});
describe('with the skills hub', () => {
const projectSkill = makeListItem({ id: 'tone', name: 'Support tone', scope: 'project' });
const instanceSkill = makeListItem({ id: 'sql', name: 'SQL helper', scope: 'instance' });
const similarSkill = makeListItem({ id: 'voice', name: 'brand-voice', scope: 'instance' });
const agentSkill = makeListItem({
id: 'notes',
name: 'Call notes',
scope: 'project',
agentId: 'a1',
});
it('lists attachable skills in project, agent and global tabs and attaches one without closing', async () => {
listAttachableSkills.mockResolvedValue([projectSkill, agentSkill, instanceSkill]);
const onAttach = vi.fn().mockResolvedValue(undefined);
const { findAllByTestId, getAllByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
projectLabel: 'Marketing',
onAttach,
});
expect(listAttachableSkills).toHaveBeenCalledWith(expect.anything(), 'a1');
expect(await findAllByTestId('agent-skill-picker-row')).toHaveLength(3);
const picker = getByTestId('agent-skill-picker');
expect(getAllByTestId('agent-skill-picker-category').map((node) => node.textContent)).toEqual(
['project', 'this-agent', 'global'],
);
expect(queryByTestId('agent-skill-upload')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
await fireEvent.click(getAllByTestId('agent-skill-picker-toggle')[2]);
expect(onAttach).toHaveBeenCalledWith(instanceSkill);
expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(picker).toBeVisible();
});
it('names the project tab after the project of the agent', async () => {
listAttachableSkills.mockResolvedValue([]);
const { getByTestId } = renderModal({
hubSkillsEnabled: true,
projectLabel: 'Marketing',
onAttach: vi.fn(),
});
expect(getByTestId('agent-skill-picker-tabs')).toHaveTextContent(
'all,project,this-agent,global Marketing',
);
});
it('shows a skill as attached while it attaches', async () => {
listAttachableSkills.mockResolvedValue([instanceSkill]);
let finishAttach = () => {};
const onAttach = vi.fn(
async () => await new Promise<void>((resolve) => (finishAttach = resolve)),
);
const { findByTestId } = renderModal({ hubSkillsEnabled: true, onAttach });
const toggle = await findByTestId('agent-skill-picker-toggle');
await fireEvent.click(toggle);
expect(await findByTestId('agent-skill-picker-toggle')).toHaveAttribute(
'data-attached',
'true',
);
finishAttach();
await waitFor(async () =>
expect(await findByTestId('agent-skill-picker-toggle')).toHaveAttribute(
'data-attached',
'false',
),
);
});
it('detaches an attached skill without closing and refuses names that are hard to tell apart', async () => {
listAttachableSkills.mockResolvedValue([projectSkill, similarSkill]);
const onAttach = vi.fn();
const onRemove = vi.fn();
const { findAllByTestId, getByTestId } = renderModal({
hubSkillsEnabled: true,
getAttachedSkills: () => [
{ id: 'tone', name: 'Support tone' },
{ id: 'other', name: 'Brand voice' },
],
onAttach,
onRemove,
});
expect(await findAllByTestId('agent-skill-picker-row')).toHaveLength(2);
expect(getByTestId('agent-skill-picker-disabled')).toHaveTextContent(
'agents.builder.skills.picker.similarName',
);
const toggle = getByTestId('agent-skill-picker-toggle');
expect(toggle).toHaveAttribute('data-attached', 'true');
await fireEvent.click(toggle);
expect(onRemove).toHaveBeenCalledWith('tone');
expect(onAttach).not.toHaveBeenCalled();
expect(uiStore.closeModal).not.toHaveBeenCalled();
});
it('shows an empty message when no skill can be attached', async () => {
listAttachableSkills.mockResolvedValue([]);
const { findByTestId } = renderModal({ hubSkillsEnabled: true, onAttach: vi.fn() });
await waitFor(async () =>
expect(await findByTestId('agent-skill-picker-empty')).toHaveTextContent(
'agents.builder.skills.picker.empty',
),
);
});
it('goes from the picker to the upload step and back', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
const { getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(getByTestId('agent-skill-picker-create'));
expect(getByTestId('agent-skill-upload')).toBeInTheDocument();
await fireEvent.click(getByTestId('agent-modal-back'));
expect(queryByTestId('agent-skill-upload')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-picker')).toBeVisible();
});
it('opens a read-only preview from a row click and adds the skill from there', async () => {
listAttachableSkills.mockResolvedValue([projectSkill, instanceSkill]);
getHubSkill.mockResolvedValue(
makeDetail({ id: 'sql', name: 'SQL helper', scope: 'instance', canEdit: true }),
);
const onAttach = vi.fn();
const { findAllByTestId, findByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach,
});
await fireEvent.click((await findAllByTestId('agent-skill-picker-row-open'))[1]);
expect(await findByTestId('agent-skill-preview')).toBeInTheDocument();
expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
expect(getByTestId('agent-skill-instructions-text')).toHaveTextContent('Instructions');
expect(queryByTestId('agent-skill-instructions-editor')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-footer-hint')).toHaveTextContent(
'agents.builder.skills.hint.addToEdit',
);
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-remove')).not.toBeInTheDocument();
expect(onAttach).not.toHaveBeenCalled();
await fireEvent.click(getByTestId('agent-skill-preview-add'));
expect(onAttach).toHaveBeenCalledWith(instanceSkill);
expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-picker')).toBeVisible();
});
it('offers to remove an attached skill from its preview', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
getHubSkill.mockResolvedValue(
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
);
const onRemove = vi.fn();
const { findByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
getAttachedSkills: () => [{ id: 'tone', name: 'Support tone' }],
onAttach: vi.fn(),
onRemove,
});
await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
await findByTestId('agent-skill-preview');
expect(queryByTestId('agent-skill-preview-add')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-footer-hint')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-remove'));
expect(onRemove).toHaveBeenCalledWith('tone');
expect(uiStore.closeModal).not.toHaveBeenCalled();
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-picker')).toBeVisible();
});
it('returns from the preview to the picker', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
getHubSkill.mockResolvedValue(
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
);
const { findByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
await findByTestId('agent-skill-preview');
await fireEvent.click(getByTestId('agent-modal-back'));
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-picker')).toBeVisible();
});
it('stays on the picker when the preview cannot load', async () => {
listAttachableSkills.mockResolvedValue([projectSkill]);
getHubSkill.mockRejectedValue(new Error('Not found'));
const { findByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(await findByTestId('agent-skill-picker-row-open'));
await waitFor(() =>
expect(showError).toHaveBeenCalledWith(
expect.any(Error),
'agents.builder.skills.picker.previewError',
),
);
expect(queryByTestId('agent-skill-preview')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-picker')).toBeVisible();
});
it.each([
['project', 'agents.builder.skills.availableTo.project'],
['instance', 'agents.builder.skills.availableTo.instance'],
] as const)(
'saves a new skill for the %s from the availability select',
async (availableTo, label) => {
listAttachableSkills.mockResolvedValue([]);
const onConfirm = vi.fn();
const { container, getByTestId, findByText } = renderModal({
hubSkillsEnabled: true,
canCreateInstanceSkills: true,
projectLabel: 'Marketing',
onAttach: vi.fn(),
onConfirm,
});
await fireEvent.click(getByTestId('agent-skill-picker-create'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.update(getTitleInput(container), 'Research');
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-availability-select').querySelector('.select-trigger')!,
);
await fireEvent.click(await findByText(label));
await fireEvent.click(getByTestId('agent-skill-create-save'));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ id: undefined, availableTo }),
);
},
);
it.each([
['all', true, 'agent'],
['project', true, 'project'],
['this-agent', true, 'agent'],
['global', true, 'instance'],
['global', false, 'agent'],
] as const)(
'starts a new skill from the %s tab with a matching target (global allowed: %s)',
async (category, canCreateInstanceSkills, availableTo) => {
listAttachableSkills.mockResolvedValue([]);
const onConfirm = vi.fn();
const { container, getByTestId } = renderModal({
hubSkillsEnabled: true,
canCreateInstanceSkills,
onAttach: vi.fn(),
onConfirm,
});
await fireEvent.click(getByTestId(`agent-skill-picker-create-${category}`));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.update(getTitleInput(container), 'Research');
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'));
expect(onConfirm).toHaveBeenCalledWith(expect.objectContaining({ availableTo }));
},
);
it('checks a new skill name against the skills attached after the picker opened', async () => {
listAttachableSkills.mockResolvedValue([]);
const attached = ref<Array<{ id: string; name: string }>>([]);
const onConfirm = vi.fn();
const { container, getByTestId } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
getAttachedSkills: () => attached.value,
onConfirm,
});
attached.value = [{ id: 'brand', name: 'P-Brand voice' }];
await fireEvent.click(getByTestId('agent-skill-picker-create'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.update(getTitleInput(container), 'p brand voice');
await fireEvent.update(
getByTestId('agent-skill-description-input').querySelector('input')!,
'Use for replies',
);
await fireEvent.update(getByTestId('agent-skill-instructions-editor'), 'Be kind');
await fireEvent.click(getByTestId('agent-skill-create-save'));
expect(onConfirm).not.toHaveBeenCalled();
expect(getByTestId('agent-modal-title-error')).toHaveTextContent(
'agents.builder.skills.validation.nameDuplicate',
);
});
it('offers only this project and this agent to a user who may not create global skills', async () => {
listAttachableSkills.mockResolvedValue([]);
const { getByTestId, findByText, queryByText } = renderModal({
hubSkillsEnabled: true,
onAttach: vi.fn(),
});
await fireEvent.click(getByTestId('agent-skill-picker-create'));
await fireEvent.click(getByTestId('agent-skill-add-manually'));
await fireEvent.click(
getByTestId('agent-skill-availability-select').querySelector('.select-trigger')!,
);
expect(await findByText('agents.builder.skills.availableTo.agent')).toBeInTheDocument();
expect(queryByText('agents.builder.skills.availableTo.instance')).not.toBeInTheDocument();
});
it('keeps a skill read-only and reports the error when its access check fails', async () => {
const error = new Error('offline');
getHubSkill.mockRejectedValue(error);
const { findByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
skillId: 'sql',
skill: { name: 'SQL helper', description: 'Use for SQL', instructions: 'Write SQL' },
onRemove: vi.fn(),
});
await findByTestId('agent-skill-remove');
await vi.waitFor(() =>
expect(showError).toHaveBeenCalledWith(error, 'agents.builder.skills.accessError'),
);
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
});
it('shows a skill that the user cannot edit as read-only', async () => {
getHubSkill.mockResolvedValue(
makeDetail({ id: 'sql', name: 'SQL helper', scope: 'instance', canEdit: false }),
);
const onRemove = vi.fn();
const { findByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
skillId: 'sql',
skill: { name: 'SQL helper', description: 'Use for SQL', instructions: 'Write SQL' },
onRemove,
});
expect(await findByTestId('agent-skill-footer-hint')).toHaveTextContent(
'agents.builder.skills.hint.readOnly.instance',
);
expect(getByTestId('agent-skill-description-text')).toHaveTextContent('Use for SQL');
expect(queryByTestId('agent-skill-description-input')).not.toBeInTheDocument();
expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-add-reference')).not.toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-remove'));
expect(onRemove).toHaveBeenCalledWith('sql');
});
describe('where an existing skill can be added', () => {
const tone = {
name: 'Support tone',
description: 'Use for replies',
instructions: 'Be kind',
};
async function renderEdit(detail: SkillDetail, onConfirm = vi.fn()) {
getHubSkill.mockResolvedValue(detail);
const result = renderModal({
hubSkillsEnabled: true,
skillId: detail.id,
skill: tone,
onConfirm,
onRemove: vi.fn(),
});
await result.findByTestId('agent-skill-availability-select');
return result;
}
it('enables save only after the skill or its target changes', async () => {
const { getByTestId } = await renderEdit(
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
);
expect(getByTestId('agent-skill-create-save')).toBeDisabled();
await fireEvent.update(
getByTestId('agent-skill-description-input').querySelector('input')!,
'Use for all replies',
);
expect(getByTestId('agent-skill-create-save')).toBeEnabled();
});
it('changes the target of a skill that no other agent uses', async () => {
const onConfirm = vi.fn();
const { getByTestId, findByText } = await renderEdit(
{
...makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
usedBy: {
drafts: [{ agentId: 'a1', agentName: 'This agent', projectId: 'p1' }],
pins: [{ agentId: 'a1', agentName: 'This agent', projectId: 'p1', version: 1 }],
hiddenAgents: 0,
},
},
onConfirm,
);
const select = getByTestId('agent-skill-availability-select');
await fireEvent.click(select.querySelector('.select-trigger')!);
await fireEvent.click(await findByText('agents.builder.skills.availableTo.agent'));
await fireEvent.click(getByTestId('agent-skill-create-save'));
expect(onConfirm).toHaveBeenCalledWith(
expect.objectContaining({ id: 'tone', availableTo: 'agent' }),
);
});
it('does not send a target when the user keeps it', async () => {
const onConfirm = vi.fn();
const { getByTestId } = await renderEdit(
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', agentId: 'a1' }),
onConfirm,
);
await fireEvent.click(getByTestId('agent-skill-create-save'));
expect(onConfirm).toHaveBeenCalledWith(
expect.not.objectContaining({ availableTo: expect.anything() }),
);
});
it.each([
[
'a draft of another agent',
{
drafts: [{ agentId: 'a2', agentName: 'Other', projectId: 'p1' }],
pins: [],
hiddenAgents: 0,
},
],
[
'a pin of another agent',
{
drafts: [],
pins: [{ agentId: 'a2', agentName: 'Other', projectId: 'p1', version: null }],
hiddenAgents: 0,
},
],
['an agent the user cannot see', { drafts: [], pins: [], hiddenAgents: 1 }],
])('locks the target when %s uses the skill', async (_case, usedBy) => {
const { getByTestId } = await renderEdit({
...makeDetail({ id: 'tone', name: 'Support tone', scope: 'project' }),
usedBy,
});
expect(
getByTestId('agent-skill-availability-select').querySelector('.is-disabled, [disabled]'),
).not.toBeNull();
});
});
it('shows a skill on an older version as read-only with a switch to the latest version', async () => {
getHubSkill.mockResolvedValue(
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', canEdit: true }),
);
const onUseLatestVersion = vi.fn();
const { findByTestId, getByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
skillId: 'tone',
skill: { name: 'Support tone', description: 'Use for replies', instructions: 'Be kind' },
outdated: true,
onUseLatestVersion,
onRemove: vi.fn(),
});
expect(await findByTestId('agent-skill-footer-hint')).toHaveTextContent(
'agents.builder.skills.hint.outdated',
);
expect(getByTestId('agent-skill-description-text')).toHaveTextContent('Use for replies');
expect(queryByTestId('agent-modal-title-input')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).not.toBeInTheDocument();
expect(getByTestId('agent-skill-remove')).toBeInTheDocument();
await fireEvent.click(getByTestId('agent-skill-use-latest'));
expect(uiStore.closeModal).toHaveBeenCalledWith(MODAL_NAME);
expect(onUseLatestVersion).toHaveBeenCalledWith('tone');
});
it('keeps a skill that the user can edit editable', async () => {
getHubSkill.mockResolvedValue(
makeDetail({ id: 'tone', name: 'Support tone', scope: 'project', canEdit: true }),
);
const { findByTestId, queryByTestId } = renderModal({
hubSkillsEnabled: true,
skillId: 'tone',
skill: { name: 'Support tone', description: 'Use for replies', instructions: 'Be kind' },
});
await waitFor(() => expect(getHubSkill).toHaveBeenCalled());
expect(await findByTestId('agent-modal-title-input')).toBeInTheDocument();
expect(queryByTestId('agent-skill-footer-hint')).not.toBeInTheDocument();
expect(queryByTestId('agent-skill-description-input')).toBeInTheDocument();
expect(queryByTestId('agent-skill-create-save')).toBeInTheDocument();
});
});
});
function makeListItem(
overrides: Pick<SkillListItem, 'id' | 'name' | 'scope'> & Partial<SkillListItem>,
): SkillListItem {
return {
description: `${overrides.name} description`,
projectId: overrides.scope === 'project' ? 'p1' : null,
projectName: overrides.scope === 'project' ? 'Marketing' : null,
agentId: null,
agentName: null,
source: 'ui',
latestVersion: 1,
usedByAgents: 0,
canEdit: true,
canDelete: true,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
...overrides,
};
}
function makeDetail(
overrides: Pick<SkillListItem, 'id' | 'name' | 'scope'> & Partial<SkillListItem>,
): SkillDetail {
const item = makeListItem(overrides);
return {
...item,
skill: { name: item.name, description: item.description, instructions: 'Instructions' },
skillHash: 'hash',
usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
};
}
function getTitleInput(container: Element): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>(
'[data-testid="agent-modal-title-input"]',

View File

@ -115,6 +115,61 @@ describe('AgentSkillViewer', () => {
expect(wrapper.emitted('select:path')).toBeUndefined();
});
it('shows a read-only skill as text, without form fields', () => {
const wrapper = mountViewer({
readOnly: true,
skill: {
name: 'Triage',
description: 'Use for triage',
instructions: 'Check the SLA first.',
references: [{ path: 'references/labels.md', content: 'Use needs-repro.' }],
},
});
expect(wrapper.find('[data-testid="agent-skill-description-text"]').text()).toBe(
'Use for triage',
);
expect(wrapper.find('[data-testid="agent-skill-instructions-text"]').text()).toContain(
'Check the SLA first.',
);
expect(wrapper.find('[data-testid="agent-skill-description-input"]').exists()).toBe(false);
expect(wrapper.find('[data-testid="agent-skill-instructions-editor"]').exists()).toBe(false);
expect(wrapper.text()).not.toContain('characters');
});
it('shows the task lists of a read-only skill as disabled checkboxes', () => {
const wrapper = mountViewer({
readOnly: true,
skill: {
name: 'Triage',
description: 'Use for triage',
instructions: '- [ ] Check the SLA',
},
});
const checkbox = wrapper.find('[data-testid="agent-skill-instructions-text"] input');
expect(checkbox.attributes('type')).toBe('checkbox');
expect(checkbox.attributes('disabled')).toBeDefined();
});
it('shows a read-only reference as text', () => {
const wrapper = mountViewer({
readOnly: true,
selectedPath: 'references/labels.md',
skill: {
name: 'Triage',
description: 'Use for triage',
instructions: 'Check the SLA first.',
references: [{ path: 'references/labels.md', content: 'Use needs-repro.' }],
},
});
expect(wrapper.find('[data-testid="agent-skill-reference-text"]').text()).toContain(
'Use needs-repro.',
);
expect(wrapper.find('[data-testid="agent-skill-reference-name-input"]').exists()).toBe(false);
});
it('shows the instructions character count, not their byte size', () => {
const wrapper = mountViewer({
skill: {

View File

@ -10,9 +10,6 @@ vi.mock('@n8n/i18n', () => ({
useI18n: () => ({ baseText: (key: string) => key }),
}));
const showMessage = vi.fn();
vi.mock('@n8n/composables/useToast', () => ({ useToast: () => ({ showMessage }) }));
const skill = { name: 'Skill', description: 'A skill', instructions: 'Do it.' };
describe('AgentSkillsSection', () => {
@ -35,7 +32,7 @@ describe('AgentSkillsSection', () => {
expect(current.find('[data-testid="agent-chip-warning-icon"]').exists()).toBe(false);
});
it('does not open an outdated skill and tells the user to add it again', async () => {
it('opens an outdated skill like any other skill', async () => {
const wrapper = mount(AgentSkillsSection, {
props: {
skills: [
@ -50,11 +47,6 @@ describe('AgentSkillsSection', () => {
await outdated.trigger('click');
await current.trigger('click');
expect(wrapper.emitted('open-skill')).toEqual([['current']]);
expect(showMessage).toHaveBeenCalledWith({
type: 'warning',
title: 'agents.builder.skills.outdated.title',
message: 'agents.builder.skills.outdated.warning',
});
expect(wrapper.emitted('open-skill')).toEqual([['outdated'], ['current']]);
});
});

View File

@ -23,7 +23,12 @@ import {
listN8nChatAgents,
listN8nChatThreads,
duplicateAgent,
listAttachableSkills,
createAgentSkill,
getHubSkill,
updateAgentSkill,
} from '../composables/useAgentApi';
import type { SkillListItem } from '@n8n/api-types';
import type { AgentResource, AgentJsonConfig } from '../types';
vi.mock('@n8n/rest-api-client', () => ({
@ -39,6 +44,79 @@ describe('useAgentApi', () => {
vi.clearAllMocks();
});
describe('listAttachableSkills', () => {
it('lists the attachable skills of the agent in one call, by target and then by name', async () => {
const skill = (name: string, scope: SkillListItem['scope'], agentId: string | null = null) =>
({ id: name, name, scope, agentId }) as SkillListItem;
vi.mocked(makeRestApiRequest).mockResolvedValueOnce({
count: 5,
data: [
skill('Tone', 'instance'),
skill('Notes', 'project', 'agent-1'),
skill('SQL', 'project'),
skill('Brand', 'instance'),
skill('Billing', 'project'),
],
});
const result = await listAttachableSkills(restApiContext, 'agent-1');
expect(makeRestApiRequest).toHaveBeenCalledWith(restApiContext, 'GET', '/skills', {
attachableTo: 'agent-1',
});
expect(result.map(({ name }) => name)).toEqual(['Billing', 'SQL', 'Notes', 'Brand', 'Tone']);
});
});
describe('hub skill requests', () => {
const skill = { name: 'Tone', description: 'Use for replies', instructions: 'Be kind' };
it('creates an agent skill with its target, and without one when none is given', async () => {
await createAgentSkill(restApiContext, 'project-1', 'agent-1', skill, 'agent');
await createAgentSkill(restApiContext, 'project-1', 'agent-1', skill);
expect(makeRestApiRequest).toHaveBeenNthCalledWith(
1,
restApiContext,
'POST',
'/projects/project-1/agents/v2/agent-1/skills',
{ ...skill, availableTo: 'agent' },
);
expect(makeRestApiRequest).toHaveBeenNthCalledWith(
2,
restApiContext,
'POST',
'/projects/project-1/agents/v2/agent-1/skills',
skill,
);
});
it('saves the skill text and its target in one request', async () => {
await updateAgentSkill(
restApiContext,
'project-1',
'agent-1',
'skill-1',
skill,
'hash-1',
'instance',
);
expect(makeRestApiRequest).toHaveBeenCalledWith(
restApiContext,
'PATCH',
'/projects/project-1/agents/v2/agent-1/skills/skill-1',
{ ...skill, baseSkillHash: 'hash-1', availableTo: 'instance' },
);
});
it('gets one hub skill', async () => {
await getHubSkill(restApiContext, 'skill-1');
expect(makeRestApiRequest).toHaveBeenCalledWith(restApiContext, 'GET', '/skills/skill-1');
});
});
describe('listAgentsPage', () => {
it('requests agents using the raw paginated response contract', async () => {
const response = {

View File

@ -5,9 +5,11 @@ import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { useUIStore } from '@/app/stores/ui.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import { AGENT_TOOLS_MODAL_KEY, AGENT_SKILL_MODAL_KEY } from '../constants';
import { useAgentCapabilitiesActions } from '../composables/useAgentCapabilitiesActions';
import { createAgentSkill } from '../composables/useAgentApi';
import { createAgentSkill, getHubSkill } from '../composables/useAgentApi';
import type { SkillDetail, SkillListItem } from '@n8n/api-types';
import type {
AgentJsonConfig,
AgentJsonMcpServerConfig,
@ -34,6 +36,14 @@ vi.mock('@n8n/composables/useToast', () => ({
vi.mock('../composables/useAgentApi', () => ({
createAgentSkill: vi.fn(),
getHubSkill: vi.fn(),
}));
const { hasScopeSpy } = vi.hoisted(() => ({
hasScopeSpy: vi.fn(() => false),
}));
vi.mock('@n8n/stores/rbac.store', () => ({
useRBACStore: () => ({ hasScope: hasScopeSpy }),
}));
function makeConfig(overrides: Partial<AgentJsonConfig> = {}): AgentJsonConfig {
@ -51,6 +61,7 @@ function setup(
overrides: {
supportsToolApproval?: boolean;
ensureAgentPersisted?: () => Promise<void>;
flushConfigSave?: () => Promise<unknown>;
beforeAgentMutation?: () => Promise<void>;
refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise<boolean>;
agent?: Ref<AgentResource | null>;
@ -63,6 +74,7 @@ function setup(
const localConfig = ref<AgentJsonConfig | null>(makeConfig());
const scheduleConfigUpdate = vi.fn();
const scheduleSkillSave = vi.fn();
const agentId = overrides.agentId ?? ref('inline:node-1');
const projectId = overrides.projectId ?? ref('project-1');
@ -73,11 +85,19 @@ function setup(
agentId: computed(() => agentId.value),
connectedTriggers: ref([]),
scheduleConfigUpdate,
scheduleSkillSave: vi.fn(),
scheduleSkillSave,
...overrides,
});
return { uiStore, actions, scheduleConfigUpdate, localConfig, agentId, projectId };
return {
uiStore,
actions,
scheduleConfigUpdate,
scheduleSkillSave,
localConfig,
agentId,
projectId,
};
}
type ToolsModalData = {
@ -150,8 +170,19 @@ describe('useAgentCapabilitiesActions — tools modal host seam', () => {
type SkillModalData = {
skillId?: string;
skill?: AgentSkill;
existingSkillNames?: string[];
onConfirm: (payload: { id?: string; skill: AgentSkill }) => void;
hubSkillsEnabled?: boolean;
outdated?: boolean;
onUseLatestVersion?: (skillId: string) => void;
getAttachedSkills?: () => Array<{ id: string; name: string }>;
canCreateInstanceSkills?: boolean;
onConfirm: (payload: {
id?: string;
skill: AgentSkill;
availableTo?: 'project' | 'agent' | 'instance';
}) => void;
onAttach?: (skill: SkillListItem) => Promise<void>;
onRemove?: (skillId: string) => void;
};
@ -580,3 +611,378 @@ describe('useAgentCapabilitiesActions — localSkills host seam', () => {
expect(actions.appliedSkills.value).toEqual([]);
});
});
describe('useAgentCapabilitiesActions — skills hub', () => {
const supportTone: AgentSkill = {
name: 'Support tone',
description: 'Use for support replies',
instructions: 'Be kind.',
};
const sqlHelper: AgentSkill = {
name: 'SQL helper',
description: 'Use for SQL questions',
instructions: 'Write SQL.',
};
function makeDetail(id: string, skill: AgentSkill): SkillDetail {
return {
id,
name: skill.name,
description: skill.description,
scope: 'instance',
projectId: null,
projectName: null,
agentId: null,
agentName: null,
source: 'ui',
latestVersion: 1,
usedByAgents: 0,
canEdit: false,
canDelete: false,
createdAt: '2026-10-01T00:00:00.000Z',
updatedAt: '2026-10-01T00:00:00.000Z',
skill,
skillHash: `hash-${id}`,
usedBy: { drafts: [], pins: [], hiddenAgents: 0 },
};
}
function setupHub({
skillsHubEnabled = true,
flushConfigSave,
}: { skillsHubEnabled?: boolean; flushConfigSave?: () => Promise<unknown> } = {}) {
const agent = ref<AgentResource | null>({
id: 'agent-1',
skills: { tone: supportTone },
skillHashes: { tone: 'hash-tone' },
} as unknown as AgentResource);
const result = setup({ agent, agentId: ref('agent-1'), flushConfigSave });
useSettingsStore().moduleSettings.agents = {
enabled: true,
modules: [],
knowledgeBaseEnabled: false,
proxyEnabled: false,
skillsHubEnabled,
};
result.localConfig.value = makeConfig({ skills: [{ type: 'skill', id: 'tone' }] });
result.actions.onOpenAddSkillModal();
const modalData = result.uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
.data as unknown as SkillModalData;
return { ...result, agent, modalData };
}
beforeEach(() => {
vi.clearAllMocks();
hasScopeSpy.mockReturnValue(false);
});
it('saves a new agent before it opens the picker', async () => {
let releasePersist: () => void = () => {};
const persisted = new Promise<void>((resolve) => {
releasePersist = resolve;
});
const { uiStore, actions } = setup({ ensureAgentPersisted: async () => await persisted });
useSettingsStore().moduleSettings.agents = {
enabled: true,
modules: [],
knowledgeBaseEnabled: false,
proxyEnabled: false,
skillsHubEnabled: true,
};
void actions.onOpenAddSkillModal();
await flushPromises();
expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY]?.open).toBeFalsy();
releasePersist();
await flushPromises();
expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY].open).toBe(true);
});
it('reports a failed agent save and keeps the picker closed', async () => {
const error = new Error('create failed');
const { uiStore, actions } = setup({
ensureAgentPersisted: async () => await Promise.reject(error),
});
useSettingsStore().moduleSettings.agents = {
enabled: true,
modules: [],
knowledgeBaseEnabled: false,
proxyEnabled: false,
skillsHubEnabled: true,
};
await actions.onOpenAddSkillModal();
expect(uiStore.modalsById[AGENT_SKILL_MODAL_KEY]?.open).toBeFalsy();
expect(showErrorSpy).toHaveBeenCalledWith(error, 'agents.builder.skills.picker.loadError');
});
it('opens the add modal with the picker data', () => {
hasScopeSpy.mockReturnValue(true);
const { modalData } = setupHub();
expect(modalData.hubSkillsEnabled).toBe(true);
expect(modalData.getAttachedSkills?.()).toEqual([{ id: 'tone', name: supportTone.name }]);
expect(modalData.canCreateInstanceSkills).toBe(true);
expect(hasScopeSpy).toHaveBeenCalledWith('skill:create');
});
it('keeps the picker off and creates the skill on the agent when the skills hub is off', async () => {
vi.mocked(createAgentSkill).mockResolvedValue({
id: 'sql',
skill: sqlHelper,
skillHash: 'hash-sql',
versionId: 'v2',
} as Awaited<ReturnType<typeof createAgentSkill>>);
const { modalData, actions, uiStore } = setupHub({ skillsHubEnabled: false });
expect(modalData.hubSkillsEnabled).toBe(false);
modalData.onConfirm({ skill: sqlHelper, availableTo: 'project' });
await flushPromises();
expect(createAgentSkill).toHaveBeenCalledWith(
expect.anything(),
'project-1',
'agent-1',
sqlHelper,
undefined,
);
expect(getHubSkill).not.toHaveBeenCalled();
actions.onOpenSkillFromList('tone');
const editData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
expect(editData.hubSkillsEnabled).toBe(false);
expect(editData.outdated).toBe(false);
});
it('attaches a hub skill as a ref through the config save, without a copy', async () => {
vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
const { modalData, scheduleConfigUpdate, agent } = setupHub();
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
await flushPromises();
expect(getHubSkill).toHaveBeenCalledWith(expect.anything(), 'sql');
expect(scheduleConfigUpdate).toHaveBeenCalledWith({
skills: [
{ type: 'skill', id: 'tone' },
{ type: 'skill', id: 'sql' },
],
});
expect(agent.value?.skills.sql).toEqual(sqlHelper);
expect(agent.value?.skillHashes?.sql).toBe('hash-sql');
expect(createAgentSkill).not.toHaveBeenCalled();
});
it('undoes an attach when the config save fails', async () => {
vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
const { modalData, scheduleConfigUpdate, agent } = setupHub({
flushConfigSave: async () => await Promise.reject(new Error('refused')),
});
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
await flushPromises();
expect(scheduleConfigUpdate).toHaveBeenLastCalledWith({
skills: [{ type: 'skill', id: 'tone' }],
});
expect(agent.value?.skills.sql).toBeUndefined();
expect(agent.value?.skillHashes?.sql).toBeUndefined();
expect(agent.value?.skills.tone).toEqual(supportTone);
});
it('keeps an attach when the config save succeeds', async () => {
vi.mocked(getHubSkill).mockResolvedValue(makeDetail('sql', sqlHelper));
const { modalData, scheduleConfigUpdate, agent } = setupHub({
flushConfigSave: async () => await Promise.resolve(undefined),
});
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
await flushPromises();
expect(scheduleConfigUpdate).toHaveBeenCalledTimes(1);
expect(agent.value?.skills.sql).toEqual(sqlHelper);
});
it('refuses to attach a skill whose name is hard to tell apart from an attached one', async () => {
vi.mocked(getHubSkill).mockResolvedValue(
makeDetail('tone-2', { ...supportTone, name: 'support_tone' }),
);
const { modalData, scheduleConfigUpdate } = setupHub();
await modalData.onAttach?.({ id: 'tone-2', name: 'support_tone' } as SkillListItem);
await flushPromises();
expect(scheduleConfigUpdate).not.toHaveBeenCalled();
expect(showMessageSpy).toHaveBeenCalledWith(
expect.objectContaining({ title: 'agents.builder.skills.duplicateName.error' }),
);
});
it('reports an attach failure and leaves the config alone', async () => {
vi.mocked(getHubSkill).mockRejectedValue(new Error('Not found'));
const { modalData, scheduleConfigUpdate } = setupHub();
await modalData.onAttach?.({ id: 'sql', name: 'SQL helper' } as SkillListItem);
await flushPromises();
expect(showErrorSpy).toHaveBeenCalledWith(
expect.any(Error),
'agents.builder.skills.attach.error',
);
expect(scheduleConfigUpdate).not.toHaveBeenCalled();
});
it.each(['project', 'agent', 'instance'] as const)(
'creates a skill for the %s through the agent endpoint',
async (availableTo) => {
vi.mocked(createAgentSkill).mockResolvedValue({
id: 'sql',
skill: sqlHelper,
skillHash: 'hash-sql',
versionId: 'v2',
});
const { modalData } = setupHub();
modalData.onConfirm({ skill: sqlHelper, availableTo });
await flushPromises();
expect(createAgentSkill).toHaveBeenCalledWith(
expect.anything(),
'project-1',
'agent-1',
sqlHelper,
availableTo,
);
},
);
it('detaches a skill by dropping its ref only', () => {
const { actions, scheduleConfigUpdate } = setupHub();
actions.onRemoveSkill('tone');
expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [] });
});
it('detaches a skill from the picker preview by dropping its ref only', () => {
const { modalData, scheduleConfigUpdate } = setupHub();
modalData.onRemove?.('tone');
expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [] });
});
it('marks a skill on an older version when the edit modal opens', () => {
const { actions, uiStore, agent } = setupHub();
agent.value = { ...agent.value, outdatedSkillIds: ['tone'] } as unknown as AgentResource;
actions.onOpenSkillFromList('tone');
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
expect(modalData.outdated).toBe(true);
});
it('unpins the skill and saves before it opens the latest version in the editor', async () => {
const latest: AgentSkill = { ...supportTone, instructions: 'Be kind and brief.' };
vi.mocked(getHubSkill).mockResolvedValue({
...makeDetail('tone', latest),
skillHash: 'hash-latest',
});
const agent = ref<AgentResource | null>({
id: 'agent-1',
skills: { tone: supportTone },
skillHashes: { tone: 'hash-tone' },
outdatedSkillIds: ['tone'],
} as unknown as AgentResource);
const events: string[] = [];
const beforeAgentMutation = vi.fn(async () => {
events.push('flush');
});
const { actions, uiStore, localConfig, scheduleConfigUpdate } = setup({
agent,
agentId: ref('agent-1'),
beforeAgentMutation,
});
scheduleConfigUpdate.mockImplementation(() => events.push('config'));
localConfig.value = makeConfig({
skills: [{ type: 'skill', id: 'tone', versionId: 'v1' }],
});
actions.onOpenSkillFromList('tone');
const outdatedData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
.data as unknown as SkillModalData;
outdatedData.onUseLatestVersion?.('tone');
await flushPromises();
expect(scheduleConfigUpdate).toHaveBeenCalledWith({ skills: [{ type: 'skill', id: 'tone' }] });
expect(events).toEqual(['config', 'flush']);
expect(agent.value?.skills.tone).toEqual(latest);
expect(agent.value?.skillHashes?.tone).toBe('hash-latest');
expect(agent.value?.outdatedSkillIds).toEqual([]);
const editData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
expect(editData.skill).toEqual(latest);
expect(editData.outdated).toBe(false);
});
function openEdit(options: { skillsHubEnabled?: boolean } = {}) {
const result = setupHub(options);
result.actions.onOpenSkillFromList('tone');
const editData = result.uiStore.modalsById[AGENT_SKILL_MODAL_KEY]
.data as unknown as SkillModalData;
return { ...result, editData };
}
it('saves the content and the target of an edited skill in one skill save', () => {
const { editData, scheduleSkillSave } = openEdit();
editData.onConfirm({ id: 'tone', skill: supportTone, availableTo: 'agent' });
expect(scheduleSkillSave).toHaveBeenCalledWith({
skillId: 'tone',
skill: supportTone,
availableTo: 'agent',
});
});
it('saves no target when the edit keeps it or the skills hub is off', () => {
const kept = openEdit();
kept.editData.onConfirm({ id: 'tone', skill: supportTone });
const hubOff = openEdit({ skillsHubEnabled: false });
hubOff.editData.onConfirm({ id: 'tone', skill: supportTone, availableTo: 'agent' });
expect(kept.scheduleSkillSave).toHaveBeenCalledWith({ skillId: 'tone', skill: supportTone });
expect(hubOff.scheduleSkillSave).toHaveBeenCalledWith({ skillId: 'tone', skill: supportTone });
});
it('tells the edit modal to check the edit permission', () => {
const { actions, uiStore } = setupHub();
actions.onOpenSkillFromList('tone');
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
expect(modalData.skillId).toBe('tone');
expect(modalData.hubSkillsEnabled).toBe(true);
});
it('never enables the hub for a host that keeps its own skill bodies', () => {
const localConfig = ref<AgentJsonConfig | null>(makeConfig());
setActivePinia(createTestingPinia({ stubActions: false }));
const uiStore = useUIStore();
const actions = useAgentCapabilitiesActions({
localConfig,
agent: ref<AgentResource | null>(null),
projectId: computed(() => 'project-1'),
agentId: computed(() => 'inline:node-1'),
connectedTriggers: ref([]),
scheduleConfigUpdate: vi.fn(),
scheduleSkillSave: vi.fn(),
localSkills: { bodies: computed(() => ({})), createSkill: vi.fn(), updateSkill: vi.fn() },
});
actions.onOpenAddSkillModal();
const modalData = uiStore.modalsById[AGENT_SKILL_MODAL_KEY].data as unknown as SkillModalData;
expect(modalData.hubSkillsEnabled).toBe(false);
});
});

View File

@ -184,7 +184,7 @@ text and `3xs` spacing. Keep warning and failure indicators distinct.
### Multi-step contract
Use the multi-step component for Channels, Sub-agents, Tools, MCP servers,
Workflows, and Vector stores.
Workflows, Vector stores, and Skills.
For tools, keep selection and configuration separate. The picker owns search,
categories, rows, and connection entry points. After selection, route every
@ -223,6 +223,40 @@ a direct pill edit. Save, Cancel, and Close exit the complete flow.
| Channel platform setup | A platform-owned setup action can stay in the inline modal content. The Agent shell still owns navigation and dismissal. |
| Memory | Do not migrate Memory into this pattern in this change. |
When the skills hub is on, `Add skill` starts with the `Skills` picker. The
picker shows only skills that the Agent can use: skills of its project, skills
of this Agent only, and global skills. Group them in the tabs All, the project
name, This agent, and Global. Do not
show a scope label or an icon in a row. Each row has a switch with the label
`Attached` or `Detached`. The switch saves at once and keeps the picker open.
This is an exception to the explicit row action. Disable a skill whose name is
hard to tell apart from an attached skill, and give the reason in its tooltip.
A row click opens a read-only preview with Back, Cancel, and `Add skill`. The
preview of an attached skill shows `Detach` instead of `Add skill`. Both
actions return to the picker. Do not edit a skill from the picker.
`Create skill` shows on every tab. It opens the upload screen, and Back returns
to the picker. The first field of the skill form is `Where can this skill be
added?`: `This project`, `This agent only`, or `Global`. Each choice has an
icon. The field starts from the tab: the project tab starts at `This project`,
the Global tab at `Global`, and the other tabs at `This agent only`. Only a
user who can create global skills sees `Global`. An existing skill that the
user can edit shows the same field. Disable it, with a tooltip that gives the
reason, when another Agent uses the skill. `Save skill` stays disabled until
the user changes the skill or its target. Do not show the field when the
skills hub is off. Then `Add skill` creates the skill on the Agent, without the picker.
Show a read-only skill as a document, not as a disabled form. Show each label
in muted text without the required mark, the description as text, and the
instructions and references as rendered markdown. Put a muted hint before Cancel that tells the
user how to edit the skill, or whom to ask. A skill that the user can use but
not edit opens this way. Hide Save. Keep `Detach` for an attached skill, and
`Add skill` for a preview of a skill that is not attached.
A skill that the draft pins to an older version also opens read-only, with
the hint that the agent uses an older version. Its primary action is
`Use latest version`. The action removes the pin, saves the draft, and opens
the latest version in the editor.
New skills start with an upload screen. Make `Upload folder` the primary
action. Support a single `SKILL.md` file and folder drops. Put `Add manually`
below the drop zone, inline with the prompt. Keep the same font and underline.

View File

@ -140,6 +140,7 @@ const emit = defineEmits<{
}
.alertIcon {
display: block;
flex-shrink: 0;
}

View File

@ -12,9 +12,11 @@ const props = withDefaults(
skill: AgentSkill;
selectedPath: string;
addReferenceDisabled?: boolean;
readOnly?: boolean;
}>(),
{
addReferenceDisabled: false,
readOnly: false,
},
);
@ -59,6 +61,7 @@ function testIdForPath(path: string) {
{{ i18n.baseText('agents.builder.skills.references.title') }}
</N8nText>
<N8nButton
v-if="!props.readOnly"
:class="$style.addReference"
variant="ghost"
size="xsmall"
@ -95,6 +98,7 @@ function testIdForPath(path: string) {
<N8nText size="small" :class="$style.path">{{ reference.path }}</N8nText>
</button>
<N8nButton
v-if="!props.readOnly"
:class="$style.removeReference"
variant="ghost"
size="xsmall"

View File

@ -1,20 +1,44 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import cloneDeep from 'lodash/cloneDeep';
import isEqual from 'lodash/isEqual';
import { computed, onMounted, ref } from 'vue';
import {
AGENT_SKILL_INSTRUCTIONS_MAX_LENGTH,
AGENT_SKILL_REFERENCE_MAX_COUNT,
skillNameKey,
type AgentSkillAvailability,
type SkillDetail,
type SkillListItem,
} from '@n8n/api-types';
import { N8nButton, N8nCallout, N8nIcon } from '@n8n/design-system';
import { useToast } from '@n8n/composables/useToast';
import {
N8nButton,
N8nCallout,
N8nIcon,
N8nInputLabel,
N8nOption,
N8nSelect,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useUIStore } from '@/app/stores/ui.store';
import ToolsConnectionModal from '@/features/shared/toolsConnection/ToolsConnectionModal.vue';
import type {
SkillConnectionItem,
ToolCategoryKey,
ToolConnectionItem,
} from '@/features/shared/toolsConnection/types';
import { getHubSkill, listAttachableSkills } from '../composables/useAgentApi';
import { useAgentTelemetry } from '../composables/useAgentTelemetry';
import type { AgentSkill } from '../types';
import { normalizeAgentSkillForSave } from '../utils/agentSkill';
import AgentSkillFileNav from './AgentSkillFileNav.vue';
import AgentSkillUpload from './AgentSkillUpload.vue';
import AgentSkillViewer from './AgentSkillViewer.vue';
import AgentModal from './modals/AgentModal.vue';
import AgentModalMultiStep from './modals/AgentModalMultiStep.vue';
const SKILL_FILE = 'SKILL.md';
@ -28,7 +52,18 @@ export type AgentSkillModalData = {
* the modal is still open — skill names must be unique per agent.
*/
existingSkillNames?: string[];
onConfirm: (payload: { id?: string; skill: AgentSkill }) => void;
hubSkillsEnabled?: boolean;
getAttachedSkills?: () => Array<{ id: string; name: string }>;
projectLabel?: string;
canCreateInstanceSkills?: boolean;
onConfirm: (payload: {
id?: string;
skill: AgentSkill;
availableTo?: AgentSkillAvailability;
}) => void;
outdated?: boolean;
onUseLatestVersion?: (id: string) => void;
onAttach?: (skill: SkillListItem) => Promise<void>;
onRemove?: (id: string) => void;
};
@ -39,38 +74,162 @@ const props = defineProps<{
const i18n = useI18n();
const uiStore = useUIStore();
const rootStore = useRootStore();
const { showError } = useToast();
const agentTelemetry = useAgentTelemetry();
const modalOpen = computed(() => uiStore.modalsById[props.modalName]?.open === true);
const existingNameKeys = computed(() => {
const names = props.data.getAttachedSkills
? props.data.getAttachedSkills().map(({ name }) => name)
: (props.data.existingSkillNames ?? []);
return new Set(names.map(skillNameKey));
});
function getDefaultSkillName(): string {
const baseName = i18n.baseText('agents.builder.skills.defaultName' as BaseTextKey);
const existingNames = new Set(
(props.data.existingSkillNames ?? []).map((name) => name.trim().toLowerCase()),
);
if (!existingNames.has(baseName.toLowerCase())) return baseName;
if (!existingNameKeys.value.has(skillNameKey(baseName))) return baseName;
let suffix = 2;
while (existingNames.has(`${baseName} ${suffix}`.toLowerCase())) suffix += 1;
while (existingNameKeys.value.has(skillNameKey(`${baseName} ${suffix}`))) suffix += 1;
return `${baseName} ${suffix}`;
}
const skill = ref<AgentSkill>(
normalizeSkill({
name: props.data.skill?.name ?? getDefaultSkillName(),
description: props.data.skill?.description ?? '',
instructions: props.data.skill?.instructions ?? '',
...(props.data.skill?.references ? { references: props.data.skill.references } : {}),
}),
);
const initialSkill = normalizeSkill({
name: props.data.skill?.name ?? getDefaultSkillName(),
description: props.data.skill?.description ?? '',
instructions: props.data.skill?.instructions ?? '',
...(props.data.skill?.references ? { references: props.data.skill.references } : {}),
});
const skill = ref<AgentSkill>(cloneDeep(initialSkill));
const submitted = ref(false);
const formIsValid = ref(false);
const selectedPath = ref(SKILL_FILE);
const step = ref<'upload' | 'manual' | 'edit'>(
props.data.skill || props.data.skillId ? 'edit' : 'upload',
const isEditing = computed(() => !!props.data.skillId);
const hasPicker = !props.data.skillId && !!props.data.hubSkillsEnabled && !!props.data.onAttach;
const step = ref<'select' | 'preview' | 'upload' | 'manual' | 'edit'>(
props.data.skill || props.data.skillId ? 'edit' : hasPicker ? 'select' : 'upload',
);
const isImporting = ref(false);
const availableTo = ref<AgentSkillAvailability>('project');
const librarySkills = ref<SkillListItem[]>([]);
const libraryLoading = ref(false);
const attachedSkills = computed(() => props.data.getAttachedSkills?.() ?? []);
const attachedSkillIds = computed(() => new Set(attachedSkills.value.map(({ id }) => id)));
const attachingSkillIds = ref(new Set<string>());
const pickerItems = computed<SkillConnectionItem[]>(() =>
librarySkills.value.map((librarySkill) => {
const attached = attachedSkillIds.value.has(librarySkill.id);
const attaching = attachingSkillIds.value.has(librarySkill.id);
const similarNameReason =
attached || attaching ? undefined : getSimilarNameReason(librarySkill.name);
return {
id: `skill:${librarySkill.id}`,
kind: 'skill',
skillId: librarySkill.id,
title: librarySkill.name,
description: librarySkill.description,
category: getPickerCategory(librarySkill),
status: 'none',
attached: attached || attaching,
attaching,
disabled: similarNameReason !== undefined,
...(similarNameReason ? { disabledReason: similarNameReason } : {}),
};
}),
);
const previewSkill = ref<SkillDetail | null>(null);
const previewPath = ref(SKILL_FILE);
const previewLoading = ref(false);
const previewIsAdded = computed(
() => !!previewSkill.value && attachedSkillIds.value.has(previewSkill.value.id),
);
const showRemove = computed(() => {
if (!props.data.onRemove) return false;
return step.value === 'preview' ? previewIsAdded.value : isEditing.value;
});
const footerPrimaryAction = computed<'add' | 'save' | 'useLatest' | undefined>(() => {
if (step.value === 'preview') return previewIsAdded.value ? undefined : 'add';
if (isOutdated.value) return props.data.onUseLatestVersion ? 'useLatest' : undefined;
return isReadOnly.value ? undefined : 'save';
});
const modalTitle = computed(() => {
if (step.value === 'select') return i18n.baseText('agents.builder.skills.title');
if (step.value === 'upload') return i18n.baseText('agents.builder.skills.add');
if (step.value === 'preview') return previewSkill.value?.name ?? '';
return skill.value.name;
});
const skillAccess = ref<SkillDetail | null>(null);
const skillAccessLoading = ref(false);
const skillAccessFailed = ref(false);
const isOutdated = computed(() => isEditing.value && !!props.data.outdated);
const isReadOnly = computed(
() =>
skillAccessLoading.value ||
skillAccessFailed.value ||
skillAccess.value?.canEdit === false ||
isOutdated.value,
);
const cannotEdit = computed(() => skillAccess.value?.canEdit === false || isOutdated.value);
const savedTarget = computed<AgentSkillAvailability | undefined>(() => {
if (!skillAccess.value) return undefined;
if (skillAccess.value.agentId) return 'agent';
return skillAccess.value.scope === 'instance' ? 'instance' : 'project';
});
const usedByOtherAgents = computed(() => {
const usage = skillAccess.value?.usedBy;
if (!usage) return false;
const isOtherAgent = ({ agentId }: { agentId: string }) => agentId !== props.data.agentId;
return usage.hiddenAgents > 0 || usage.drafts.some(isOtherAgent) || usage.pins.some(isOtherAgent);
});
const showAvailabilitySelect = computed(() => {
if (!props.data.hubSkillsEnabled || step.value === 'preview') return false;
if (!isEditing.value) return true;
return !!savedTarget.value && !cannotEdit.value;
});
const showInstanceTarget = computed(
() => !!props.data.canCreateInstanceSkills || savedTarget.value === 'instance',
);
const AVAILABILITY_ICONS = {
project: 'layers',
agent: 'bot',
instance: 'globe',
} as const satisfies Record<AgentSkillAvailability, string>;
const availabilityOptions = computed(() => [
{ value: 'project' as const, label: i18n.baseText('agents.builder.skills.availableTo.project') },
{ value: 'agent' as const, label: i18n.baseText('agents.builder.skills.availableTo.agent') },
...(showInstanceTarget.value
? [
{
value: 'instance' as const,
label: i18n.baseText('agents.builder.skills.availableTo.instance'),
},
]
: []),
]);
const removeLabel = computed(() =>
props.data.hubSkillsEnabled
? i18n.baseText('agents.builder.skills.detach')
: i18n.baseText('agents.builder.skills.remove'),
);
const footerHint = computed(() => {
const shownSkill = step.value === 'preview' ? previewSkill.value : skillAccess.value;
if (step.value === 'edit' && isOutdated.value) {
return i18n.baseText('agents.builder.skills.hint.outdated');
}
if (!shownSkill || (step.value !== 'preview' && step.value !== 'edit')) return '';
if (!shownSkill.canEdit) {
return shownSkill.scope === 'instance'
? i18n.baseText('agents.builder.skills.hint.readOnly.instance')
: i18n.baseText('agents.builder.skills.hint.readOnly.project');
}
if (step.value !== 'preview' || previewIsAdded.value) return '';
return i18n.baseText('agents.builder.skills.hint.addToEdit');
});
const isEditing = computed(() => !!props.data.skillId);
const canAddReference = computed(
() => (skill.value.references ?? []).length < AGENT_SKILL_REFERENCE_MAX_COUNT,
);
@ -93,11 +252,7 @@ const validationErrors = computed<Partial<Record<keyof AgentSkill, string>>>(()
errors.name = i18n.baseText('agents.builder.skills.validation.nameRequired');
} else if (name.length > 128) {
errors.name = i18n.baseText('agents.builder.skills.validation.nameMaxLength');
} else if (
(props.data.existingSkillNames ?? []).some(
(existingName) => existingName.trim().toLowerCase() === name.toLowerCase(),
)
) {
} else if (existingNameKeys.value.has(skillNameKey(name))) {
errors.name = i18n.baseText('agents.builder.skills.validation.nameDuplicate');
}
@ -127,7 +282,115 @@ const visibleErrors = computed(() =>
const visibleNameError = computed(() =>
submitted.value ? (validationErrors.value.name ?? '') : '',
);
const canSave = computed(() => formIsValid.value && !validationErrors.value.name);
const canSave = computed(
() => formIsValid.value && !validationErrors.value.name && !isReadOnly.value,
);
const hasChanges = computed(
() =>
!isEditing.value ||
!isEqual(skill.value, initialSkill) ||
(showAvailabilitySelect.value && availableTo.value !== savedTarget.value),
);
function getSimilarNameReason(name: string): string | undefined {
const key = skillNameKey(name);
const similarNames = attachedSkills.value
.map((attachedSkill) => attachedSkill.name)
.filter((attachedName) => skillNameKey(attachedName) === key);
if (similarNames.length === 0) return undefined;
return i18n.baseText('agents.builder.skills.picker.similarName', {
interpolate: { names: [...similarNames, name].map((value) => `"${value}"`).join(', ') },
});
}
function getPickerCategory(librarySkill: SkillListItem): 'project' | 'this-agent' | 'global' {
if (librarySkill.scope === 'instance') return 'global';
return librarySkill.agentId ? 'this-agent' : 'project';
}
async function loadLibrarySkills() {
libraryLoading.value = true;
try {
librarySkills.value = await listAttachableSkills(rootStore.restApiContext, props.data.agentId);
} catch (error) {
showError(error, i18n.baseText('agents.builder.skills.picker.loadError'));
} finally {
libraryLoading.value = false;
}
}
function targetForPickerCategory(category: ToolCategoryKey): AgentSkillAvailability {
if (category === 'project') return 'project';
if (category === 'global' && props.data.canCreateInstanceSkills) return 'instance';
return 'agent';
}
function onPickerCreate(category: ToolCategoryKey) {
availableTo.value = targetForPickerCategory(category);
step.value = 'upload';
}
async function loadSkillAccess(skillId: string) {
skillAccessLoading.value = true;
try {
skillAccess.value = await getHubSkill(rootStore.restApiContext, skillId);
availableTo.value = savedTarget.value ?? 'project';
} catch (error) {
skillAccess.value = null;
skillAccessFailed.value = true;
showError(error, i18n.baseText('agents.builder.skills.accessError'));
} finally {
skillAccessLoading.value = false;
}
}
function onPickerItemAttach(item: ToolConnectionItem) {
if (item.kind !== 'skill' || item.disabled || item.attached) return;
void attachLibrarySkill(item.skillId);
}
function onPickerItemDetach(item: ToolConnectionItem) {
if (item.kind !== 'skill' || !item.attached || item.attaching) return;
props.data.onRemove?.(item.skillId);
}
async function onPickerItemPreview(item: ToolConnectionItem) {
if (item.kind !== 'skill' || item.disabled) return;
previewLoading.value = true;
try {
previewSkill.value = await getHubSkill(rootStore.restApiContext, item.skillId);
previewPath.value = SKILL_FILE;
step.value = 'preview';
} catch (error) {
showError(error, i18n.baseText('agents.builder.skills.picker.previewError'));
} finally {
previewLoading.value = false;
}
}
async function attachLibrarySkill(skillId: string) {
const librarySkill = librarySkills.value.find((candidate) => candidate.id === skillId);
if (!librarySkill || !props.data.onAttach) return;
attachingSkillIds.value = new Set([...attachingSkillIds.value, skillId]);
try {
await props.data.onAttach(librarySkill);
} finally {
const remaining = new Set(attachingSkillIds.value);
remaining.delete(skillId);
attachingSkillIds.value = remaining;
}
}
function onPreviewAdd() {
if (!previewSkill.value) return;
void attachLibrarySkill(previewSkill.value.id);
step.value = 'select';
}
onMounted(() => {
if (hasPicker) void loadLibrarySkills();
if (props.data.skillId && props.data.hubSkillsEnabled) void loadSkillAccess(props.data.skillId);
});
function onSkillUploaded(importedSkill: AgentSkill) {
skill.value = normalizeSkill(importedSkill);
@ -137,6 +400,10 @@ function onSkillUploaded(importedSkill: AgentSkill) {
}
function onBack() {
if (step.value === 'upload' || step.value === 'preview') {
step.value = 'select';
return;
}
skill.value = { name: getDefaultSkillName(), description: '', instructions: '' };
selectedPath.value = SKILL_FILE;
submitted.value = false;
@ -145,6 +412,7 @@ function onBack() {
}
function onSkillUpdate(updates: Partial<AgentSkill>) {
if (isReadOnly.value) return;
skill.value = normalizeSkill({ ...skill.value, ...updates });
if (
selectedPath.value !== SKILL_FILE &&
@ -221,11 +489,28 @@ function onSave() {
...(skill.value.references ? { references: skill.value.references } : {}),
});
props.data.onConfirm({ id: props.data.skillId, skill: payload });
const targetChanged = !isEditing.value || availableTo.value !== savedTarget.value;
props.data.onConfirm({
id: props.data.skillId,
skill: payload,
...(showAvailabilitySelect.value && targetChanged ? { availableTo: availableTo.value } : {}),
});
closeModal();
}
function onUseLatestVersion() {
if (!props.data.skillId) return;
const skillId = props.data.skillId;
closeModal();
props.data.onUseLatestVersion?.(skillId);
}
function onRemove() {
if (step.value === 'preview') {
if (previewSkill.value) props.data.onRemove?.(previewSkill.value.id);
step.value = 'select';
return;
}
if (!props.data.skillId) return;
props.data.onRemove?.(props.data.skillId);
closeModal();
@ -233,72 +518,200 @@ function onRemove() {
</script>
<template>
<AgentModal
<AgentModalMultiStep
:open="modalOpen"
:title="step === 'upload' ? i18n.baseText('agents.builder.skills.add') : skill.name"
:step="step"
:title="modalTitle"
:title-placeholder="i18n.baseText('agents.builder.skills.name.placeholder')"
:title-max-length="128"
:title-error="visibleNameError"
:editable-title="step !== 'upload'"
:show-back="step === 'manual'"
:show-footer="step !== 'upload'"
:busy="isImporting"
:editable-title="step === 'manual' || (step === 'edit' && !isReadOnly)"
:show-back="step === 'manual' || step === 'preview' || (step === 'upload' && hasPicker)"
:show-footer="step === 'manual' || step === 'edit' || step === 'preview'"
:body-flush="step !== 'select'"
:busy="isImporting || previewLoading"
size="fit"
body-flush
data-testid="agent-skill-modal"
@update:open="!$event && closeModal()"
@update:title="onSkillUpdate({ name: $event })"
@back="onBack"
>
<N8nCallout
v-if="openedWithMissingContent"
theme="warning"
:class="$style.missingContentCallout"
data-testid="agent-skill-missing-content-callout"
>
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
</N8nCallout>
<div :class="$style.content">
<AgentSkillUpload
v-if="step === 'upload'"
v-model:busy="isImporting"
@uploaded="onSkillUploaded"
@add-manually="step = 'manual'"
@import:skill="onImportSkill"
<div v-if="hasPicker" v-show="step === 'select'" :class="$style.picker">
<ToolsConnectionModal
:open="modalOpen && step === 'select'"
:items="pickerItems"
:categories="['all', 'project', 'this-agent', 'global']"
:category-labels="data.projectLabel ? { project: data.projectLabel } : undefined"
:detail-item="null"
:search-placeholder="i18n.baseText('agents.builder.skills.picker.search.placeholder')"
:empty-message="
libraryLoading
? i18n.baseText('agents.builder.skills.picker.loading')
: i18n.baseText('agents.builder.skills.picker.empty')
"
:no-results-message="i18n.baseText('agents.builder.skills.picker.noResults')"
:create-action="{
label: i18n.baseText('agents.builder.skills.picker.create'),
description: i18n.baseText('agents.builder.skills.picker.create.description'),
testId: 'agent-skill-picker-create',
}"
embedded
show-connect-actions
persistent-scrollbar
data-testid="agent-skill-picker"
@connect="onPickerItemAttach"
@disconnect="onPickerItemDetach"
@open-detail="onPickerItemPreview"
@create="onPickerCreate"
/>
<template v-else>
<AgentSkillFileNav
:skill="skill"
:selected-path="selectedPath"
:add-reference-disabled="!canAddReference"
@add-reference="onAddReference"
@remove-reference="onRemoveReference"
@select="selectedPath = $event"
/>
<AgentSkillViewer
:skill="skill"
:selected-path="selectedPath"
:errors="visibleErrors"
:show-validation-warnings="submitted || openedWithMissingContent"
@select:path="selectedPath = $event"
@update:skill="onSkillUpdate"
@update:valid="onValidUpdate"
/>
</template>
</div>
<div
v-if="step === 'preview' && previewSkill"
:class="$style.content"
data-testid="agent-skill-preview"
>
<AgentSkillFileNav
:skill="previewSkill.skill"
:selected-path="previewPath"
read-only
@select="previewPath = $event"
/>
<AgentSkillViewer
:skill="previewSkill.skill"
:selected-path="previewPath"
read-only
@select:path="previewPath = $event"
/>
</div>
<template v-else-if="step !== 'select'">
<N8nCallout
v-if="openedWithMissingContent && !isReadOnly"
theme="warning"
:class="$style.callout"
data-testid="agent-skill-missing-content-callout"
>
{{ i18n.baseText('agents.builder.skills.missingContent.callout' as BaseTextKey) }}
</N8nCallout>
<div :class="$style.content">
<AgentSkillUpload
v-if="step === 'upload'"
v-model:busy="isImporting"
@uploaded="onSkillUploaded"
@add-manually="step = 'manual'"
@import:skill="onImportSkill"
/>
<template v-else>
<AgentSkillFileNav
:skill="skill"
:selected-path="selectedPath"
:add-reference-disabled="!canAddReference"
:read-only="isReadOnly"
@add-reference="onAddReference"
@remove-reference="onRemoveReference"
@select="selectedPath = $event"
/>
<AgentSkillViewer
:skill="skill"
:selected-path="selectedPath"
:disabled="isReadOnly"
:read-only="cannotEdit"
:errors="visibleErrors"
:show-validation-warnings="submitted || openedWithMissingContent"
@select:path="selectedPath = $event"
@update:skill="onSkillUpdate"
@update:valid="onValidUpdate"
>
<template v-if="showAvailabilitySelect" #fields-start>
<N8nInputLabel
:label="i18n.baseText('agents.builder.skills.availableTo.label')"
:tooltip-text="i18n.baseText('agents.builder.skills.availableTo.tooltip')"
show-tooltip
size="small"
>
<N8nTooltip
:content="i18n.baseText('agents.builder.skills.availableTo.locked')"
:disabled="!usedByOtherAgents"
placement="top"
>
<N8nSelect
v-model="availableTo"
:teleported="false"
:disabled="usedByOtherAgents"
data-testid="agent-skill-availability-select"
>
<template #prefix>
<N8nIcon
:icon="AVAILABILITY_ICONS[availableTo]"
:size="16"
color="text-dark"
/>
</template>
<N8nOption
v-for="option in availabilityOptions"
:key="option.value"
:value="option.value"
:label="option.label"
>
<span :class="$style.availabilityOption">
<N8nIcon :icon="AVAILABILITY_ICONS[option.value]" :size="16" />
{{ option.label }}
</span>
</N8nOption>
</N8nSelect>
</N8nTooltip>
</N8nInputLabel>
</template>
</AgentSkillViewer>
</template>
</div>
</template>
<template v-if="isEditing && data.onRemove" #footerLeft>
<template v-if="showRemove" #footerLeft>
<N8nButton variant="ghost" data-testid="agent-skill-remove" @click="onRemove">
<template #icon><N8nIcon icon="trash-2" :size="16" /></template>
{{ i18n.baseText('agents.builder.skills.remove') }}
<template #icon
><N8nIcon :icon="data.hubSkillsEnabled ? 'unlink' : 'trash-2'" :size="16"
/></template>
{{ removeLabel }}
</N8nButton>
</template>
<template #footerActions>
<N8nButton variant="solid" data-testid="agent-skill-create-save" @click="onSave">
<template v-if="footerHint" #footerBeforeCancel>
<N8nText
size="small"
color="text-light"
:class="$style.footerHint"
data-testid="agent-skill-footer-hint"
>
{{ footerHint }}
</N8nText>
</template>
<template v-if="footerPrimaryAction" #footerActions>
<N8nButton
v-if="footerPrimaryAction === 'add'"
variant="solid"
data-testid="agent-skill-preview-add"
@click="onPreviewAdd"
>
{{ i18n.baseText('agents.builder.skills.picker.add') }}
</N8nButton>
<N8nButton
v-else-if="footerPrimaryAction === 'useLatest'"
variant="solid"
data-testid="agent-skill-use-latest"
@click="onUseLatestVersion"
>
{{ i18n.baseText('agents.builder.skills.useLatest') }}
</N8nButton>
<N8nButton
v-else
variant="solid"
:disabled="!hasChanges"
data-testid="agent-skill-create-save"
@click="onSave"
>
{{ i18n.baseText('agents.builder.skills.save') }}
</N8nButton>
</template>
</AgentModal>
</AgentModalMultiStep>
</template>
<style module>
@ -312,10 +725,25 @@ function onRemove() {
display: flex;
}
.missingContentCallout {
.picker {
width: min(calc(52rem - var(--spacing--lg) * 2), calc(100dvw - var(--spacing--lg) * 5));
height: 100%;
}
.callout {
margin: var(--spacing--md) var(--spacing--lg) 0;
}
.footerHint {
text-align: right;
}
.availabilityOption {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
}
@media (max-width: 480px) {
.content {
flex-direction: column;

View File

@ -6,7 +6,13 @@ import {
AGENT_SKILL_REFERENCE_MAX_COUNT,
AGENT_SKILL_REFERENCES_TOTAL_MAX_LENGTH,
} from '@n8n/api-types';
import { N8nFormInput, N8nInputLabel, N8nMarkdownEditor, N8nText } from '@n8n/design-system';
import {
N8nFormInput,
N8nInputLabel,
N8nMarkdown,
N8nMarkdownEditor,
N8nText,
} from '@n8n/design-system';
import type { IValidator, Validatable } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
@ -14,17 +20,22 @@ import type { Rule, RuleGroup } from '@/Interface';
import type { AgentSkill, AgentSkillReference } from '../types';
const SKILL_FILE = 'SKILL.md';
const READ_ONLY_MARKDOWN_OPTIONS = {
tasklists: { enabled: false, label: true, labelAfter: false },
};
const props = withDefaults(
defineProps<{
skill: AgentSkill;
disabled?: boolean;
readOnly?: boolean;
errors?: Partial<Record<keyof AgentSkill, string>>;
selectedPath?: string;
showValidationWarnings?: boolean;
}>(),
{
disabled: false,
readOnly: false,
selectedPath: SKILL_FILE,
showValidationWarnings: false,
},
@ -257,7 +268,54 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
<template>
<div :class="$style.panel" data-testid="agent-skill-viewer">
<template v-if="isSkillFileSelected">
<template v-if="props.readOnly">
<template v-if="isSkillFileSelected">
<N8nInputLabel
:class="$style.readOnlyField"
:label="i18n.baseText('agents.builder.skills.description.label')"
size="medium"
color="text-light"
>
<N8nText
tag="p"
color="text-base"
:class="$style.readOnlyText"
data-testid="agent-skill-description-text"
>{{ props.skill.description }}</N8nText
>
</N8nInputLabel>
<N8nInputLabel
:class="$style.readOnlyField"
:label="i18n.baseText('agents.builder.skills.instructions.label')"
size="medium"
color="text-light"
>
<N8nMarkdown
:class="$style.readOnlyMarkdown"
:options="READ_ONLY_MARKDOWN_OPTIONS"
:content="props.skill.instructions"
data-testid="agent-skill-instructions-text"
/>
</N8nInputLabel>
</template>
<N8nInputLabel
v-else-if="selectedReference"
:class="$style.readOnlyField"
:label="selectedReference.path"
size="medium"
color="text-light"
>
<N8nMarkdown
:class="$style.readOnlyMarkdown"
:options="READ_ONLY_MARKDOWN_OPTIONS"
:content="selectedReference.content"
data-testid="agent-skill-reference-text"
/>
</N8nInputLabel>
</template>
<template v-else-if="isSkillFileSelected">
<slot name="fields-start" />
<N8nText v-if="referencesError" size="small" color="danger">{{ referencesError }}</N8nText>
<N8nText v-if="props.errors?.references && !referencesError" size="small" color="danger">{{
props.errors.references
@ -374,6 +432,46 @@ watch(formIsValid, (valid) => emit('update:valid', valid), { immediate: true });
box-sizing: border-box;
}
.readOnlyField {
display: flex;
flex-direction: column;
gap: var(--spacing--2xs);
}
.readOnlyText {
margin: 0;
line-height: var(--line-height--xl);
white-space: pre-wrap;
}
.panel .readOnlyMarkdown {
:global(p),
:global(li) {
font-size: var(--font-size--sm);
line-height: var(--line-height--xl);
}
:global(h1),
:global(h2) {
margin: var(--spacing--sm) 0 var(--spacing--2xs);
font-size: var(--font-size--md);
line-height: var(--line-height--lg);
}
:global(h3),
:global(h4),
:global(h5),
:global(h6) {
margin: var(--spacing--xs) 0 var(--spacing--3xs);
font-size: var(--font-size--sm);
line-height: var(--line-height--lg);
}
:global(:is(h1, h2, h3, h4, h5, h6):first-child) {
margin-top: 0;
}
}
.field {
display: flex;
flex-direction: column;

View File

@ -1,6 +1,5 @@
<script setup lang="ts">
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { computed } from 'vue';
@ -40,23 +39,6 @@ const skillIssueMessages = computed(() =>
);
const outdatedSkillIds = computed(() => new Set(props.outdatedSkillIds));
const outdatedWarning = computed(() => [i18n.baseText('agents.builder.skills.outdated.warning')]);
const toast = useToast();
/**
* An outdated skill cannot be edited: the hub refuses a save based on an older version.
* Tell the user to remove the skill and add it again instead of opening the editor.
*/
function onOpenSkill(id: string) {
if (outdatedSkillIds.value.has(id)) {
toast.showMessage({
type: 'warning',
title: i18n.baseText('agents.builder.skills.outdated.title'),
message: i18n.baseText('agents.builder.skills.outdated.warning'),
});
return;
}
emit('open-skill', id);
}
</script>
<template>
@ -80,7 +62,7 @@ function onOpenSkill(id: string) {
:disabled="props.disabled"
:class="$style.skillChip"
data-testid="agent-capabilities-skill-row"
@click="onOpenSkill(id)"
@click="emit('open-skill', id)"
>
{{ skill.name || id }}
</AgentChipButton>

View File

@ -16,6 +16,7 @@ const props = withDefaults(
showBack?: boolean;
showFooter?: boolean;
showCancel?: boolean;
bodyFlush?: boolean;
stableHeight?: boolean;
busy?: boolean;
size?: DialogSize;
@ -31,6 +32,7 @@ const props = withDefaults(
showBack: false,
showFooter: undefined,
showCancel: true,
bodyFlush: false,
stableHeight: false,
busy: false,
size: '2xlarge',
@ -71,6 +73,7 @@ watch(
:show-footer="showFooter"
:show-cancel="showCancel"
:body-scrollable="!isPickerStep"
:body-flush="bodyFlush"
:stable-height="stableHeight"
:busy="busy"
:size="size"

View File

@ -24,6 +24,7 @@ import type {
AgentN8nChatThreadSummary,
AgentN8nChatThreadsResponse,
AgentSkill,
AgentSkillAvailability,
AgentsSettingsDto,
AgentSkillMutationResponse,
AgentTaskConfig,
@ -33,6 +34,9 @@ import type {
AgentProviderModelsResponse,
AgentVersionListItemDto,
ChatIntegrationDescriptor,
SkillDetail,
SkillListItem,
SkillListResponse,
VectorStoreTestResult,
} from '@n8n/api-types';
import { getFullApiResponse, makeRestApiRequest, request } from '@n8n/rest-api-client';
@ -703,12 +707,13 @@ export const createAgentSkill = async (
projectId: string,
agentId: string,
skill: AgentSkill,
availableTo?: AgentSkillAvailability,
): Promise<AgentSkillMutationResponse> => {
return await makeRestApiRequest<AgentSkillMutationResponse>(
context,
'POST',
`/projects/${projectId}/agents/v2/${agentId}/skills`,
skill,
availableTo ? { ...skill, availableTo } : skill,
);
};
@ -719,15 +724,37 @@ export const updateAgentSkill = async (
skillId: string,
updates: Partial<AgentSkill>,
baseSkillHash?: string,
availableTo?: AgentSkillAvailability,
): Promise<AgentSkillMutationResponse> => {
return await makeRestApiRequest<AgentSkillMutationResponse>(
context,
'PATCH',
`/projects/${projectId}/agents/v2/${agentId}/skills/${skillId}`,
{ ...updates, baseSkillHash },
{ ...updates, baseSkillHash, ...(availableTo ? { availableTo } : {}) },
);
};
export const listAttachableSkills = async (
context: IRestApiContext,
agentId: string,
): Promise<SkillListItem[]> => {
const { data } = await makeRestApiRequest<SkillListResponse>(context, 'GET', '/skills', {
attachableTo: agentId,
});
const targetRank = (skill: SkillListItem) =>
skill.scope === 'instance' ? 2 : skill.agentId ? 1 : 0;
const byTargetThenName = (left: SkillListItem, right: SkillListItem) =>
targetRank(left) - targetRank(right) || left.name.localeCompare(right.name);
return data.sort(byTargetThenName);
};
export const getHubSkill = async (
context: IRestApiContext,
skillId: string,
): Promise<SkillDetail> => {
return await makeRestApiRequest<SkillDetail>(context, 'GET', `/skills/${skillId}`);
};
export const getAgentBackgroundJobs = async (
context: IRestApiContext,
projectId: string,

View File

@ -1,3 +1,5 @@
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { computed, ref } from 'vue';
@ -69,6 +71,7 @@ function makeActions(
describe('useAgentCapabilitiesActions', () => {
beforeEach(() => {
vi.clearAllMocks();
setActivePinia(createTestingPinia());
});
it('forwards the selected picker mode to the tools modal', () => {

View File

@ -2,11 +2,20 @@ import { computed, type ComputedRef, type Ref } from 'vue';
import { useI18n } from '@n8n/i18n';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useToast } from '@n8n/composables/useToast';
import type { AgentConfigValidationIssue } from '@n8n/api-types';
import { useRBACStore } from '@n8n/stores/rbac.store';
import { useSettingsStore } from '@n8n/stores/settings.store';
import {
skillNameKey,
type AgentConfigValidationIssue,
type AgentSkillAvailability,
type SkillDetail,
type SkillListItem,
} from '@n8n/api-types';
import { useUIStore } from '@/app/stores/ui.store';
import { useNodeTypesStore } from '@/app/stores/nodeTypes.store';
import { AI_MCP_TOOL_NODE_TYPE } from '@/app/constants/nodeTypes';
import { createAgentSkill } from './useAgentApi';
import { createAgentSkill, getHubSkill } from './useAgentApi';
import { useAgentProjectBreadcrumb } from './useAgentProjectBreadcrumb';
import { mcpServerToNode } from './useMcpServerAdapter';
import {
AGENT_TOOLS_MODAL_KEY,
@ -57,7 +66,11 @@ export interface UseAgentCapabilitiesActionsDeps {
* `onOpenAddSkillModal` via the shared `createAgentSkill` API call.
* Unused when `localSkills` is provided.
*/
scheduleSkillSave: (payload: { skillId: string; skill: AgentSkill }) => void;
scheduleSkillSave: (payload: {
skillId: string;
skill: AgentSkill;
availableTo?: AgentSkillAvailability;
}) => void;
/**
* Skill store for hosts whose bodies live outside an agent entity — inline
* agents keep them in the node parameter. `bodies` is where the handlers
@ -83,6 +96,7 @@ export interface UseAgentCapabilitiesActionsDeps {
* Hosts whose agent always exists omit it.
*/
ensureAgentPersisted?: () => Promise<void>;
flushConfigSave?: () => Promise<unknown>;
/**
* Flushes pending agent edits before an API mutation that also writes the
* agent config. This prevents the mutation from advancing the config hash
@ -117,6 +131,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
localSkills,
supportsToolApproval,
ensureAgentPersisted,
flushConfigSave,
beforeAgentMutation,
refreshAgentAfterMutation,
validationIssues,
@ -127,7 +142,14 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
const rootStore = useRootStore();
const uiStore = useUIStore();
const nodeTypesStore = useNodeTypesStore();
const rbacStore = useRBACStore();
const settingsStore = useSettingsStore();
const { showError, showMessage } = useToast();
const { projectName } = useAgentProjectBreadcrumb(computed(() => projectId.value));
const hubSkillsEnabled = computed(
() => !localSkills && settingsStore.moduleSettings.agents?.skillsHubEnabled === true,
);
function onOpenAddToolModal(mode: ToolPickerMode = 'tools') {
// Capture the target at open time: a confirm landing after an agent/node
@ -327,11 +349,26 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
skill,
skillId: id,
existingSkillNames: appliedSkillNames(id),
hubSkillsEnabled: hubSkillsEnabled.value,
outdated: hubSkillsEnabled.value && (agent.value?.outdatedSkillIds ?? []).includes(id),
canCreateInstanceSkills: rbacStore.hasScope('skill:create'),
onUseLatestVersion: (skillId: string) => {
if (agentId.value !== targetAgentId) return;
void useLatestSkillVersion(skillId);
},
onRemove: (skillId: string) => {
if (agentId.value !== targetAgentId) return;
onRemoveSkill(skillId);
},
onConfirm: ({ id: skillId, skill: updatedSkill }: { id?: string; skill: AgentSkill }) => {
onConfirm: ({
id: skillId,
skill: updatedSkill,
availableTo,
}: {
id?: string;
skill: AgentSkill;
availableTo?: AgentSkillAvailability;
}) => {
if (!skillId) return;
if (agentId.value !== targetAgentId) return;
const sanitizedSkill = normalizeAgentSkillForSave(updatedSkill);
@ -353,7 +390,11 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
[skillId]: sanitizedSkill,
},
};
scheduleSkillSave({ skillId, skill: sanitizedSkill });
scheduleSkillSave({
skillId,
skill: sanitizedSkill,
...(availableTo && hubSkillsEnabled.value ? { availableTo } : {}),
});
},
},
});
@ -369,10 +410,8 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
* local-skill hosts. The modal validates against `existingSkillNames` before closing
*/
function hasDuplicateSkillName(name: string, excludeId?: string): boolean {
const normalized = name.trim().toLowerCase();
return appliedSkillNames(excludeId).some(
(existing) => existing.trim().toLowerCase() === normalized,
);
const key = skillNameKey(name);
return appliedSkillNames(excludeId).some((existing) => skillNameKey(existing) === key);
}
function showDuplicateSkillNameError(name: string) {
@ -411,7 +450,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
scheduleConfigUpdate({ tasks: nextTasks });
}
function onOpenAddSkillModal() {
async function onOpenAddSkillModal() {
telemetry?.trackOpenedAddSkillModal?.();
// Capture the target at open time: the modal can outlive an agent/node
@ -423,13 +462,44 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
const isCurrentTarget = () =>
projectId.value === targetProjectId && agentId.value === targetAgentId;
if (hubSkillsEnabled.value && ensureAgentPersisted) {
try {
await ensureAgentPersisted();
} catch (error) {
showError(error, locale.baseText('agents.builder.skills.picker.loadError'));
return;
}
if (!isCurrentTarget()) return;
}
uiStore.openModalWithData({
name: AGENT_SKILL_MODAL_KEY,
data: {
projectId: targetProjectId,
agentId: targetAgentId,
existingSkillNames: appliedSkillNames(),
onConfirm: ({ skill }: { id?: string; skill: AgentSkill }) => {
hubSkillsEnabled: hubSkillsEnabled.value,
getAttachedSkills: () =>
appliedSkills.value.map(({ id, skill }) => ({ id, name: skill.name })),
projectLabel: projectName.value ?? undefined,
canCreateInstanceSkills: rbacStore.hasScope('skill:create'),
onRemove: (skillId: string) => {
if (!isCurrentTarget()) return;
onRemoveSkill(skillId);
},
onAttach: async (librarySkill: SkillListItem) =>
await attachHubSkill(
isCurrentTarget,
async () => await getHubSkill(rootStore.restApiContext, librarySkill.id),
),
onConfirm: ({
skill,
availableTo,
}: {
id?: string;
skill: AgentSkill;
availableTo?: AgentSkillAvailability;
}) => {
if (localSkills) {
if (!isCurrentTarget()) return;
const sanitizedSkill = normalizeAgentSkillForSave(skill);
@ -465,6 +535,7 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
targetProjectId,
targetAgentId,
sanitizedSkill,
hubSkillsEnabled.value ? availableTo : undefined,
);
skillId = result.id;
created = result.skill;
@ -510,6 +581,86 @@ export function useAgentCapabilitiesActions(deps: UseAgentCapabilitiesActionsDep
});
}
async function attachHubSkill(
isCurrentTarget: () => boolean,
loadSkill: () => Promise<SkillDetail>,
) {
let hubSkill: SkillDetail;
try {
hubSkill = await loadSkill();
} catch (error) {
showError(error, locale.baseText('agents.builder.skills.attach.error'));
return;
}
if (!isCurrentTarget() || !agent.value || !localConfig.value) return;
if (hasDuplicateSkillName(hubSkill.skill.name)) {
showDuplicateSkillNameError(hubSkill.skill.name);
return;
}
agent.value = {
...agent.value,
skillHashes: { ...(agent.value.skillHashes ?? {}), [hubSkill.id]: hubSkill.skillHash },
skills: { ...(agent.value.skills ?? {}), [hubSkill.id]: hubSkill.skill },
};
scheduleConfigUpdate({
skills: [
...(localConfig.value.skills ?? []).filter((ref) => ref.id !== hubSkill.id),
{ type: 'skill', id: hubSkill.id },
],
});
if (!flushConfigSave) return;
try {
await flushConfigSave();
} catch {
if (isCurrentTarget()) undoRefusedAttach(hubSkill.id);
}
}
function undoRefusedAttach(skillId: string) {
if (!agent.value || !localConfig.value) return;
const withoutSkill = <T>(byId: Record<string, T> | undefined) =>
Object.fromEntries(Object.entries(byId ?? {}).filter(([id]) => id !== skillId));
agent.value = {
...agent.value,
skills: withoutSkill(agent.value.skills),
skillHashes: withoutSkill(agent.value.skillHashes),
};
scheduleConfigUpdate({
skills: (localConfig.value.skills ?? []).filter((ref) => ref.id !== skillId),
});
}
async function useLatestSkillVersion(skillId: string) {
const targetAgentId = agentId.value;
let hubSkill: SkillDetail;
try {
hubSkill = await getHubSkill(rootStore.restApiContext, skillId);
} catch (error) {
showError(error, locale.baseText('agents.builder.skills.useLatest.error'));
return;
}
if (agentId.value !== targetAgentId || agent.value?.id !== targetAgentId) return;
if (!localConfig.value) return;
agent.value = {
...agent.value,
skills: { ...(agent.value.skills ?? {}), [skillId]: hubSkill.skill },
skillHashes: { ...(agent.value.skillHashes ?? {}), [skillId]: hubSkill.skillHash },
outdatedSkillIds: (agent.value.outdatedSkillIds ?? []).filter((id) => id !== skillId),
};
scheduleConfigUpdate({
skills: (localConfig.value.skills ?? []).map((ref) =>
ref.id === skillId ? { type: 'skill', id: skillId } : ref,
),
});
try {
await beforeAgentMutation?.();
} catch {
return;
}
if (agentId.value !== targetAgentId) return;
onOpenSkillFromList(skillId);
}
function onConnectedTriggersUpdate(triggers: string[]) {
connectedTriggers.value = triggers;
}

View File

@ -27,6 +27,7 @@ import {
MAX_AGENT_KNOWLEDGE_BASE_SIZE_GB,
addMissingAgentPersonalisation,
type AgentFileDto,
type AgentSkillAvailability,
type InstanceAiHandoffContext,
type PushMessage,
type PushPayload,
@ -1324,6 +1325,7 @@ interface SkillAutosaveSnapshot {
skillId: string;
skill: AgentSkill;
baseSkillHash: string | undefined;
availableTo?: AgentSkillAvailability;
}
interface McpAvailabilitySnapshot {
@ -1556,6 +1558,7 @@ async function saveSkill(snapshot: SkillAutosaveSnapshot): Promise<AutosaveResul
snapshot.skillId,
snapshot.skill,
snapshot.baseSkillHash,
snapshot.availableTo,
);
} catch (error) {
if (error instanceof ResponseError && error.httpStatusCode === 409) {
@ -1981,11 +1984,12 @@ const caps = useAgentCapabilitiesActions({
agentId,
connectedTriggers,
ensureAgentPersisted,
flushConfigSave: flushConfigAutosave,
beforeAgentMutation: flushAutosaveIgnoringResult,
refreshAgentAfterMutation: onConfigUpdated,
validationIssues: computed(() => configValidation.value?.issues ?? []),
scheduleConfigUpdate: onConfigFieldUpdate,
scheduleSkillSave: ({ skillId, skill }) => {
scheduleSkillSave: ({ skillId, skill, availableTo }) => {
// Acquire the write lock before persisting any change.
agentCollaborationStore.requestWriteAccess();
// The persisted validation result no longer reflects the working copy —
@ -1998,6 +2002,7 @@ const caps = useAgentCapabilitiesActions({
skillId,
skill,
baseSkillHash: agent.value?.skillHashes?.[skillId],
...(availableTo ? { availableTo } : {}),
});
},
telemetry: {

View File

@ -1,6 +1,14 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue';
import { N8nBadge, N8nButton, N8nIcon, N8nSpinner, N8nText, N8nTooltip } from '@n8n/design-system';
import {
N8nBadge,
N8nButton,
N8nIcon,
N8nSpinner,
N8nSwitch2,
N8nText,
N8nTooltip,
} from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { RestrictedNodePopover } from '@n8n/frontend-module-type-availability-policies';
import ToolCredentialPicker from './ToolCredentialPicker.vue';
@ -24,6 +32,7 @@ const props = defineProps<{
const emit = defineEmits<{
'open-detail': [item: ToolConnectionItem];
connect: [item: ToolConnectionItem];
disconnect: [item: ToolConnectionItem];
'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string];
'credential-dropdown-open': [item: ToolConnectionItem];
'first-credential-connect': [item: ToolConnectionItem];
@ -183,7 +192,11 @@ function handleConnect() {
</template>
<template v-else>
<ToolIcon :source="resolvedIcon" :fallback-icon="placeholderIcon" />
<ToolIcon
v-if="item.kind !== 'skill'"
:source="resolvedIcon"
:fallback-icon="placeholderIcon"
/>
<span :class="$style.text">
<span :class="$style.titleRow">
<N8nText :class="$style.title" tag="span" bold>{{ item.title }}</N8nText>
@ -230,12 +243,7 @@ function handleConnect() {
:anchor="rowRef"
:side-offset="48"
/>
<N8nTooltip
v-else-if="isDisabled"
:content="item.disabledReason ?? ''"
:disabled="!item.disabledReason"
placement="top"
>
<N8nTooltip v-else-if="isDisabled" :disabled="!item.disabledReason" placement="top">
<span
:class="$style.disabledMarker"
role="img"
@ -245,7 +253,20 @@ function handleConnect() {
>
<N8nIcon icon="info" :size="14" color="text-light" />
</span>
<template #content>{{ item.disabledReason }}</template>
</N8nTooltip>
<N8nSwitch2
v-else-if="item.kind === 'skill'"
:model-value="item.attached"
:disabled="item.attaching"
:label="
i18n.baseText(
item.attached ? 'tools.connection.action.attached' : 'tools.connection.action.detached',
)
"
data-test-id="tools-connection-row-attach"
@update:model-value="$event ? emit('connect', item) : emit('disconnect', item)"
/>
<N8nButton
v-else-if="item.kind === 'service'"
variant="outline"

View File

@ -28,7 +28,7 @@ import {
} from './types';
type PickerCreateAction = {
category: ToolCategoryKey;
category?: ToolCategoryKey;
label: string;
description?: string;
testId?: string;
@ -62,6 +62,7 @@ const props = withDefaults(
connectLabel?: (item: ToolConnectionItem) => string;
connectAriaLabel?: (item: ToolConnectionItem) => string;
connectedLabel?: (item: ToolConnectionItem) => string;
categoryLabels?: Partial<Record<ToolCategoryKey, string>>;
}>(),
{
open: false,
@ -92,7 +93,7 @@ const emit = defineEmits<{
'new-credential-connect': [item: ToolConnectionItem];
'open-detail': [item: ToolConnectionItem];
connect: [item: ToolConnectionItem];
create: [];
create: [category: ToolCategoryKey];
}>();
const i18n = useI18n();
@ -278,11 +279,14 @@ const CATEGORY_I18N: Record<ToolCategoryKey, BaseTextKey> = {
community: 'tools.connection.categories.community',
workflows: 'tools.connection.categories.workflows',
agents: 'tools.connection.categories.agents',
project: 'tools.connection.categories.project',
'this-agent': 'tools.connection.categories.thisAgent',
global: 'tools.connection.categories.global',
data: 'tools.connection.categories.data',
};
function categoryLabel(category: ToolCategoryKey): string {
return i18n.baseText(CATEGORY_I18N[category]);
return props.categoryLabels?.[category] ?? i18n.baseText(CATEGORY_I18N[category]);
}
const groupedSearchRows = computed<ListRow[]>(() => {
@ -336,7 +340,12 @@ const resolvedEmptyMessage = computed(() => {
}
return props.emptyMessage ?? i18n.baseText('tools.connection.empty.title');
});
const showCreateAction = computed(() => props.createAction?.category === activeCategory.value);
const showCreateAction = computed(
() =>
props.createAction !== undefined &&
(props.createAction.category === undefined ||
props.createAction.category === activeCategory.value),
);
function openDetail(item: ToolConnectionItem) {
emit('open-detail', item);
@ -434,7 +443,7 @@ function handleOpenChange(value: boolean) {
:disabled="createActionLoading"
:aria-busy="createActionLoading"
:data-test-id="createAction.testId ?? 'tools-connection-create'"
@click="emit('create')"
@click="emit('create', activeCategory)"
>
<span :class="$style.createIcon" aria-hidden="true">
<N8nIcon
@ -489,6 +498,7 @@ function handleOpenChange(value: boolean) {
:connected-label="props.connectedLabel?.(row.item)"
@open-detail="openDetail($event)"
@connect="emit('connect', $event)"
@disconnect="emit('disconnect', $event)"
@select-credential="
(item, authType, credentialId) =>
emit('select-credential', item, authType, credentialId)
@ -554,6 +564,7 @@ function handleOpenChange(value: boolean) {
gap: var(--spacing--xs);
width: 100%;
min-height: 58px;
margin-top: var(--spacing--sm);
padding: var(--spacing--2xs);
border: 0;
border-radius: var(--radius--2xs);

View File

@ -13,6 +13,7 @@ import {
type McpServerConnectionItem,
type NodeConnectionItem,
type ServiceConnectionItem,
type SkillConnectionItem,
type WorkflowConnectionItem,
} from '../types';
@ -33,6 +34,7 @@ function render(
| McpServerConnectionItem
| NodeConnectionItem
| ServiceConnectionItem
| SkillConnectionItem
| WorkflowConnectionItem,
) {
return renderRow({ props: { item }, pinia: createTestingPinia() });
@ -99,6 +101,28 @@ const baseService: ServiceConnectionItem = {
};
describe('ToolRow', () => {
it('switches a detached skill on and an attached skill off', async () => {
const skill: SkillConnectionItem = {
id: 'skill:tone',
kind: 'skill',
skillId: 'tone',
title: 'Support tone',
status: 'none',
attached: false,
};
const detached = render(skill);
expect(detached.getByText('Detached')).toBeInTheDocument();
await userEvent.click(detached.getByTestId('tools-connection-row-attach'));
expect(detached.emitted('connect')).toEqual([[skill]]);
detached.unmount();
const attachedSkill = { ...skill, attached: true };
const attached = render(attachedSkill);
expect(attached.getByText('Attached')).toBeInTheDocument();
await userEvent.click(attached.getByTestId('tools-connection-row-attach'));
expect(attached.emitted('disconnect')).toEqual([[attachedSkill]]);
});
it('shows a Connect button for an available mcp-server and emits connect on click', async () => {
const { getByTestId, emitted } = render(baseMcp);
@ -375,6 +399,28 @@ describe('ToolRow', () => {
disabledReason: "Contains nodes that aren't supported as agent tools (Wait, Form)",
};
it('shows the disabled reason as plain text', () => {
const { getByTestId } = renderRow({
props: {
item: { ...disabledWorkflow, disabledReason: 'Rename <img src=x>_tag first' },
},
pinia: createTestingPinia(),
global: {
stubs: {
N8nTooltip: {
props: ['content'],
template:
'<span><slot /><span data-test-id="stub-tooltip-content"><span v-html="content" /><slot name="content" /></span></span>',
},
},
},
});
const content = getByTestId('stub-tooltip-content');
expect(content.querySelector('img')).toBeNull();
expect(content).toHaveTextContent('Rename <img src=x>_tag first');
});
it('renders a disabled marker instead of a connect/install action', () => {
const { getByTestId, queryByTestId } = render(disabledWorkflow);

View File

@ -94,7 +94,7 @@ function renderWith(
showSuggestionFooter: boolean;
groupSearchResults: boolean;
createAction: {
category: ToolCategoryKey;
category?: ToolCategoryKey;
label: string;
testId?: string;
};
@ -342,7 +342,20 @@ describe('ToolsConnectionModal', () => {
expect(getByTestId('tools-connection-empty')).toBeTruthy();
await fireEvent.click(getByTestId('tools-connection-create-workflow'));
expect(emitted().create).toEqual([[]]);
expect(emitted().create).toEqual([['workflows']]);
});
it('offers the create action on every tab when it has no category', async () => {
const { getByTestId } = renderWith({
categories: ['all', 'mcp', 'workflows'],
createAction: { label: 'Create', testId: 'tools-connection-create-any' },
});
expect(getByTestId('tools-connection-create-any')).toBeTruthy();
await fireEvent.click(getByTestId('tab-mcp'));
expect(getByTestId('tools-connection-create-any')).toBeTruthy();
await fireEvent.click(getByTestId('tab-workflows'));
expect(getByTestId('tools-connection-create-any')).toBeTruthy();
});
it('renders the detail view when a detailItem is set', () => {

View File

@ -11,7 +11,8 @@ export type ConnectionItemKind =
| 'mcp-server'
| 'agent'
| 'data-store'
| 'service';
| 'service'
| 'skill';
export type ToolIconSource =
| { type: 'file'; src: string }
@ -137,13 +138,21 @@ export interface ServiceConnectionItem extends BaseConnectionItem {
serviceId: string;
}
export interface SkillConnectionItem extends BaseConnectionItem {
kind: 'skill';
skillId: string;
attached: boolean;
attaching?: boolean;
}
export type ToolConnectionItem =
| NodeConnectionItem
| WorkflowConnectionItem
| McpServerConnectionItem
| AgentConnectionItem
| DataStoreConnectionItem
| ServiceConnectionItem;
| ServiceConnectionItem
| SkillConnectionItem;
/**
* One tab in the modal. Consumers declare the subset they support. `data` has
@ -161,6 +170,9 @@ export type ToolCategoryKey =
| 'community'
| 'workflows'
| 'agents'
| 'project'
| 'this-agent'
| 'global'
| 'data';
/** Used when an item carries no explicit `category`. */
@ -171,6 +183,7 @@ export const CATEGORY_BY_KIND: Record<ConnectionItemKind, ToolCategoryKey> = {
service: 'built-in',
agent: 'agents',
'data-store': 'data',
skill: 'project',
};
/**