From b2b2a8641a3b75479879b02aed363104823dfd44 Mon Sep 17 00:00:00 2001 From: Svetoslav Dekov Date: Thu, 15 Oct 2026 14:14:18 +0000 Subject: [PATCH] feat(editor): Attach skills hub skills from a picker in the agent builder (no-changelog) (#41104) Co-authored-by: Claude Opus 5.5 --- .../src/agents/__tests__/skill-names.test.ts | 17 + packages/@n8n/api-types/src/agents/index.ts | 1 + .../@n8n/api-types/src/agents/skill-names.ts | 11 + .../agents/__tests__/skill-names.test.ts | 17 - .../src/modules/agents/skills/skill-names.ts | 12 +- .../frontend/@n8n/i18n/src/locales/en.json | 37 +- .../agents/__tests__/AgentBuilderView.test.ts | 2 + .../agents/__tests__/AgentSkillModal.test.ts | 652 +++++++++++++++++- .../agents/__tests__/AgentSkillViewer.test.ts | 55 ++ .../__tests__/AgentSkillsSection.test.ts | 12 +- .../agents/__tests__/useAgentApi.test.ts | 78 +++ .../useAgentCapabilitiesActions.test.ts | 414 ++++++++++- .../features/agents/agents-design-language.md | 36 +- .../agents/components/AgentChipButton.vue | 1 + .../agents/components/AgentSkillFileNav.vue | 4 + .../agents/components/AgentSkillModal.vue | 578 ++++++++++++++-- .../agents/components/AgentSkillViewer.vue | 102 ++- .../agents/components/AgentSkillsSection.vue | 20 +- .../components/modals/AgentModalMultiStep.vue | 3 + .../agents/composables/useAgentApi.ts | 31 +- .../useAgentCapabilitiesActions.test.ts | 3 + .../useAgentCapabilitiesActions.ts | 173 ++++- .../agents/views/AgentBuilderView.vue | 7 +- .../shared/toolsConnection/ToolRow.vue | 37 +- .../toolsConnection/ToolsConnectionModal.vue | 21 +- .../toolsConnection/__tests__/ToolRow.test.ts | 46 ++ .../__tests__/ToolsConnectionModal.test.ts | 17 +- .../features/shared/toolsConnection/types.ts | 17 +- 28 files changed, 2224 insertions(+), 180 deletions(-) create mode 100644 packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts create mode 100644 packages/@n8n/api-types/src/agents/skill-names.ts diff --git a/packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts b/packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts new file mode 100644 index 00000000000..02a2c0d42db --- /dev/null +++ b/packages/@n8n/api-types/src/agents/__tests__/skill-names.test.ts @@ -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')); + }); +}); diff --git a/packages/@n8n/api-types/src/agents/index.ts b/packages/@n8n/api-types/src/agents/index.ts index 08cabe0d9eb..f5e86f490bd 100644 --- a/packages/@n8n/api-types/src/agents/index.ts +++ b/packages/@n8n/api-types/src/agents/index.ts @@ -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'; diff --git a/packages/@n8n/api-types/src/agents/skill-names.ts b/packages/@n8n/api-types/src/agents/skill-names.ts new file mode 100644 index 00000000000..a297813ebb8 --- /dev/null +++ b/packages/@n8n/api-types/src/agents/skill-names.ts @@ -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(); +} diff --git a/packages/cli/src/modules/agents/__tests__/skill-names.test.ts b/packages/cli/src/modules/agents/__tests__/skill-names.test.ts index a0158e4ef7b..e6a3ba86713 100644 --- a/packages/cli/src/modules/agents/__tests__/skill-names.test.ts +++ b/packages/cli/src/modules/agents/__tests__/skill-names.test.ts @@ -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([ diff --git a/packages/cli/src/modules/agents/skills/skill-names.ts b/packages/cli/src/modules/agents/skills/skill-names.ts index 5a569890cbd..9a5ace137cf 100644 --- a/packages/cli/src/modules/agents/skills/skill-names.ts +++ b/packages/cli/src/modules/agents/skills/skill-names.ts @@ -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. diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 7b0f5b7c9be..509517ffafd 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -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", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 36a2589de5b..a5a6fcc8ef5 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -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, ); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts index 50a10d8bd7c..0b4ffa2f0f7 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillModal.test.ts @@ -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: ` +
+ {{ categories.join(',') }} {{ categoryLabels?.project }} + + + {{ item.category }} + {{ item.disabledReason }} +
+ + `, + }, })); 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((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>([]); + 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 & Partial, +): 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 & Partial, +): 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( '[data-testid="agent-modal-title-input"]', diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts index c489f9d0587..61942c174eb 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts @@ -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: { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts index 6837afd28ed..647a9f753d6 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillsSection.test.ts @@ -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']]); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts index 8c0304f202a..cbd2d40c369 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentApi.test.ts @@ -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 = { diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts index 395be09cd05..60f3f3d297e 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/useAgentCapabilitiesActions.test.ts @@ -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 { @@ -51,6 +61,7 @@ function setup( overrides: { supportsToolApproval?: boolean; ensureAgentPersisted?: () => Promise; + flushConfigSave?: () => Promise; beforeAgentMutation?: () => Promise; refreshAgentAfterMutation?: (projectId: string, agentId: string) => Promise; agent?: Ref; @@ -63,6 +74,7 @@ function setup( const localConfig = ref(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; 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 } = {}) { + const agent = ref({ + 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((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>); + 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({ + 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(makeConfig()); + setActivePinia(createTestingPinia({ stubActions: false })); + const uiStore = useUIStore(); + const actions = useAgentCapabilitiesActions({ + localConfig, + agent: ref(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); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md index 2521b872b05..a9de68438e7 100644 --- a/packages/frontend/editor-ui/src/features/agents/agents-design-language.md +++ b/packages/frontend/editor-ui/src/features/agents/agents-design-language.md @@ -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. diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue index 8b74f3c3994..d66942502db 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChipButton.vue @@ -140,6 +140,7 @@ const emit = defineEmits<{ } .alertIcon { + display: block; flex-shrink: 0; } diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue index a966f28ca50..60cc0b2be7e 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentSkillFileNav.vue @@ -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') }} {{ reference.path }} -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; 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( - 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(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('project'); + +const librarySkills = ref([]); +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()); +const pickerItems = computed(() => + 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(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(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(() => { + 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; +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>>(() 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) { + 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() {