basant307/AI_Governance_Project
048
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, vi, beforeEach } from 'vitest';8import { SkillCommandLoader } from './SkillCommandLoader.js';9import { CommandKind, type CommandContext } from '../ui/commands/types.js';10import {11 buildSkillLlmContent,12 type Config,13 type SkillConfig,14} from '@qwen-code/qwen-code-core';15 16function makeSkill(overrides: Partial<SkillConfig> = {}): SkillConfig {17 return {18 name: 'my-skill',19 description: 'My skill description',20 level: 'user',21 filePath: '/tmp/qwen-test/skills/my-skill/SKILL.md',22 body: 'Skill body content.',23 ...overrides,24 };25}26 27function makeSkillPrompt(body: string): string {28 return buildSkillLlmContent('/tmp/qwen-test/skills/my-skill', body);29}30 31describe('SkillCommandLoader', () => {32 let mockConfig: Config;33 let mockSkillManager: { listSkills: ReturnType<typeof vi.fn> };34 let mockAddSessionAllowRule: ReturnType<typeof vi.fn>;35 36 beforeEach(() => {37 vi.clearAllMocks();38 mockSkillManager = {39 listSkills: vi.fn().mockResolvedValue([]),40 };41 mockAddSessionAllowRule = vi.fn();42 mockConfig = {43 getSkillManager: vi.fn().mockReturnValue(mockSkillManager),44 getBareMode: vi.fn().mockReturnValue(false),45 getPermissionManager: vi46 .fn()47 .mockReturnValue({ addSessionAllowRule: mockAddSessionAllowRule }),48 // SkillCommandLoader filters via this. Default to empty so existing49 // assertions about "all skills surface" stay true; per-test cases50 // override to verify the filter behavior.51 getDisabledSkillNames: vi.fn().mockReturnValue(new Set<string>()),52 } as unknown as Config;53 });54 55 const signal = new AbortController().signal;56 57 it('should return empty array when config is null', async () => {58 const loader = new SkillCommandLoader(null);59 expect(await loader.loadCommands(signal)).toEqual([]);60 });61 62 it('should return empty array when SkillManager is not available', async () => {63 const config = {64 getSkillManager: vi.fn().mockReturnValue(null),65 getBareMode: vi.fn().mockReturnValue(false),66 } as unknown as Config;67 const loader = new SkillCommandLoader(config);68 expect(await loader.loadCommands(signal)).toEqual([]);69 });70 71 it('should return empty array in bare mode', async () => {72 (mockConfig.getBareMode as ReturnType<typeof vi.fn>).mockReturnValue(true);73 const loader = new SkillCommandLoader(mockConfig);74 expect(await loader.loadCommands(signal)).toEqual([]);75 expect(mockSkillManager.listSkills).not.toHaveBeenCalled();76 });77 78 it('should propagate argumentHint from skills to slash commands', async () => {79 const skill = makeSkill({ argumentHint: '[topic]' });80 mockSkillManager.listSkills.mockImplementation(81 ({ level }: { level: string }) =>82 Promise.resolve(level === 'user' ? [skill] : []),83 );84 85 const loader = new SkillCommandLoader(mockConfig);86 const commands = await loader.loadCommands(signal);87 88 expect(commands[0]?.argumentHint).toBe('[topic]');89 });90 91 it('should default skills to user-invocable slash commands', async () => {92 const skill = makeSkill();93 mockSkillManager.listSkills.mockImplementation(94 ({ level }: { level: string }) =>95 Promise.resolve(level === 'user' ? [skill] : []),96 );97 98 const loader = new SkillCommandLoader(mockConfig);99 const commands = await loader.loadCommands(signal);100 101 expect(commands[0]?.userInvocable).toBe(true);102 });103 104 it('should propagate userInvocable from skills to slash commands', async () => {105 const skill = makeSkill({ userInvocable: false });106 mockSkillManager.listSkills.mockImplementation(107 ({ level }: { level: string }) =>108 Promise.resolve(level === 'user' ? [skill] : []),109 );110 111 const loader = new SkillCommandLoader(mockConfig);112 const commands = await loader.loadCommands(signal);113 114 expect(commands[0]?.userInvocable).toBe(false);115 expect(commands[0]?.modelInvocable).toBe(true);116 });117 118 it('should query user, project, and extension levels', async () => {119 const loader = new SkillCommandLoader(mockConfig);120 await loader.loadCommands(signal);121 expect(mockSkillManager.listSkills).toHaveBeenCalledWith({ level: 'user' });122 expect(mockSkillManager.listSkills).toHaveBeenCalledWith({123 level: 'project',124 });125 expect(mockSkillManager.listSkills).toHaveBeenCalledWith({126 level: 'extension',127 });128 });129 130 it('should load user skill as slash command with correct properties', async () => {131 const skill = makeSkill({ level: 'user' });132 mockSkillManager.listSkills.mockImplementation(133 ({ level }: { level: string }) =>134 Promise.resolve(level === 'user' ? [skill] : []),135 );136 137 const loader = new SkillCommandLoader(mockConfig);138 const commands = await loader.loadCommands(signal);139 140 expect(commands).toHaveLength(1);141 const cmd = commands[0];142 expect(cmd.name).toBe('my-skill');143 expect(cmd.description).toBe('My skill description');144 expect(cmd.kind).toBe(CommandKind.SKILL);145 expect(cmd.source).toBe('skill-dir-command');146 expect(cmd.sourceLabel).toBe('User');147 expect(cmd.sourceDetail).toBe('user');148 expect(cmd.modelInvocable).toBe(true);149 });150 151 it('does not propagate skill.priority to completionPriority', async () => {152 // Priority is scoped to the `/skills` listing only; slash-completion /153 // `/help` ordering should be independent of any skill's priority value.154 const skill = makeSkill({ level: 'user', priority: 42 });155 mockSkillManager.listSkills.mockImplementation(156 ({ level }: { level: string }) =>157 Promise.resolve(level === 'user' ? [skill] : []),158 );159 160 const loader = new SkillCommandLoader(mockConfig);161 const commands = await loader.loadCommands(signal);162 163 expect(commands[0].completionPriority).toBeUndefined();164 });165 166 it('should load project skill with sourceLabel "Project"', async () => {167 const skill = makeSkill({ level: 'project' });168 mockSkillManager.listSkills.mockImplementation(169 ({ level }: { level: string }) =>170 Promise.resolve(level === 'project' ? [skill] : []),171 );172 173 const loader = new SkillCommandLoader(mockConfig);174 const commands = await loader.loadCommands(signal);175 176 expect(commands[0].sourceLabel).toBe('Project');177 expect(commands[0].sourceDetail).toBe('project');178 expect(commands[0].source).toBe('skill-dir-command');179 expect(commands[0].modelInvocable).toBe(true);180 });181 182 it('should submit skill body as prompt', async () => {183 const skill = makeSkill();184 mockSkillManager.listSkills.mockImplementation(185 ({ level }: { level: string }) =>186 Promise.resolve(level === 'user' ? [skill] : []),187 );188 189 const loader = new SkillCommandLoader(mockConfig);190 const commands = await loader.loadCommands(signal);191 const result = await commands[0].action!(192 { invocation: { raw: '/my-skill', args: '' } } as never,193 '',194 );195 196 expect(result).toEqual({197 type: 'submit_prompt',198 content: [{ text: makeSkillPrompt('Skill body content.') }],199 });200 });201 202 it('should append raw invocation when args are provided', async () => {203 const skill = makeSkill();204 mockSkillManager.listSkills.mockImplementation(205 ({ level }: { level: string }) =>206 Promise.resolve(level === 'user' ? [skill] : []),207 );208 209 const loader = new SkillCommandLoader(mockConfig);210 const commands = await loader.loadCommands(signal);211 const result = await commands[0].action!(212 { invocation: { raw: '/my-skill foo', args: 'foo' } } as never,213 'foo',214 );215 216 expect(result).toEqual({217 type: 'submit_prompt',218 content: [219 {220 text: `${makeSkillPrompt('Skill body content.')}\n\n/my-skill foo`,221 },222 ],223 });224 });225 226 it('should return empty array when listSkills throws', async () => {227 mockSkillManager.listSkills.mockRejectedValue(new Error('load failed'));228 const loader = new SkillCommandLoader(mockConfig);229 expect(await loader.loadCommands(signal)).toEqual([]);230 });231 232 describe('extension skills', () => {233 it('should be modelInvocable when description is present', async () => {234 const skill = makeSkill({235 level: 'extension',236 extensionName: 'superpowers-lab',237 description: 'Use tmux for interactive commands',238 });239 mockSkillManager.listSkills.mockImplementation(240 ({ level }: { level: string }) =>241 Promise.resolve(level === 'extension' ? [skill] : []),242 );243 244 const loader = new SkillCommandLoader(mockConfig);245 const commands = await loader.loadCommands(signal);246 247 expect(commands[0].modelInvocable).toBe(true);248 expect(commands[0].source).toBe('plugin-command');249 expect(commands[0].sourceLabel).toBe('Extension: superpowers-lab');250 expect(commands[0].sourceDetail).toBe('extension');251 });252 253 it('should be modelInvocable when whenToUse is present', async () => {254 const skill = makeSkill({255 level: 'extension',256 extensionName: 'superpowers-lab',257 description: '',258 whenToUse: 'Use when you need tmux',259 });260 mockSkillManager.listSkills.mockImplementation(261 ({ level }: { level: string }) =>262 Promise.resolve(level === 'extension' ? [skill] : []),263 );264 265 const loader = new SkillCommandLoader(mockConfig);266 const commands = await loader.loadCommands(signal);267 268 expect(commands[0].modelInvocable).toBe(true);269 });270 271 it('should NOT be modelInvocable when description and whenToUse are absent', async () => {272 const skill = makeSkill({273 level: 'extension',274 extensionName: 'superpowers-lab',275 description: '',276 whenToUse: undefined,277 });278 mockSkillManager.listSkills.mockImplementation(279 ({ level }: { level: string }) =>280 Promise.resolve(level === 'extension' ? [skill] : []),281 );282 283 const loader = new SkillCommandLoader(mockConfig);284 const commands = await loader.loadCommands(signal);285 286 expect(commands[0].modelInvocable).toBe(false);287 });288 289 it('should NOT be modelInvocable when disableModelInvocation is true, even with description', async () => {290 const skill = makeSkill({291 level: 'extension',292 extensionName: 'superpowers-lab',293 description: 'Some description',294 disableModelInvocation: true,295 });296 mockSkillManager.listSkills.mockImplementation(297 ({ level }: { level: string }) =>298 Promise.resolve(level === 'extension' ? [skill] : []),299 );300 301 const loader = new SkillCommandLoader(mockConfig);302 const commands = await loader.loadCommands(signal);303 304 expect(commands[0].modelInvocable).toBe(false);305 });306 307 it('should use "Extension: unknown" as sourceLabel when extensionName is absent', async () => {308 const skill = makeSkill({ level: 'extension', description: 'foo' });309 mockSkillManager.listSkills.mockImplementation(310 ({ level }: { level: string }) =>311 Promise.resolve(level === 'extension' ? [skill] : []),312 );313 314 const loader = new SkillCommandLoader(mockConfig);315 const commands = await loader.loadCommands(signal);316 317 expect(commands[0].sourceLabel).toBe('Extension: unknown');318 expect(commands[0].sourceDetail).toBe('extension');319 });320 });321 322 describe('user/project skill disableModelInvocation', () => {323 it('user skill with disableModelInvocation:true should NOT be modelInvocable', async () => {324 const skill = makeSkill({ level: 'user', disableModelInvocation: true });325 mockSkillManager.listSkills.mockImplementation(326 ({ level }: { level: string }) =>327 Promise.resolve(level === 'user' ? [skill] : []),328 );329 330 const loader = new SkillCommandLoader(mockConfig);331 const commands = await loader.loadCommands(signal);332 333 expect(commands[0].modelInvocable).toBe(false);334 });335 });336 337 it('should aggregate skills from all levels', async () => {338 mockSkillManager.listSkills.mockImplementation(339 ({ level }: { level: string }) => {340 if (level === 'user')341 return Promise.resolve([342 makeSkill({ name: 'user-skill', level: 'user' }),343 ]);344 if (level === 'project')345 return Promise.resolve([346 makeSkill({ name: 'proj-skill', level: 'project' }),347 ]);348 if (level === 'extension')349 return Promise.resolve([350 makeSkill({351 name: 'ext-skill',352 level: 'extension',353 description: 'foo',354 }),355 ]);356 return Promise.resolve([]);357 },358 );359 360 const loader = new SkillCommandLoader(mockConfig);361 const commands = await loader.loadCommands(signal);362 363 expect(commands).toHaveLength(3);364 expect(commands.map((c) => c.name)).toEqual([365 'user-skill',366 'proj-skill',367 'ext-skill',368 ]);369 });370 371 describe('allowedTools grant', () => {372 it('grants allowedTools as session allow rules when the command runs', async () => {373 const skill = makeSkill({374 level: 'user',375 allowedTools: ['Bash(git *)', 'Edit'],376 });377 mockSkillManager.listSkills.mockImplementation(378 ({ level }: { level: string }) =>379 Promise.resolve(level === 'user' ? [skill] : []),380 );381 382 const loader = new SkillCommandLoader(mockConfig);383 const commands = await loader.loadCommands(signal);384 await commands[0].action?.({} as CommandContext, '');385 386 expect(mockAddSessionAllowRule).toHaveBeenCalledTimes(2);387 expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(1, 'Bash(git *)');388 expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(2, 'Edit');389 });390 391 it('does not grant when the skill declares no allowedTools', async () => {392 const skill = makeSkill({ level: 'user' }); // no allowedTools393 mockSkillManager.listSkills.mockImplementation(394 ({ level }: { level: string }) =>395 Promise.resolve(level === 'user' ? [skill] : []),396 );397 398 const loader = new SkillCommandLoader(mockConfig);399 const commands = await loader.loadCommands(signal);400 await commands[0].action?.({} as CommandContext, '');401 402 expect(mockAddSessionAllowRule).not.toHaveBeenCalled();403 });404 });405 406 describe('skills.disabled filter', () => {407 it('omits disabled skills (case-insensitive) from the command list', async () => {408 mockSkillManager.listSkills.mockImplementation(409 ({ level }: { level: string }) => {410 if (level === 'user')411 return Promise.resolve([412 makeSkill({ name: 'KeepMe', level: 'user' }),413 makeSkill({ name: 'HideMe', level: 'user' }),414 ]);415 return Promise.resolve([]);416 },417 );418 // Disabled set is lower-case (matches Config.getDisabledSkillNames419 // contract). Loader compares with `.toLowerCase()`.420 (421 mockConfig.getDisabledSkillNames as ReturnType<typeof vi.fn>422 ).mockReturnValue(new Set(['hideme']));423 424 const loader = new SkillCommandLoader(mockConfig);425 const commands = await loader.loadCommands(signal);426 427 expect(commands.map((c) => c.name)).toEqual(['KeepMe']);428 });429 430 it('reflects provider mutations on each load (live read)', async () => {431 // Regression: the provider must be called per-load, not cached, so432 // CommandService rebuilds (triggered by `reloadCommands`) pick up433 // the latest `skills.disabled`. A frozen-at-construction snapshot434 // would be a silent regression.435 mockSkillManager.listSkills.mockImplementation(436 ({ level }: { level: string }) =>437 level === 'user'438 ? Promise.resolve([makeSkill({ name: 'foo', level: 'user' })])439 : Promise.resolve([]),440 );441 let disabled = new Set<string>();442 (443 mockConfig.getDisabledSkillNames as ReturnType<typeof vi.fn>444 ).mockImplementation(() => disabled);445 446 const loader = new SkillCommandLoader(mockConfig);447 448 const first = await loader.loadCommands(signal);449 expect(first.map((c) => c.name)).toEqual(['foo']);450 451 disabled = new Set(['foo']);452 const second = await loader.loadCommands(signal);453 expect(second).toEqual([]);454 455 disabled = new Set<string>();456 const third = await loader.loadCommands(signal);457 expect(third.map((c) => c.name)).toEqual(['foo']);458 });459 });460});461 