CoolFace
Datasetpublic

basant307/AI_Governance_Project

sourceHugging Faceapache-2.0updated 2mo agoView on Hugging Face
0likes48downloads
skill.test.ts1655 linesDownload Raw Back to tools
1/**2 * @license3 * Copyright 2025 Qwen4 * SPDX-License-Identifier: Apache-2.05 */6 7import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';8import { logSkillLaunch, recordSkillInvocation } from '../telemetry/index.js';9import { SkillTool, type SkillParams } from './skill.js';10import type { PartListUnion } from '@google/genai';11import type { ToolResultDisplay } from './tools.js';12import type { Config } from '../config/config.js';13import { SkillManager } from '../skills/skill-manager.js';14import type { SkillConfig } from '../skills/types.js';15import type { ToolResult } from './tools.js';16import { partToString } from '../utils/partUtils.js';17import {18  collectAvailableSkillEntries,19  clearCollectedSkillEntriesCache,20  renderAvailableSkillsBlock,21} from './skill-utils.js';22 23// Type for accessing protected methods in tests24type SkillToolWithProtectedMethods = SkillTool & {25  createInvocation: (params: SkillParams) => {26    execute: (27      signal?: AbortSignal,28      updateOutput?: (output: ToolResultDisplay) => void,29    ) => Promise<{30      llmContent: PartListUnion;31      returnDisplay: ToolResultDisplay;32    }>;33    getDescription: () => string;34    setPromptId: (promptId: string) => void;35  };36};37 38// Mock dependencies39vi.mock('../skills/skill-manager.js');40vi.mock('../telemetry/index.js', () => ({41  logSkillLaunch: vi.fn(),42  recordSkillInvocation: vi.fn(),43  SkillLaunchEvent: class {44    constructor(45      public skill_name: string,46      public success: boolean,47      public prompt_id: string = '',48    ) {}49  },50}));51 52const MockedSkillManager = vi.mocked(SkillManager);53 54describe('SkillTool', () => {55  let config: Config;56  let skillTool: SkillTool;57  let mockSkillManager: SkillManager;58  let changeListeners: Array<() => void>;59  let mockAddSessionAllowRule: ReturnType<typeof vi.fn>;60 61  const mockSkills: SkillConfig[] = [62    {63      name: 'code-review',64      description: 'Specialized skill for reviewing code quality',65      level: 'project',66      filePath: '/project/.qwen/skills/code-review/SKILL.md',67      body: 'Review code for quality and best practices.',68    },69    {70      name: 'testing',71      description: 'Skill for writing and running tests',72      level: 'user',73      filePath: '/home/user/.qwen/skills/testing/SKILL.md',74      body: 'Help write comprehensive tests.',75      allowedTools: ['read_file', 'write_file', 'shell'],76    },77  ];78 79  beforeEach(async () => {80    // Setup fake timers81    vi.useFakeTimers();82 83    mockAddSessionAllowRule = vi.fn();84    vi.mocked(recordSkillInvocation).mockClear();85 86    // Clear skill-entries cache so fake timers don't cause stale hits.87    clearCollectedSkillEntriesCache();88 89    // Create mock config90    config = {91      getProjectRoot: vi.fn().mockReturnValue('/test/project'),92      getSessionId: vi.fn().mockReturnValue('test-session-id'),93      getSkillManager: vi.fn(),94      getGeminiClient: vi.fn().mockReturnValue(undefined),95      getModelInvocableCommandsProvider: vi.fn().mockReturnValue(null),96      getModelInvocableCommandsExecutor: vi.fn().mockReturnValue(null),97      getPermissionManager: vi98        .fn()99        .mockReturnValue({ addSessionAllowRule: mockAddSessionAllowRule }),100      // SkillTool reads this in `refreshSkills`, `validateToolParams`, and101      // `SkillToolInvocation.execute` to apply the user-controlled102      // `skills.disabled` filter. Default empty so existing tests are103      // unaffected; per-test cases override.104      getDisabledSkillNames: vi.fn().mockReturnValue(new Set<string>()),105    } as unknown as Config;106 107    changeListeners = [];108 109    // Setup SkillManager mock110    mockSkillManager = {111      listSkills: vi.fn().mockResolvedValue(mockSkills),112      loadSkill: vi.fn(),113      loadSkillForRuntime: vi.fn(),114      addChangeListener: vi.fn((listener: () => void) => {115        changeListeners.push(listener);116        return () => {117          const index = changeListeners.indexOf(listener);118          if (index >= 0) {119            changeListeners.splice(index, 1);120          }121        };122      }),123      getParseErrors: vi.fn().mockReturnValue(new Map()),124      // Default to "all skills active" so existing tests that use125      // unconditional skills are unaffected by the conditional-skill gating126      // added alongside `paths:` frontmatter.127      isSkillActive: vi.fn().mockReturnValue(true),128    } as unknown as SkillManager;129 130    MockedSkillManager.mockImplementation(() => mockSkillManager);131 132    // Make config return the mock SkillManager133    vi.mocked(config.getSkillManager).mockReturnValue(mockSkillManager);134 135    // Create SkillTool instance136    skillTool = new SkillTool(config);137 138    // Allow async initialization to complete139    await vi.runAllTimersAsync();140  });141 142  afterEach(() => {143    vi.useRealTimers();144    vi.clearAllMocks();145    clearCollectedSkillEntriesCache(mockSkillManager);146  });147 148  // The skill listing moved out of the tool description into a system-reminder149  // snapshot rendered by collectAvailableSkillEntries + renderAvailableSkillsBlock150  // (see skill-utils). Tests that used to assert on `tool.description` now assert151  // on this rendered block, which is derived from the SAME mock skillManager +152  // config — preserving the original escaping / dedup / disabled-filter coverage.153  async function renderListing(): Promise<string> {154    const sm = config.getSkillManager();155    if (!sm) return '';156    const { entries } = await collectAvailableSkillEntries(sm, config);157    return renderAvailableSkillsBlock(entries);158  }159 160  describe('initialization', () => {161    it('should initialize with correct name and properties', () => {162      expect(skillTool.name).toBe('skill');163      expect(skillTool.displayName).toBe('Skill');164      expect(skillTool.kind).toBe('read');165    });166 167    it('should load available skills during initialization', () => {168      expect(mockSkillManager.listSkills).toHaveBeenCalled();169    });170 171    it('should subscribe to skill manager changes', () => {172      expect(mockSkillManager.addChangeListener).toHaveBeenCalledTimes(1);173    });174 175    it('keeps the tool description static (no per-skill listing)', () => {176      // The listing moved out of the tool declaration into a system-reminder177      // snapshot, so the description must not vary with the skill set — that is178      // what keeps the tools cache prefix byte-stable across skill changes.179      expect(skillTool.description).toContain('Execute a skill');180      expect(skillTool.description).toContain('<system-reminder>');181      expect(skillTool.description).not.toContain('code-review');182      expect(skillTool.description).not.toContain('testing');183      expect(skillTool.description).not.toContain('<available_skills>');184    });185 186    it('renders available skills in the <available_skills> snapshot block', async () => {187      const listing = await renderListing();188      expect(listing).toContain('code-review');189      expect(listing).toContain('Specialized skill for reviewing code quality');190      expect(listing).toContain('testing');191      expect(listing).toContain('Skill for writing and running tests');192    });193 194    it('should XML-escape description and whenToUse fields', async () => {195      // A crafted description containing XML-special characters must not196      // inject raw tags into the <available_skills> block.197      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([198        {199          name: 'xss-skill',200          description: 'Skill <b>bold</b> & more',201          whenToUse: 'When <script> tags > nothing',202          level: 'project',203          filePath: '/project/.qwen/skills/xss-skill/SKILL.md',204          body: 'Body text.',205        },206      ]);207      new SkillTool(config);208      await vi.runAllTimersAsync();209 210      const listing = await renderListing();211      expect(listing).toContain('Skill &lt;b&gt;bold&lt;/b&gt; &amp; more');212      expect(listing).toContain('When &lt;script&gt; tags &gt; nothing');213      // Raw tags must not appear214      expect(listing).not.toContain('<b>');215      expect(listing).not.toContain('<script>');216    });217 218    it('should XML-escape skill.name (defends against extension-skill bypass)', async () => {219      // Regression: file-based skill names go through validateSkillName,220      // but extension skills come in via extension.skills (skill-manager221      // line 827) and bypass that validator. A crafted extension name222      // would otherwise inject raw tags into <available_skills>.223      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([224        {225          name: 'evil<inject>',226          description: 'Innocent description',227          level: 'extension',228          filePath: '/ext/skills/evil/SKILL.md',229          body: 'Body.',230        },231      ]);232      new SkillTool(config);233      await vi.runAllTimersAsync();234 235      const listing = await renderListing();236      expect(listing).toContain('evil&lt;inject&gt;');237      expect(listing).not.toContain('evil<inject>');238    });239 240    it('should XML-escape modelInvocableCommands name (bypasses validateSkillName)', async () => {241      // file-based skill names go through `validateSkillName` (regex242      // whitelist) at parse time. Command names from243      // modelInvocableCommands come from MCP / extensions and bypass244      // that validator entirely — so the SkillTool description must245      // escape them at the sink before they're handed to the model.246      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);247      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(248        () => [{ name: 'mcp<inject>', description: 'unrelated description' }],249      );250      new SkillTool(config);251      await vi.runAllTimersAsync();252 253      const listing = await renderListing();254      expect(listing).toContain('mcp&lt;inject&gt;');255      expect(listing).not.toContain('mcp<inject>');256    });257 258    it('should XML-escape modelInvocableCommands description', async () => {259      // Same XML-injection vector via the cmd.description field — an260      // MCP prompt can ship a crafted description and the SkillTool's261      // <available_skills> block must escape it the same way as262      // file-based skills.263      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);264      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(265        () => [266          {267            name: 'mcp-evil',268            description:269              'MCP <description>fake</description> & </available_skills><tag>',270          },271        ],272      );273      new SkillTool(config);274      await vi.runAllTimersAsync();275 276      const listing = await renderListing();277      expect(listing).toContain(278        'MCP &lt;description&gt;fake&lt;/description&gt; &amp; &lt;/available_skills&gt;&lt;tag&gt;',279      );280      // The crafted closing tag must NOT escape the <available_skills>281      // block as a literal raw tag.282      expect(listing).not.toContain('</available_skills><tag>');283    });284 285    it('renders an empty listing when there are no skills', async () => {286      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);287 288      new SkillTool(config);289      await vi.runAllTimersAsync();290 291      // No skills/commands → empty block. The "no skills configured" messaging292      // is no longer baked into the tool description (which is now static); the293      // snapshot builder simply omits the reminder when empty.294      expect(await renderListing()).toBe('');295    });296 297    it('degrades gracefully when skill loading throws', async () => {298      vi.mocked(mockSkillManager.listSkills).mockRejectedValue(299        new Error('Loading failed'),300      );301 302      const failedSkillTool = new SkillTool(config);303      await vi.runAllTimersAsync();304 305      // refreshSkills swallows the error and clears the runtime sets, so a306      // previously-available skill no longer validates.307      expect(308        failedSkillTool.validateToolParams({ skill: 'code-review' }),309      ).toMatch(/not found/);310    });311  });312 313  describe('schema generation', () => {314    it('should expose static schema without dynamic enums', () => {315      const schema = skillTool.schema;316      const properties = schema.parametersJsonSchema as {317        properties: {318          skill: {319            type: string;320            description: string;321            enum?: string[];322          };323          args: {324            type: string;325            description: string;326          };327        };328      };329      expect(properties.properties.skill.type).toBe('string');330      expect(properties.properties.skill.description).toBe(331        'The skill or command name. E.g., "pdf" or "xlsx"',332      );333      expect(properties.properties.args.type).toBe('string');334      expect(properties.properties.args.description).toBe(335        'Optional arguments for model-invocable slash commands.',336      );337      expect(properties.properties.skill.enum).toBeUndefined();338    });339 340    it('should keep schema static even when no skills available', async () => {341      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);342 343      const emptySkillTool = new SkillTool(config);344      await vi.runAllTimersAsync();345 346      const schema = emptySkillTool.schema;347      const properties = schema.parametersJsonSchema as {348        properties: {349          skill: {350            type: string;351            description: string;352            enum?: string[];353          };354          args: {355            type: string;356            description: string;357          };358        };359      };360      expect(properties.properties.skill.type).toBe('string');361      expect(properties.properties.skill.description).toBe(362        'The skill or command name. E.g., "pdf" or "xlsx"',363      );364      expect(properties.properties.args.type).toBe('string');365      expect(properties.properties.args.description).toBe(366        'Optional arguments for model-invocable slash commands.',367      );368      expect(properties.properties.skill.enum).toBeUndefined();369    });370  });371 372  describe('validateToolParams', () => {373    it('should validate valid parameters', () => {374      const result = skillTool.validateToolParams({ skill: 'code-review' });375      expect(result).toBeNull();376    });377 378    it('should reject empty skill', () => {379      const result = skillTool.validateToolParams({ skill: '' });380      expect(result).toBe('Parameter "skill" must be a non-empty string.');381    });382 383    it('should reject non-string args', () => {384      const result = skillTool.validateToolParams({385        skill: 'code-review',386        args: 123 as unknown as string,387      });388      expect(result).toBe('Parameter "args" must be a string when provided.');389    });390 391    it('should reject non-existent skill', () => {392      const result = skillTool.validateToolParams({393        skill: 'non-existent',394      });395      expect(result).toBe(396        'Skill "non-existent" not found. Available skills: code-review, testing',397      );398    });399 400    it('should show appropriate message when no skills available', async () => {401      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([]);402 403      const emptySkillTool = new SkillTool(config);404      await vi.runAllTimersAsync();405 406      const result = emptySkillTool.validateToolParams({407        skill: 'non-existent',408      });409      expect(result).toBe(410        'Skill "non-existent" not found. No skills are currently available.',411      );412    });413 414    it('returns a path-activation error for a registered but not-yet-activated conditional skill', async () => {415      const conditionalSkill: SkillConfig = {416        name: 'tsx-helper',417        description: 'React TSX helper',418        level: 'project',419        filePath: '/test/project/.qwen/skills/tsx-helper/SKILL.md',420        body: 'Body.',421        paths: ['src/**/*.tsx'],422      };423      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([424        conditionalSkill,425      ]);426      // Simulate the skill being registered on disk but not yet activated.427      vi.mocked(mockSkillManager.isSkillActive).mockImplementation(428        (s: SkillConfig) => !s.paths || s.paths.length === 0,429      );430 431      const gatedTool = new SkillTool(config);432      await vi.runAllTimersAsync();433 434      const result = gatedTool.validateToolParams({ skill: 'tsx-helper' });435      expect(result).toMatch(/gated by path-based activation/);436      expect(result).toMatch(/paths: frontmatter/);437    });438 439    it('returns the disabled-specific error when no command alternative exists', async () => {440      vi.mocked(config.getDisabledSkillNames).mockReturnValue(441        new Set(['testing']),442      );443      const tool = new SkillTool(config);444      await vi.runAllTimersAsync();445 446      const result = tool.validateToolParams({ skill: 'testing' });447      expect(result).toMatch(/is disabled/);448      expect(result).toMatch(/skills manage|skills\.disabled/);449      // Sanity: not the generic "not found" or "gated" branches.450      expect(result).not.toMatch(/not found/);451      expect(result).not.toMatch(/gated by path-based activation/);452    });453 454    it('passes validation when a same-named MCP prompt exists for a disabled skill', async () => {455      // Regression: validateToolParams must place the disabled-branch456      // AFTER the modelInvocableCommands check. Otherwise the model457      // invoking the same name (intending the MCP prompt) would be told458      // "skill disabled" — but the prompt is legitimately available459      // because §3c excludes disabled skills from `fileBasedSkillNames`.460      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([461        {462          name: 'mytool',463          description: 'Skill body',464          level: 'project',465          filePath: '/p/.qwen/skills/mytool/SKILL.md',466          body: 'skill body',467        },468      ]);469      vi.mocked(config.getDisabledSkillNames).mockReturnValue(470        new Set(['mytool']),471      );472      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(473        () => [474          { name: 'mytool', description: 'Same-named MCP prompt' },475          { name: 'other-cmd', description: 'Unrelated' },476        ],477      );478 479      const tool = new SkillTool(config);480      await vi.runAllTimersAsync();481 482      // commandExists branch returns null (passes through to MCP prompt483      // execution, NOT the disabled-skill error message).484      expect(tool.validateToolParams({ skill: 'mytool' })).toBeNull();485    });486 487    it('does not allow a pending conditional skill to be invoked via the model-invocable command path', async () => {488      // Regression for /review finding: SkillCommandLoader exposes every489      // user/project skill as a model-invocable command. Without dropping490      // file-based names from modelInvocableCommands, validateToolParams491      // would accept a path-gated skill via the command branch and bypass492      // the activation contract entirely.493      const conditionalSkill: SkillConfig = {494        name: 'tsx-helper',495        description: 'React TSX helper',496        level: 'project',497        filePath: '/test/project/.qwen/skills/tsx-helper/SKILL.md',498        body: 'Body.',499        paths: ['src/**/*.tsx'],500      };501      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([502        conditionalSkill,503      ]);504      vi.mocked(mockSkillManager.isSkillActive).mockImplementation(505        (s: SkillConfig) => !s.paths || s.paths.length === 0,506      );507      // SkillCommandLoader would surface tsx-helper here even though it is508      // a path-gated file-based skill.509      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(510        () => [{ name: 'tsx-helper', description: 'React TSX helper' }],511      );512 513      const gatedTool = new SkillTool(config);514      await vi.runAllTimersAsync();515 516      const result = gatedTool.validateToolParams({ skill: 'tsx-helper' });517      expect(result).toMatch(/gated by path-based activation/);518    });519  });520 521  describe('refreshSkills', () => {522    it('should refresh when change listener fires', async () => {523      const newSkills: SkillConfig[] = [524        {525          name: 'new-skill',526          description: 'A brand new skill',527          level: 'project',528          filePath: '/project/.qwen/skills/new-skill/SKILL.md',529          body: 'New skill content.',530        },531      ];532 533      vi.mocked(mockSkillManager.listSkills).mockResolvedValueOnce(newSkills);534 535      const listener = changeListeners[0];536      expect(listener).toBeDefined();537 538      listener?.();539      await vi.runAllTimersAsync();540 541      // refreshSkills updates the in-memory runtime sets (not the static542      // description). listSkills was a one-shot mock consumed by the refresh, so543      // assert via the tool's runtime view rather than re-deriving the listing.544      expect(skillTool.getAvailableSkillNames()).toContain('new-skill');545    });546 547    it('should refresh available skills and update validation state', async () => {548      const newSkills: SkillConfig[] = [549        {550          name: 'test-skill',551          description: 'A test skill',552          level: 'project',553          filePath: '/project/.qwen/skills/test-skill/SKILL.md',554          body: 'Test content.',555        },556      ];557 558      vi.mocked(mockSkillManager.listSkills).mockResolvedValue(newSkills);559 560      await skillTool.refreshSkills();561 562      expect(skillTool.getAvailableSkillNames()).toContain('test-skill');563      const listing = await renderListing();564      expect(listing).toContain('test-skill');565      expect(listing).toContain('A test skill');566    });567  });568 569  describe('dispose', () => {570    it('detaches the change listener so per-subagent SkillTools do not leak', () => {571      // Regression: subagents share the parent's SkillManager via572      // InProcessBackend.createPerAgentConfig, so each per-subagent573      // SkillTool registers its own listener on the parent's manager.574      // Without dispose() the listeners accumulate and every575      // matchAndActivateByPaths call awaits each stale subagent's576      // refreshSkills sequentially.577      expect(changeListeners.length).toBe(1);578      (skillTool as unknown as { dispose: () => void }).dispose();579      expect(changeListeners.length).toBe(0);580    });581  });582 583  describe('SkillToolInvocation', () => {584    const mockRuntimeConfig: SkillConfig = {585      ...mockSkills[0],586    };587 588    beforeEach(() => {589      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(590        mockRuntimeConfig,591      );592    });593 594    it('should execute skill load successfully', async () => {595      const params: SkillParams = {596        skill: 'code-review',597      };598 599      const invocation = (600        skillTool as SkillToolWithProtectedMethods601      ).createInvocation(params);602      const result = await invocation.execute();603 604      expect(mockSkillManager.loadSkillForRuntime).toHaveBeenCalledWith(605        'code-review',606      );607 608      const llmText = partToString(result.llmContent);609      expect(llmText).toContain(610        'Base directory for this skill: /project/.qwen/skills/code-review',611      );612      expect(llmText.trim()).toContain(613        'Review code for quality and best practices.',614      );615 616      expect(result.returnDisplay).toBe(617        'Specialized skill for reviewing code quality',618      );619      expect(recordSkillInvocation).toHaveBeenCalledWith(config, {620        skillName: 'code-review',621        success: true,622      });623    });624 625    it('should include allowedTools in result when present', async () => {626      const skillWithTools: SkillConfig = {627        ...mockSkills[1],628      };629      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(630        skillWithTools,631      );632 633      const params: SkillParams = {634        skill: 'testing',635      };636 637      const invocation = (638        skillTool as SkillToolWithProtectedMethods639      ).createInvocation(params);640      const result = await invocation.execute();641 642      const llmText = partToString(result.llmContent);643      expect(llmText).toContain('testing');644      // Base description is omitted from llmContent; ensure body is present.645      expect(llmText).toContain('Help write comprehensive tests.');646 647      expect(result.returnDisplay).toBe('Skill for writing and running tests');648    });649 650    it('grants allowedTools as session allow rules on invocation', async () => {651      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue({652        ...mockSkills[1],653        allowedTools: ['Bash(git *)', 'Edit'],654      });655 656      const invocation = (657        skillTool as SkillToolWithProtectedMethods658      ).createInvocation({ skill: 'testing' });659      await invocation.execute();660 661      expect(mockAddSessionAllowRule).toHaveBeenCalledTimes(2);662      expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(1, 'Bash(git *)');663      expect(mockAddSessionAllowRule).toHaveBeenNthCalledWith(2, 'Edit');664    });665 666    it('does not add allow rules when the skill declares no allowedTools', async () => {667      // code-review (mockSkills[0]) has no allowedTools field.668      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(669        mockSkills[0],670      );671 672      const invocation = (673        skillTool as SkillToolWithProtectedMethods674      ).createInvocation({ skill: 'code-review' });675      await invocation.execute();676 677      expect(mockAddSessionAllowRule).not.toHaveBeenCalled();678    });679 680    it('should handle skill not found error', async () => {681      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null);682 683      const params: SkillParams = {684        skill: 'non-existent',685      };686 687      const invocation = (688        skillTool as SkillToolWithProtectedMethods689      ).createInvocation(params);690      const result = await invocation.execute();691 692      const llmText = partToString(result.llmContent);693      expect(llmText).toContain('Skill "non-existent" not found');694      expect(recordSkillInvocation).toHaveBeenCalledWith(config, {695        skillName: 'non-existent',696        success: false,697      });698    });699 700    it('should handle execution errors gracefully', async () => {701      vi.mocked(mockSkillManager.loadSkillForRuntime).mockRejectedValue(702        new Error('Loading failed'),703      );704 705      const params: SkillParams = {706        skill: 'code-review',707      };708 709      const invocation = (710        skillTool as SkillToolWithProtectedMethods711      ).createInvocation(params);712      const result = await invocation.execute();713 714      const llmText = partToString(result.llmContent);715      expect(llmText).toContain('Failed to load skill');716      expect(llmText).toContain('Loading failed');717      expect(recordSkillInvocation).toHaveBeenCalledWith(config, {718        skillName: 'code-review',719        success: false,720      });721    });722 723    it("L3 default is 'ask' so AUTO mode routes through the classifier", async () => {724      // Previously this returned 'allow', but skills load user-defined725      // code that runs with the agent's tool access — a privileged sink.726      // The AUTO scheduler short-circuits at L4 when finalPermission ===727      // 'allow', so without this override the classifier projection728      // added in PR #4151 would never be reached and arbitrary skill729      // invocations would bypass classifier review.730      const params: SkillParams = {731        skill: 'code-review',732      };733 734      const invocation = (735        skillTool as SkillToolWithProtectedMethods736      ).createInvocation(params);737      const permission = await invocation.getDefaultPermission();738 739      expect(permission).toBe('ask');740    });741 742    it('should provide correct description', () => {743      const params: SkillParams = {744        skill: 'code-review',745      };746 747      const invocation = (748        skillTool as SkillToolWithProtectedMethods749      ).createInvocation(params);750      const description = invocation.getDescription();751 752      expect(description).toBe('Use skill: "code-review"');753    });754 755    it('should handle skill without additional files', async () => {756      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(757        mockSkills[0],758      );759 760      const params: SkillParams = {761        skill: 'code-review',762      };763 764      const invocation = (765        skillTool as SkillToolWithProtectedMethods766      ).createInvocation(params);767      const result = await invocation.execute();768 769      const llmText = partToString(result.llmContent);770      expect(llmText).not.toContain('## Additional Files');771 772      expect(result.returnDisplay).toBe(773        'Specialized skill for reviewing code quality',774      );775    });776 777    it('propagates prompt_id to SkillLaunchEvent when setPromptId is called', async () => {778      const params: SkillParams = {779        skill: 'code-review',780      };781 782      const invocation = (783        skillTool as SkillToolWithProtectedMethods784      ).createInvocation(params);785      // setPromptId is intentionally a scheduler-only hook (duck-typed by786      // CoreToolScheduler.buildInvocation; not on the public ToolInvocation787      // interface). Tests cast through `unknown` to exercise it directly.788      (789        invocation as unknown as { setPromptId: (id: string) => void }790      ).setPromptId('prompt-abc-123');791      await invocation.execute();792 793      expect(logSkillLaunch).toHaveBeenCalled();794      const lastEvent = vi.mocked(logSkillLaunch).mock.calls.at(-1)?.[1];795      expect(lastEvent).toEqual(796        expect.objectContaining({797          skill_name: 'code-review',798          success: true,799          prompt_id: 'prompt-abc-123',800        }),801      );802    });803 804    it('records empty prompt_id when setPromptId is never called (direct invocation)', async () => {805      const params: SkillParams = {806        skill: 'code-review',807      };808 809      const invocation = (810        skillTool as SkillToolWithProtectedMethods811      ).createInvocation(params);812      await invocation.execute();813 814      expect(logSkillLaunch).toHaveBeenCalled();815      const lastEvent = vi.mocked(logSkillLaunch).mock.calls.at(-1)?.[1];816      expect(lastEvent).toEqual(817        expect.objectContaining({818          skill_name: 'code-review',819          success: true,820          prompt_id: '',821        }),822      );823    });824 825    it('propagates prompt_id through the commandExecutor-success branch', async () => {826      // skill not on disk → loadSkillForRuntime returns null → falls through827      // to commandExecutor (the L386 branch in skill.ts).828      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null);829      const executor = vi.fn().mockResolvedValue('content from executor');830      vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(831        executor,832      );833 834      const invocation = (835        skillTool as SkillToolWithProtectedMethods836      ).createInvocation({ skill: 'mcp-prompt-a' });837      (838        invocation as unknown as { setPromptId: (id: string) => void }839      ).setPromptId('prompt-via-executor');840      await invocation.execute();841 842      const lastEvent = vi.mocked(logSkillLaunch).mock.calls.at(-1)?.[1];843      expect(lastEvent).toEqual(844        expect.objectContaining({845          skill_name: 'mcp-prompt-a',846          success: true,847          prompt_id: 'prompt-via-executor',848        }),849      );850      expect(recordSkillInvocation).not.toHaveBeenCalled();851    });852 853    it('returns the executor error from the disabled-skill delegation path', async () => {854      // Disabled skill that shadows a same-named command whose executor fails:855      // the { error } result must surface as the tool result, not fall through856      // to the generic "skill is disabled" message.857      vi.mocked(config.getDisabledSkillNames).mockReturnValue(858        new Set(['blocked']),859      );860      const executor = vi861        .fn()862        .mockResolvedValue({ error: 'command failed: boom' });863      vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(864        executor,865      );866 867      const invocation = (868        skillTool as SkillToolWithProtectedMethods869      ).createInvocation({ skill: 'blocked' });870      const result = await invocation.execute();871 872      expect(result.llmContent).toBe('command failed: boom');873      expect(result.returnDisplay).toBe('command failed: boom');874    });875 876    it('propagates prompt_id through the not-found branch', async () => {877      // Both loadSkillForRuntime and commandExecutor return null → L399878      // branch in skill.ts logs a failed SkillLaunchEvent.879      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(null);880      vi.mocked(config.getModelInvocableCommandsExecutor).mockReturnValue(null);881 882      const invocation = (883        skillTool as SkillToolWithProtectedMethods884      ).createInvocation({ skill: 'nonexistent' });885      (886        invocation as unknown as { setPromptId: (id: string) => void }887      ).setPromptId('prompt-on-miss');888      await invocation.execute();889 890      const lastEvent = vi.mocked(logSkillLaunch).mock.calls.at(-1)?.[1];891      expect(lastEvent).toEqual(892        expect.objectContaining({893          skill_name: 'nonexistent',894          success: false,895          prompt_id: 'prompt-on-miss',896        }),897      );898    });899 900    it('propagates prompt_id through the thrown-exception branch', async () => {901      // loadSkillForRuntime throws → caught by L482 branch in skill.ts.902      vi.mocked(mockSkillManager.loadSkillForRuntime).mockRejectedValue(903        new Error('synthetic load failure'),904      );905 906      const invocation = (907        skillTool as SkillToolWithProtectedMethods908      ).createInvocation({ skill: 'code-review' });909      (910        invocation as unknown as { setPromptId: (id: string) => void }911      ).setPromptId('prompt-on-throw');912      await invocation.execute();913 914      const lastEvent = vi.mocked(logSkillLaunch).mock.calls.at(-1)?.[1];915      expect(lastEvent).toEqual(916        expect.objectContaining({917          skill_name: 'code-review',918          success: false,919          prompt_id: 'prompt-on-throw',920        }),921      );922    });923 924    it('returns full content on first invocation and short message on re-invocation', async () => {925      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(926        mockRuntimeConfig,927      );928 929      const invocation1 = (930        skillTool as SkillToolWithProtectedMethods931      ).createInvocation({ skill: 'code-review' });932      const result1 = await invocation1.execute();933      const llmText1 = partToString(result1.llmContent);934      expect(llmText1).toContain('Review code for quality and best practices.');935      expect(llmText1).toContain('Base directory for this skill:');936      expect(result1.returnDisplay).toBe(937        'Specialized skill for reviewing code quality',938      );939 940      const invocation2 = (941        skillTool as SkillToolWithProtectedMethods942      ).createInvocation({ skill: 'code-review' });943      const result2 = await invocation2.execute();944      const llmText2 = partToString(result2.llmContent);945      expect(llmText2).toBe(946        'Skill "code-review" is already loaded in context.',947      );948      expect(result2.returnDisplay).toBe(949        'Skill "code-review" is already loaded in context.',950      );951    });952 953    it('still allows loading a different skill after one is already loaded', async () => {954      vi.mocked(mockSkillManager.loadSkillForRuntime)955        .mockResolvedValueOnce(mockSkills[0])956        .mockResolvedValueOnce(mockSkills[1]);957 958      const inv1 = (959        skillTool as SkillToolWithProtectedMethods960      ).createInvocation({ skill: 'code-review' });961      await inv1.execute();962 963      const inv2 = (964        skillTool as SkillToolWithProtectedMethods965      ).createInvocation({ skill: 'testing' });966      const result2 = await inv2.execute();967      const llmText2 = partToString(result2.llmContent);968      expect(llmText2).toContain('Help write comprehensive tests.');969    });970 971    it('does not skip dedup for skills that failed to load on first attempt', async () => {972      vi.mocked(mockSkillManager.loadSkillForRuntime)973        .mockResolvedValueOnce(null)974        .mockResolvedValueOnce(mockRuntimeConfig);975 976      const inv1 = (977        skillTool as SkillToolWithProtectedMethods978      ).createInvocation({ skill: 'code-review' });979      await inv1.execute();980 981      const inv2 = (982        skillTool as SkillToolWithProtectedMethods983      ).createInvocation({ skill: 'code-review' });984      const result2 = await inv2.execute();985      const llmText2 = partToString(result2.llmContent);986      expect(llmText2).toContain('Review code for quality and best practices.');987    });988 989    it('clearLoadedSkills resets dedup state so the next invocation returns full content', async () => {990      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(991        mockRuntimeConfig,992      );993 994      const inv1 = (995        skillTool as SkillToolWithProtectedMethods996      ).createInvocation({ skill: 'code-review' });997      await inv1.execute();998 999      skillTool.clearLoadedSkills();1000 1001      const inv2 = (1002        skillTool as SkillToolWithProtectedMethods1003      ).createInvocation({ skill: 'code-review' });1004      const result2 = await inv2.execute();1005      const llmText2 = partToString(result2.llmContent);1006      expect(llmText2).toContain('Review code for quality and best practices.');1007    });1008 1009    it('re-invocation still logs telemetry and calls onSkillLoaded', async () => {1010      vi.mocked(mockSkillManager.loadSkillForRuntime).mockResolvedValue(1011        mockRuntimeConfig,1012      );1013 1014      const inv1 = (1015        skillTool as SkillToolWithProtectedMethods1016      ).createInvocation({ skill: 'code-review' });1017      await inv1.execute();1018 1019      vi.mocked(logSkillLaunch).mockClear();1020      vi.mocked(recordSkillInvocation).mockClear();1021 1022      const inv2 = (1023        skillTool as SkillToolWithProtectedMethods1024      ).createInvocation({ skill: 'code-review' });1025      await inv2.execute();1026 1027      expect(logSkillLaunch).toHaveBeenCalledWith(1028        config,1029        expect.objectContaining({1030          skill_name: 'code-review',1031          success: true,1032        }),1033      );1034    });1035  });1036 1037  describe('modelInvocableCommands integration', () => {1038    const mockCommands = [1039      { name: 'review', description: 'Bundled code review skill' },1040      { name: 'mcp-prompt-a', description: 'An MCP prompt' },1041    ];1042 1043    it('should show non-skill commands in <available_skills> section', async () => {1044      // 'review' and 'mcp-prompt-a' don't overlap with file skills1045      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(1046        () => mockCommands,1047      );1048 1049      new SkillTool(config);1050      await vi.runAllTimersAsync();1051 1052      const listing = await renderListing();1053      // Commands share the single <available_skills> listing — no separate1054      // <available_commands> block.1055      expect(listing).not.toContain('<available_commands>');1056      expect(listing).toContain('review');1057      expect(listing).toContain('mcp-prompt-a');1058    });1059 1060    it('includes command args in the confirmation description', async () => {1061      const invocation = (1062        skillTool as SkillToolWithProtectedMethods1063      ).createInvocation({1064        skill: 'mcp-prompt-a',1065        args: 'dangerous input',1066      });1067 1068      expect(invocation.getDescription()).toBe(1069        'Use skill: "mcp-prompt-a" with args: "dangerous input"',1070      );1071    });1072 1073    it('includes empty command args in the confirmation description', async () => {1074      const invocation = (1075        skillTool as SkillToolWithProtectedMethods1076      ).createInvocation({1077        skill: 'mcp-prompt-a',1078        args: '',1079      });1080 1081      expect(invocation.getDescription()).toBe(1082        'Use skill: "mcp-prompt-a" with args: ""',1083      );1084    });1085 1086    it('truncates markdown-looking command args in the confirmation description', async () => {1087      const invocation = (1088        skillTool as SkillToolWithProtectedMethods1089      ).createInvocation({1090        skill: 'mcp-prompt-a',1091        args: `${'x'.repeat(121)} **bold** [link](https://example.com)`,1092      });1093 1094      expect(invocation.getDescription()).toBe(1095        `Use skill: "mcp-prompt-a" with args: "${'x'.repeat(117)}..."`,1096      );1097    });1098 1099    it('escapes markdown-looking command args in the confirmation description', async () => {1100      const invocation = (1101        skillTool as SkillToolWithProtectedMethods1102      ).createInvocation({1103        skill: 'mcp-prompt-a',1104        args: '**bold** [link](https://example.com)',1105      });1106 1107      expect(invocation.getDescription()).toBe(1108        'Use skill: "mcp-prompt-a" with args: "\\*\\*bold\\*\\* \\[link\\]\\(https://example\\.com\\)"',1109      );1110    });1111 1112    it('should not duplicate commands already present as file-based skills', async () => {1113      // 'code-review' matches a skill in mockSkills → should be filtered out1114      const commandsIncludingSkill = [1115        { name: 'code-review', description: 'Bundled version of code-review' },1116        { name: 'mcp-prompt-a', description: 'An MCP prompt' },1117      ];1118      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(1119        () => commandsIncludingSkill,1120      );1121 1122      new SkillTool(config);1123      await vi.runAllTimersAsync();1124 1125      const listing = await renderListing();1126      // 'code-review' is already in <available_skills> as a file skill, must NOT appear twice1127      const codeReviewMatches = (listing.match(/code-review/g) || []).length;1128      expect(codeReviewMatches).toBe(1);1129      // 'mcp-prompt-a' is not a file-based skill, must appear in the unified list1130      expect(listing).toContain('mcp-prompt-a');1131    });1132 1133    it('should hide <available_commands> when all commands are already covered by skills', async () => {1134      // Both command names match existing skills1135      const commandsAllOverlapping = [1136        { name: 'code-review', description: 'Bundled code-review' },1137        { name: 'testing', description: 'Bundled testing' },1138      ];1139      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(1140        () => commandsAllOverlapping,1141      );1142 1143      new SkillTool(config);1144      await vi.runAllTimersAsync();1145 1146      const listing = await renderListing();1147      expect(listing).not.toContain('<available_commands>');1148      // Both commands overlapped with file skills, so no extra command entries1149      // are added (the command-form descriptions must not appear).1150      expect(listing).not.toContain('Bundled code-review');1151      expect(listing).not.toContain('Bundled testing');1152      expect(listing).toContain('code-review');1153      expect(listing).toContain('testing');1154    });1155 1156    it('does not let a disable-model-invocation skill block an unrelated command of the same name', async () => {1157      // Regression for /review finding: the model-invocable-commands dedup1158      // set was built from every file-based skill name, including hidden1159      // ones. A skill marked `disable-model-invocation: true` is1160      // intentionally invisible to the model — it must not also suppress1161      // an unrelated MCP prompt or command that happens to share its name.1162      const hiddenSkill: SkillConfig = {1163        name: 'mcp-prompt-a',1164        description: 'A hidden file-based skill',1165        level: 'project',1166        filePath: '/test/project/.qwen/skills/mcp-prompt-a/SKILL.md',1167        body: 'Body.',1168        disableModelInvocation: true,1169      };1170      vi.mocked(mockSkillManager.listSkills).mockResolvedValue([hiddenSkill]);1171      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(1172        () => [1173          { name: 'mcp-prompt-a', description: 'An unrelated MCP prompt' },1174        ],1175      );1176 1177      new SkillTool(config);1178      await vi.runAllTimersAsync();1179 1180      const listing = await renderListing();1181      // The unrelated MCP prompt should still appear; the disabled file1182      // skill must not have suppressed it.1183      expect(listing).toContain('mcp-prompt-a');1184      expect(listing).toContain('An unrelated MCP prompt');1185    });1186  });1187 1188  describe('validateToolParams with modelInvocableCommands', () => {1189    beforeEach(async () => {1190      vi.mocked(config.getModelInvocableCommandsProvider).mockReturnValue(1191        () => [{ name: 'mcp-prompt-a', description: 'An MCP prompt' }],1192      );1193      await skillTool.refreshSkills();1194    });1195 1196    it('should accept a model-invocable command name that is not a file skill', () => {1197      const result = skillTool.validateToolParams({ skill: 'mcp-prompt-a' });1198      expect(result).toBeNull();1199    });1200 

Showing the first 1,200 of 1655 lines. Download the file for the rest.

basant307/AI_Governance_Project · CoolFace