From f5c305a3a51f8bec68e1c91d41aca9ec4653f6a0 Mon Sep 17 00:00:00 2001 From: Zavian <36817799+Zavianx@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:32:55 +0800 Subject: [PATCH 1/2] fix(agent-core-v2): resolve plugin-qualified skill names --- .changeset/qualified-plugin-skills.md | 5 ++ .../agent-core-v2/docs/state-manifest.d.ts | 36 ++++++++- .../src/agent/skill/skillService.ts | 23 ++++-- .../src/agent/tools/skill/skillTool.ts | 20 +++-- .../src/app/skillCatalog/registry.ts | 67 ++++++++++++++-- .../src/app/skillCatalog/types.ts | 24 ++++++ .../test/agent/skill/skill.test.ts | 79 +++++++++++++++++++ .../sessionLifecycle/sessionLifecycle.test.ts | 1 + .../test/app/skillCatalog/registry.test.ts | 64 +++++++++++++++ .../skillCatalog/skill-tool-manager.test.ts | 4 + 10 files changed, 302 insertions(+), 21 deletions(-) create mode 100644 .changeset/qualified-plugin-skills.md diff --git a/.changeset/qualified-plugin-skills.md b/.changeset/qualified-plugin-skills.md new file mode 100644 index 0000000000..665f239cbf --- /dev/null +++ b/.changeset/qualified-plugin-skills.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix plugin-qualified Skill invocation and report ambiguous bare plugin Skill names. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 90050ef3ba..fd44c8e2c9 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -497,6 +497,40 @@ export interface SessionStateSnapshot { readonly mermaid?: string; readonly d2?: string; } | undefined; + resolveSkill: (name: string) => /* SkillResolution — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly kind: 'resolved'; + readonly skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name: string; + readonly description: string; + readonly path: string; + readonly dir: string; + readonly content: string; + readonly metadata: /* SkillMetadata — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly name?: string; + readonly description?: string; + readonly type?: string; + readonly whenToUse?: string; + readonly disableModelInvocation?: boolean; + readonly isSubSkill?: boolean; + readonly safe?: boolean; + readonly arguments?: string | readonly unknown[]; + [key: string]: unknown; + }; + readonly source: /* SkillSource — packages/agent-core-v2/src/app/skillCatalog/types.ts */ 'project' | 'user' | 'extra' | 'builtin'; + readonly plugin?: /* SkillPluginContext — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { + readonly id: string; + readonly instructions?: string; + }; + readonly mermaid?: string; + readonly d2?: string; + }; + readonly canonicalName: string; + } | { + readonly kind: 'not-found'; + } | { + readonly kind: 'ambiguous'; + readonly candidates: readonly string[]; + }; renderSkillPrompt: (skill: /* SkillDefinition — packages/agent-core-v2/src/app/skillCatalog/types.ts */ { readonly name: string; readonly description: string; @@ -1010,7 +1044,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { await this.skillCatalog.ready; - const skill = this.skillCatalog.catalog.getSkill(input.name); - if (skill === undefined) { + const resolution = this.skillCatalog.catalog.resolveSkill(input.name); + if (resolution.kind === 'not-found') { throw new Error2(ErrorCodes.SKILL_NOT_FOUND, `Skill "${input.name}" was not found`); } + if (resolution.kind === 'ambiguous') { + throw new Error2( + ErrorCodes.SKILL_NOT_FOUND, + formatAmbiguousSkillMessage(input.name, resolution.candidates), + ); + } + const { canonicalName, skill } = resolution; if (!isUserActivatableSkillType(skill.metadata.type)) { throw new Error2( ErrorCodes.SKILL_TYPE_UNSUPPORTED, - `Skill "${skill.name}" cannot be activated by the user`, + `Skill "${canonicalName}" cannot be activated by the user`, ); } @@ -62,7 +73,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService { type: 'text', text: renderUserSlashSkillPrompt({ - skillName: skill.name, + skillName: canonicalName, skillArgs, skillContent, skillSource: skill.source, @@ -75,7 +86,7 @@ export class AgentSkillService extends Disposable implements IAgentSkillService { kind: 'skill_activation', activationId: randomUUID(), - skillName: skill.name, + skillName: canonicalName, trigger: 'user-slash', skillType: skill.metadata.type, skillPath: skill.path, diff --git a/packages/agent-core-v2/src/agent/tools/skill/skillTool.ts b/packages/agent-core-v2/src/agent/tools/skill/skillTool.ts index fe567ab5f0..a4ce05f4ac 100644 --- a/packages/agent-core-v2/src/agent/tools/skill/skillTool.ts +++ b/packages/agent-core-v2/src/agent/tools/skill/skillTool.ts @@ -26,7 +26,7 @@ import { IAgentSkillService } from '#/agent/skill/skill'; import { renderModelToolSkillPrompt } from '#/agent/skill/prompt'; import type { ExecutableToolResult, ToolDeliveryMessage, ToolExecution } from '#/tool/toolContract'; import { registerAgentToolService } from '#/agent/toolRegistry/toolContribution'; -import { isInlineSkillType } from '#/app/skillCatalog/types'; +import { formatAmbiguousSkillMessage, isInlineSkillType } from '#/app/skillCatalog/types'; import { ISessionSkillCatalog } from '#/session/sessionSkillCatalog/skillCatalog'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { renderPrompt } from '#/_base/utils/render-prompt'; @@ -100,18 +100,22 @@ export async function executeModelSkill( } await catalog.ready; - const skill = catalog.catalog.getSkill(args.skill); - if (skill === undefined) { + const resolution = catalog.catalog.resolveSkill(args.skill); + if (resolution.kind === 'not-found') { return errorResult(`Skill "${args.skill}" not found in the current skill listing.`); } + if (resolution.kind === 'ambiguous') { + return errorResult(formatAmbiguousSkillMessage(args.skill, resolution.candidates)); + } + const { canonicalName, skill } = resolution; if (skill.metadata.disableModelInvocation === true) { return errorResult( - `Skill "${args.skill}" can only be triggered by the user (model invocation is disabled).`, + `Skill "${canonicalName}" can only be triggered by the user (model invocation is disabled).`, ); } if (!isInlineSkillType(skill.metadata.type)) { return errorResult( - `Skill "${skill.name}" is not an inline skill and cannot be invoked by the model in v1.`, + `Skill "${canonicalName}" is not an inline skill and cannot be invoked by the model in v1.`, ); } @@ -120,7 +124,7 @@ export async function executeModelSkill( const origin: SkillActivationOrigin = { kind: 'skill_activation', activationId: randomUUID(), - skillName: skill.name, + skillName: canonicalName, skillArgs: skillArgs.length > 0 ? skillArgs : undefined, trigger, skillType: skill.metadata.type, @@ -134,7 +138,7 @@ export async function executeModelSkill( { type: 'text', text: renderModelToolSkillPrompt({ - skillName: skill.name, + skillName: canonicalName, skillArgs, skillContent, skillSource: skill.source, @@ -148,7 +152,7 @@ export async function executeModelSkill( }; skillService.recordModelToolActivation(origin); return { - output: `Skill "${skill.name}" loaded inline. Follow its instructions.`, + output: `Skill "${canonicalName}" loaded inline. Follow its instructions.`, delivery: { kind: 'steer', message }, }; } diff --git a/packages/agent-core-v2/src/app/skillCatalog/registry.ts b/packages/agent-core-v2/src/app/skillCatalog/registry.ts index 22d18b1ba3..90120ea243 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/registry.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/registry.ts @@ -15,10 +15,11 @@ import type { SkillCatalog, SkillDefinition, SkillMetadata, + SkillResolution, SkillSource, SkippedSkill, } from './types'; -import { isInlineSkillType, normalizeSkillName } from './types'; +import { canonicalSkillName, isInlineSkillType, normalizeSkillName } from './types'; const LISTING_DESC_MAX = 250; @@ -68,6 +69,42 @@ export class InMemorySkillCatalog implements SkillCatalog { return this.byPluginAndName.get(pluginSkillKey(pluginId, name)); } + resolveSkill(name: string): SkillResolution { + const separator = name.indexOf(':'); + if (separator > 0 && separator < name.length - 1) { + const pluginSkill = this.getPluginSkill( + name.slice(0, separator), + name.slice(separator + 1), + ); + if (pluginSkill !== undefined) { + return { + kind: 'resolved', + skill: pluginSkill, + canonicalName: canonicalSkillName(pluginSkill), + }; + } + } + + const skill = this.getSkill(name); + if (skill === undefined) return { kind: 'not-found' }; + if (skill.plugin === undefined) { + return { kind: 'resolved', skill, canonicalName: skill.name }; + } + + const candidates = this.pluginSkillsNamed(name); + if (candidates.length > 1) { + return { + kind: 'ambiguous', + candidates: candidates.map(canonicalSkillName), + }; + } + return { + kind: 'resolved', + skill, + canonicalName: canonicalSkillName(skill), + }; + } + renderSkillPrompt( skill: SkillDefinition, rawArgs: string, @@ -95,10 +132,12 @@ export class InMemorySkillCatalog implements SkillCatalog { } listInvocableSkills(): readonly SkillDefinition[] { - return this.listSkills().filter( - (skill) => - skill.metadata.disableModelInvocation !== true && isInlineSkillType(skill.metadata.type), - ); + return this.listInvocationCandidates() + .filter( + (skill) => + skill.metadata.disableModelInvocation !== true && isInlineSkillType(skill.metadata.type), + ) + .toSorted((a, b) => canonicalSkillName(a).localeCompare(canonicalSkillName(b))); } getSkillRoots(): readonly string[] { @@ -136,6 +175,20 @@ export class InMemorySkillCatalog implements SkillCatalog { this.byPluginAndName.set(key, skill); } } + + private listInvocationCandidates(): readonly SkillDefinition[] { + return [ + ...[...this.byName.values()].filter((skill) => skill.plugin === undefined), + ...this.byPluginAndName.values(), + ]; + } + + private pluginSkillsNamed(name: string): readonly SkillDefinition[] { + const normalizedName = normalizeSkillName(name); + return [...this.byPluginAndName.values()] + .filter((skill) => normalizeSkillName(skill.name) === normalizedName) + .toSorted((a, b) => canonicalSkillName(a).localeCompare(canonicalSkillName(b))); + } } interface SkillExpandContext { @@ -225,7 +278,9 @@ function formatFullSkill(skill: SkillDefinition): readonly string[] { } function formatModelSkill(skill: SkillDefinition): readonly string[] { - const lines = [`- ${skill.name}: ${truncate(skill.description, LISTING_DESC_MAX)}`]; + const lines = [ + `- ${canonicalSkillName(skill)}: ${truncate(skill.description, LISTING_DESC_MAX)}`, + ]; if (typeof skill.metadata.whenToUse === 'string' && skill.metadata.whenToUse.length > 0) { lines.push(` When to use: ${skill.metadata.whenToUse}`); } diff --git a/packages/agent-core-v2/src/app/skillCatalog/types.ts b/packages/agent-core-v2/src/app/skillCatalog/types.ts index 210cfe62d0..8220e96698 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/types.ts +++ b/packages/agent-core-v2/src/app/skillCatalog/types.ts @@ -46,6 +46,18 @@ export interface SkillPluginContext { readonly instructions?: string; } +export type SkillResolution = + | { + readonly kind: 'resolved'; + readonly skill: SkillDefinition; + readonly canonicalName: string; + } + | { readonly kind: 'not-found' } + | { + readonly kind: 'ambiguous'; + readonly candidates: readonly string[]; + }; + export interface SkippedSkill { readonly path: string; readonly type: string; @@ -55,6 +67,7 @@ export interface SkippedSkill { export interface SkillCatalog { getSkill(name: string): SkillDefinition | undefined; getPluginSkill(pluginId: string, name: string): SkillDefinition | undefined; + resolveSkill(name: string): SkillResolution; renderSkillPrompt( skill: SkillDefinition, rawArgs: string, @@ -71,6 +84,17 @@ export function normalizeSkillName(name: string): string { return name.toLowerCase(); } +export function canonicalSkillName(skill: SkillDefinition): string { + return skill.plugin === undefined ? skill.name : `${skill.plugin.id}:${skill.name}`; +} + +export function formatAmbiguousSkillMessage( + name: string, + candidates: readonly string[], +): string { + return `Skill "${name}" is ambiguous. Use one of these qualified names: ${candidates.map((candidate) => `"${candidate}"`).join(', ')}.`; +} + export function isInlineSkillType(type: string | undefined): boolean { return type === undefined || type === 'prompt' || type === 'inline'; } diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index a46bd364d9..1df6096896 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -33,6 +33,19 @@ const COMMIT_SKILL = stubSkill('commit', { source: 'user', }); +function pluginSkill(pluginId: string, name: string) { + const dir = `/plugins/${pluginId}/skills/${name}`; + return stubSkill(name, { + description: `${pluginId} ${name}`, + path: `${dir}/SKILL.md`, + dir, + content: `# ${pluginId} ${name}`, + metadata: {}, + source: 'extra', + plugin: { id: pluginId }, + }); +} + function stubSessionContext(sessionId = 'test-session'): ISessionContext { return { _serviceBrand: undefined, @@ -108,6 +121,34 @@ describe('AgentSkillService', () => { }); }); + it('activate resolves a plugin-qualified skill and records its canonical name', async () => { + skills.register(pluginSkill('example-plugin', 'diagnose')); + const svc = ix.get(IAgentSkillService); + + await svc.activate({ name: 'example-plugin:diagnose' }); + + expect(prompted).toHaveLength(1); + expect(prompted[0]!.origin).toMatchObject({ + kind: 'skill_activation', + skillName: 'example-plugin:diagnose', + }); + expect(prompted[0]!.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('name="example-plugin:diagnose"'), + }); + }); + + it('activate rejects an ambiguous bare plugin skill name with qualified candidates', async () => { + skills.register(pluginSkill('beta', 'review')); + skills.register(pluginSkill('alpha', 'review')); + const svc = ix.get(IAgentSkillService); + + await expect(svc.activate({ name: 'review' })).rejects.toThrow( + 'Skill "review" is ambiguous. Use one of these qualified names: "alpha:review", "beta:review".', + ); + expect(prompted).toHaveLength(0); + }); + it('activate throws for an unknown skill', async () => { const svc = ix.get(IAgentSkillService); await expect(svc.activate({ name: 'missing' })).rejects.toThrow(/not found/i); @@ -251,6 +292,44 @@ describe('SkillTool', () => { }); }); + it('loads a plugin skill by its qualified name', async () => { + skills.register(pluginSkill('example-plugin', 'diagnose')); + + const result = await executeTool( + makeTool(ix), + toolContext({ skill: 'example-plugin:diagnose' }), + ); + + expect(result).toMatchObject({ + output: + 'Skill "example-plugin:diagnose" loaded inline. Follow its instructions.', + }); + expect(result.delivery?.message.origin).toMatchObject({ + kind: 'skill_activation', + skillName: 'example-plugin:diagnose', + }); + expect(result.delivery?.message.content[0]).toMatchObject({ + type: 'text', + text: expect.stringContaining('name="example-plugin:diagnose"'), + }); + }); + + it('returns qualified candidates for an ambiguous bare plugin skill name', async () => { + skills.register(pluginSkill('beta', 'review')); + skills.register(pluginSkill('alpha', 'review')); + + const result = await executeTool( + makeTool(ix), + toolContext({ skill: 'review' }), + ); + + expect(result).toEqual({ + isError: true, + output: + 'Skill "review" is ambiguous. Use one of these qualified names: "alpha:review", "beta:review".', + }); + }); + it('rejects skills that disable model invocation', async () => { skills.register(stubSkill('private', { metadata: { disableModelInvocation: true } })); diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index 40766015e6..22ad9ad83d 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -141,6 +141,7 @@ function skillCatalogStub(): ISessionSkillCatalog { catalog: { getSkill: () => undefined, getPluginSkill: () => undefined, + resolveSkill: () => ({ kind: 'not-found' }), renderSkillPrompt: () => '', listSkills: () => [], listInvocableSkills: () => [], diff --git a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts index f7cde21a14..637787f885 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts @@ -129,6 +129,19 @@ describe('InMemorySkillCatalog model skill listing', () => { expect(rendered).not.toContain('sub-step'); }); + it('lists every plugin skill by its canonical invocation name', () => { + const registry = makeRegistry([ + makeSkill('review', 'extra', 'Alpha review', undefined, { type: 'prompt' }, { id: 'alpha' }), + makeSkill('review', 'extra', 'Beta review', undefined, { type: 'prompt' }, { id: 'beta' }), + ]); + + const rendered = registry.getModelSkillListing(); + + expect(rendered).toContain('- alpha:review: Alpha review'); + expect(rendered).toContain('- beta:review: Beta review'); + expect(rendered).not.toContain('\n- review:'); + }); + it('returns an empty string when no skills are model-invocable', () => { const registry = makeRegistry([ makeSkill('private', 'user', 'Private', undefined, { @@ -359,6 +372,57 @@ describe('InMemorySkillCatalog plugin lookup', () => { description: 'second', }); }); + + it('resolves a plugin-qualified name to the selected plugin skill', () => { + const registry = makeRegistry([ + makeSkill('review', 'extra', 'Alpha review', undefined, {}, { id: 'alpha' }), + makeSkill('review', 'extra', 'Beta review', undefined, {}, { id: 'beta' }), + ]); + + expect(registry.resolveSkill('beta:review')).toMatchObject({ + kind: 'resolved', + canonicalName: 'beta:review', + skill: { description: 'Beta review', plugin: { id: 'beta' } }, + }); + }); + + it('keeps a bare plugin skill name as an alias when it has one candidate', () => { + const registry = makeRegistry([ + makeSkill('review', 'extra', 'Plugin review', undefined, {}, { id: 'alpha' }), + ]); + + expect(registry.resolveSkill('review')).toMatchObject({ + kind: 'resolved', + canonicalName: 'alpha:review', + skill: { description: 'Plugin review' }, + }); + }); + + it('reports every qualified candidate for an ambiguous bare plugin skill name', () => { + const registry = makeRegistry([ + makeSkill('review', 'extra', 'Beta review', undefined, {}, { id: 'beta' }), + makeSkill('review', 'extra', 'Alpha review', undefined, {}, { id: 'alpha' }), + ]); + + expect(registry.resolveSkill('review')).toEqual({ + kind: 'ambiguous', + candidates: ['alpha:review', 'beta:review'], + }); + }); + + it('keeps the merged non-plugin winner available by its bare name', () => { + const registry = makeRegistry([ + makeSkill('review', 'extra', 'Alpha review', undefined, {}, { id: 'alpha' }), + makeSkill('review', 'extra', 'Beta review', undefined, {}, { id: 'beta' }), + ]); + registry.register(makeSkill('review', 'project', 'Project review'), { replace: true }); + + expect(registry.resolveSkill('review')).toMatchObject({ + kind: 'resolved', + canonicalName: 'review', + skill: { description: 'Project review', source: 'project' }, + }); + }); }); function makeRegistry(skills: readonly SkillDefinition[]): InMemorySkillCatalog { diff --git a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts index ed3865e9be..d8d20b8543 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/skill-tool-manager.test.ts @@ -165,6 +165,10 @@ describe('ToolManager SkillTool registration with a structural catalog', () => { skills = { getSkill: (name) => (name === skill.name ? skill : undefined), getPluginSkill: () => undefined, + resolveSkill: (name) => + name === skill.name + ? { kind: 'resolved', skill, canonicalName: skill.name } + : { kind: 'not-found' }, renderSkillPrompt: () => skill.content, listSkills: () => [skill], listInvocableSkills: () => [skill], From eca34a689ae28703c152ffffac6090775fedf7d7 Mon Sep 17 00:00:00 2001 From: Zavian <36817799+Zavianx@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:50:40 +0800 Subject: [PATCH 2/2] fix(skills): list canonical plugin skill names --- .changeset/qualified-plugin-skills.md | 2 +- .../test/tui/commands/resolve.test.ts | 11 ++++- .../test/tui/commands/skills.test.ts | 11 +++++ .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../src/app/skillCatalog/registry.ts | 28 ++++++------ .../test/app/skillCatalog/registry.test.ts | 20 ++++++++- packages/kap-server/src/routes/skills.ts | 6 ++- packages/kap-server/test/skills.test.ts | 44 +++++++++++++++++++ 8 files changed, 103 insertions(+), 21 deletions(-) diff --git a/.changeset/qualified-plugin-skills.md b/.changeset/qualified-plugin-skills.md index 665f239cbf..931520ded1 100644 --- a/.changeset/qualified-plugin-skills.md +++ b/.changeset/qualified-plugin-skills.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Fix plugin-qualified Skill invocation and report ambiguous bare plugin Skill names. +Fix plugin-qualified Skill listing and invocation, and report ambiguous bare plugin Skill names. diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 614553bb42..dbdccf9bce 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -196,7 +196,10 @@ describe('resolveSlashCommandInput', () => { }); it('resolves skill commands and blocks them while busy', () => { - const skillCommandMap = new Map([['skill:review', 'review']]); + const skillCommandMap = new Map([ + ['skill:review', 'review'], + ['skill:alpha-plugin:review', 'alpha-plugin:review'], + ]); expect(resolve('/skill:review src/app.ts', { skillCommandMap })).toEqual({ kind: 'skill', @@ -204,6 +207,12 @@ describe('resolveSlashCommandInput', () => { skillName: 'review', args: 'src/app.ts', }); + expect(resolve('/skill:alpha-plugin:review src/app.ts', { skillCommandMap })).toEqual({ + kind: 'skill', + commandName: 'skill:alpha-plugin:review', + skillName: 'alpha-plugin:review', + args: 'src/app.ts', + }); expect(resolve('/skill:review src/app.ts', { skillCommandMap, isStreaming: true })).toEqual({ kind: 'blocked', commandName: 'skill:review', diff --git a/apps/kimi-code/test/tui/commands/skills.test.ts b/apps/kimi-code/test/tui/commands/skills.test.ts index b9cf46bd60..27f0526222 100644 --- a/apps/kimi-code/test/tui/commands/skills.test.ts +++ b/apps/kimi-code/test/tui/commands/skills.test.ts @@ -91,6 +91,17 @@ describe('skill slash commands', () => { expect(built.commandMap.get('mcp-config')).toBe('mcp-config'); }); + it('preserves a qualified plugin name through the slash-command map', () => { + const built = buildSkillSlashCommands([ + skill('alpha-plugin:review', 'prompt', { source: 'extra' }), + ]); + + expect(built.commands.map((command) => command.name)).toEqual([ + 'skill:alpha-plugin:review', + ]); + expect(built.commandMap.get('skill:alpha-plugin:review')).toBe('alpha-plugin:review'); + }); + it('keeps sub-skills slash-invocable', () => { const built = buildSkillSlashCommands([ skill('outer.inner', 'prompt', { diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index fd44c8e2c9..7b3ef6f3f1 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1044,7 +1044,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map a.name.localeCompare(b.name)); + return [ + ...[...this.byName.values()].filter((skill) => skill.plugin === undefined), + ...this.byPluginAndName.values(), + ].toSorted((a, b) => canonicalSkillName(a).localeCompare(canonicalSkillName(b))); } listInvocableSkills(): readonly SkillDefinition[] { - return this.listInvocationCandidates() - .filter( - (skill) => - skill.metadata.disableModelInvocation !== true && isInlineSkillType(skill.metadata.type), - ) - .toSorted((a, b) => canonicalSkillName(a).localeCompare(canonicalSkillName(b))); + return this.listSkills().filter( + (skill) => + skill.metadata.disableModelInvocation !== true && isInlineSkillType(skill.metadata.type), + ); } getSkillRoots(): readonly string[] { @@ -176,13 +177,6 @@ export class InMemorySkillCatalog implements SkillCatalog { } } - private listInvocationCandidates(): readonly SkillDefinition[] { - return [ - ...[...this.byName.values()].filter((skill) => skill.plugin === undefined), - ...this.byPluginAndName.values(), - ]; - } - private pluginSkillsNamed(name: string): readonly SkillDefinition[] { const normalizedName = normalizeSkillName(name); return [...this.byPluginAndName.values()] @@ -274,7 +268,11 @@ function renderGroupedSkills( } function formatFullSkill(skill: SkillDefinition): readonly string[] { - return [`- ${skill.name}`, ` - Path: ${skill.path}`, ` - Description: ${skill.description}`]; + return [ + `- ${canonicalSkillName(skill)}`, + ` - Path: ${skill.path}`, + ` - Description: ${skill.description}`, + ]; } function formatModelSkill(skill: SkillDefinition): readonly string[] { diff --git a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts index 637787f885..8da886d04e 100644 --- a/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts +++ b/packages/agent-core-v2/test/app/skillCatalog/registry.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from 'vitest'; import { InMemorySkillCatalog } from '#/app/skillCatalog/registry'; -import type { SkillDefinition, SkillSource } from '#/app/skillCatalog/types'; +import { + canonicalSkillName, + type SkillDefinition, + type SkillSource, +} from '#/app/skillCatalog/types'; import { stubSkill } from './stubs'; describe('InMemorySkillCatalog skill listing', () => { @@ -92,6 +96,20 @@ describe('InMemorySkillCatalog skill listing', () => { expect(rendered).not.toContain('builtin version'); }); + it('lists every plugin identity alongside the merged non-plugin winner', () => { + const registry = makeRegistry([ + makeSkill('review', 'extra', 'Alpha review', undefined, {}, { id: 'alpha' }), + makeSkill('review', 'extra', 'Beta review', undefined, {}, { id: 'beta' }), + ]); + registry.register(makeSkill('review', 'project', 'Project review'), { replace: true }); + + expect(registry.listSkills().map(canonicalSkillName)).toEqual([ + 'alpha:review', + 'beta:review', + 'review', + ]); + }); + it('registerBuiltinSkill stamps non-builtin skills as builtin', () => { const registry = new InMemorySkillCatalog(); registry.registerBuiltinSkill(makeSkill('theme', 'user')); diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 7c0a1045b2..5b3fe8cb3a 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -51,7 +51,8 @@ * (`packages/agent-core/src/services/skill/skill.ts`): only * `name`/`description`/`path`/`source` plus optional `type` and * `disable_model_invocation` are emitted; `isSubSkill` is intentionally - * dropped. + * dropped. Plugin Skill names use their canonical `pluginId:skillName` + * identity so every listed name round-trips through the activation route. * * **Error mapping**: * - unknown workspace id → envelope `code: 40410 workspace.not_found`. @@ -89,6 +90,7 @@ import { MERGE_ALL_AVAILABLE_SKILLS_SECTION, SKILL_SOURCE_PRIORITY, applyPromptMetadataUpdate, + canonicalSkillName, configuredRoots, projectRoots, promptMetadataTextFromSkill, @@ -389,7 +391,7 @@ type SkillElement = ReturnType[nu function toProtocolSkill(skill: SkillElement): SkillDescriptor { const base: SkillDescriptor = { - name: skill.name, + name: canonicalSkillName(skill), description: skill.description, path: skill.path, source: skill.source, diff --git a/packages/kap-server/test/skills.test.ts b/packages/kap-server/test/skills.test.ts index 6a0286297d..01c28f67a8 100644 --- a/packages/kap-server/test/skills.test.ts +++ b/packages/kap-server/test/skills.test.ts @@ -27,6 +27,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, + IPluginService, ISessionLifecycleService, ISkillCatalogRuntimeOptions, } from '@moonshot-ai/agent-core-v2'; @@ -149,6 +150,21 @@ describe('server-v2 /api/v1 skills', () => { ); } + async function seedPluginSkill(pluginId: string, name: string): Promise { + const root = await makeWorkspaceDir(); + const dir = join(root, 'skills', name); + await mkdir(dir, { recursive: true }); + await writeFile( + join(root, 'kimi.plugin.json'), + JSON.stringify({ name: pluginId, skills: './skills/' }), + ); + await writeFile( + join(dir, 'SKILL.md'), + `---\nname: ${name}\ndescription: ${pluginId} ${name}\n---\n\nPlugin skill body.\n`, + ); + return root; + } + describe('GET /api/v1/sessions/{sid}/skills', () => { it('returns 40401 for an unknown session', async () => { const { body } = await getJson('/api/v1/sessions/nope/skills'); @@ -199,6 +215,34 @@ describe('server-v2 /api/v1 skills', () => { expect(docsSkill).toMatchObject({ source: 'builtin' }); expect(docsSkill?.description.length).toBeGreaterThan(0); }); + + it('lists colliding plugin skills under names that round-trip through activation', async () => { + const plugins = server!.core.accessor.get(IPluginService); + await plugins.installPlugin({ source: await seedPluginSkill('alpha-plugin', 'review') }); + await plugins.installPlugin({ source: await seedPluginSkill('beta-plugin', 'review') }); + const id = await createSession(); + await createMainAgent(id); + + const { body: listed } = await getJson<{ skills: SkillWire[] }>( + `/api/v1/sessions/${id}/skills`, + ); + const names = listSkillsResponseSchema + .parse(listed.data) + .skills.map((skill) => skill.name) + .filter((name) => name.endsWith(':review')); + const selected = 'beta-plugin:review'; + expect(names).toEqual(['alpha-plugin:review', selected]); + + const { body: activated } = await postJson<{ + activated: boolean; + skill_name: string; + }>(`/api/v1/sessions/${id}/skills/${encodeURIComponent(selected)}:activate`); + expect(activated.code).toBe(0); + expect(activateSkillResultSchema.parse(activated.data)).toEqual({ + activated: true, + skill_name: selected, + }); + }); }); describe('POST /api/v1/sessions/{sid}/skills/{name}:activate', () => {