Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.spec.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { SweCustomAgent } from '@github/copilot/sdk';6import { afterEach, beforeEach, describe, expect, it } from 'vitest';7import * as vscode from 'vscode';8import { ILogService } from '../../../../../platform/log/common/logService';9import { MockCustomInstructionsService } from '../../../../../platform/test/common/testCustomInstructionsService';10import { mock } from '../../../../../util/common/test/simpleMock';11import { Emitter } from '../../../../../util/vs/base/common/event';12import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';13import { URI } from '../../../../../util/vs/base/common/uri';14import { CLIAgentInfo, ICopilotCLIAgents } from '../../../copilotcli/node/copilotCli';15import { CopilotCLICustomizationProvider } from '../copilotCLICustomizationProvider';16import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';1718class FakeChatSessionCustomizationType {19static readonly Agent = new FakeChatSessionCustomizationType('agent');20static readonly Skill = new FakeChatSessionCustomizationType('skill');21static readonly Instructions = new FakeChatSessionCustomizationType('instructions');22static readonly Prompt = new FakeChatSessionCustomizationType('prompt');23static readonly Hook = new FakeChatSessionCustomizationType('hook');24static readonly Plugins = new FakeChatSessionCustomizationType('plugins');25constructor(readonly id: string) { }26}2728function makeSweAgent(name: string, description = '', displayName?: string): Readonly<SweCustomAgent> {29return {30name,31displayName: displayName ?? name,32description,33tools: null,34prompt: () => Promise.resolve(''),35disableModelInvocation: false,36};37}3839/** Creates a CLIAgentInfo with a synthetic copilotcli: URI (SDK-only agent). */40function makeAgentInfo(name: string, description = '', displayName?: string): CLIAgentInfo {41return {42agent: makeSweAgent(name, description, displayName),43sourceUri: URI.from({ scheme: 'copilotcli', path: `/agents/${name}` }),44};45}4647/** Creates a CLIAgentInfo with a file: URI (prompt-file-backed agent). */48function makeFileAgentInfo(name: string, fileUri: URI, description = ''): CLIAgentInfo {49return {50agent: makeSweAgent(name, description),51sourceUri: fileUri,52};53}5455/** Creates a ChatInstruction stub with the required name and source fields. */56function makeInstruction(uri: URI, name: string, pattern: string | undefined, description?: string): vscode.ChatInstruction {57return { uri, name, pattern, source: 'local', description };58}5960/** Creates a ChatSkill stub, deriving the name from the parent directory for SKILL.md files. */61function makeSkill(uri: URI, name: string): vscode.ChatSkill {62return { uri, name: name, source: 'local' };63}6465/** Creates a ChatHook stub. */66function makeHook(uri: URI): vscode.ChatHook {67return { uri, source: 'local' };68}6970/** Creates a ChatPlugin stub. */71function makePlugin(uri: URI): vscode.ChatPlugin {72return { uri };73}7475class MockCopilotCLIAgents extends mock<ICopilotCLIAgents>() {76private readonly _onDidChangeAgents = new Emitter<void>();77override readonly onDidChangeAgents = this._onDidChangeAgents.event;78private _agents: CLIAgentInfo[] = [];7980setAgents(agents: CLIAgentInfo[]) { this._agents = agents; }81override async getAgents(): Promise<readonly CLIAgentInfo[]> { return this._agents; }82fireAgentsChanged() { this._onDidChangeAgents.fire(); }83dispose() { this._onDidChangeAgents.dispose(); }84}8586class TestLogService extends mock<ILogService>() {87override trace() { }88override debug() { }89}9091class TestCustomInstructionsService extends MockCustomInstructionsService {92private _agentInstructions: URI[] = [];9394setAgentInstructionUris(uris: URI[]) { this._agentInstructions = uris; }95override getAgentInstructions(): Promise<URI[]> { return Promise.resolve(this._agentInstructions); }96}9798describe('CopilotCLICustomizationProvider', () => {99let disposables: DisposableStore;100let mockPromptsService: MockPromptsService;101let mockCopilotCLIAgents: MockCopilotCLIAgents;102let mockCustomInstructionsService: TestCustomInstructionsService;103let provider: CopilotCLICustomizationProvider;104105let originalChatSessionCustomizationType: unknown;106107beforeEach(() => {108originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;109(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;110disposables = new DisposableStore();111mockPromptsService = disposables.add(new MockPromptsService());112mockCopilotCLIAgents = disposables.add(new MockCopilotCLIAgents());113mockCustomInstructionsService = new TestCustomInstructionsService();114provider = disposables.add(new CopilotCLICustomizationProvider(115mockCopilotCLIAgents,116mockCustomInstructionsService,117mockPromptsService,118new TestLogService(),119{ getWorkspaceFolders: () => [] } as any,120{ stat: () => Promise.reject(new Error('not found')) } as any,121));122});123124afterEach(() => {125disposables.dispose();126(vscode as Record<string, unknown>).ChatSessionCustomizationType = originalChatSessionCustomizationType;127});128129describe('metadata', () => {130it('has correct label and icon', () => {131expect(CopilotCLICustomizationProvider.metadata.label).toBe('Copilot CLI');132expect(CopilotCLICustomizationProvider.metadata.iconId).toBe('copilot');133});134135it('supports Agent, Skill, Instructions, Hook, and Plugins types', () => {136const supported = CopilotCLICustomizationProvider.metadata.supportedTypes;137expect(supported).toBeDefined();138expect(supported).toHaveLength(5);139expect(supported).toContain(FakeChatSessionCustomizationType.Agent);140expect(supported).toContain(FakeChatSessionCustomizationType.Skill);141expect(supported).toContain(FakeChatSessionCustomizationType.Instructions);142expect(supported).toContain(FakeChatSessionCustomizationType.Hook);143expect(supported).toContain(FakeChatSessionCustomizationType.Plugins);144});145146it('only returns items whose type is in supportedTypes', async () => {147mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);148const items = await provider.provideChatSessionCustomizations(undefined!);149const supported = new Set(CopilotCLICustomizationProvider.metadata.supportedTypes!.map(t => t.id));150for (const item of items) {151expect(supported.has(item.type.id), `item "${item.name}" has type "${item.type.id}" not in supportedTypes`).toBe(true);152}153});154155it('does not set groupKey for items with synthetic URIs (vscode infers grouping)', async () => {156mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);157const items = await provider.provideChatSessionCustomizations(undefined!);158const builtinItems = items.filter(i => i.uri.scheme !== 'file');159for (const item of builtinItems) {160expect(item.groupKey, `item "${item.name}" should not have groupKey (vscode infers)`).toBeUndefined();161}162});163});164165describe('provideChatSessionCustomizations', () => {166it('returns empty array when no files exist', async () => {167const items = await provider.provideChatSessionCustomizations(undefined!);168expect(items).toEqual([]);169});170171it('returns agents from ICopilotCLIAgents with source URIs', async () => {172mockCopilotCLIAgents.setAgents([173makeAgentInfo('explore', 'Fast code exploration'),174makeAgentInfo('task', 'Multi-step tasks'),175]);176177const items = await provider.provideChatSessionCustomizations(undefined!);178const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);179expect(agentItems).toHaveLength(2);180expect(agentItems[0].name).toBe('explore');181expect(agentItems[0].description).toBe('Fast code exploration');182});183184it('uses file URI from sourceUri for file-backed agents', async () => {185const fileUri = URI.file('/workspace/.github/explore.agent.md');186mockCopilotCLIAgents.setAgents([makeFileAgentInfo('explore', fileUri, 'Explore agent')]);187188const items = await provider.provideChatSessionCustomizations(undefined!);189const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);190expect(agentItems).toHaveLength(1);191expect(agentItems[0].uri).toEqual(fileUri);192expect(agentItems[0].groupKey).toBeUndefined();193});194195it('uses synthetic URI for SDK-only agents', async () => {196mockCopilotCLIAgents.setAgents([makeAgentInfo('task', 'Task agent')]);197198const items = await provider.provideChatSessionCustomizations(undefined!);199const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);200expect(agentItems).toHaveLength(1);201expect(agentItems[0].uri.scheme).toBe('copilotcli');202expect(agentItems[0].uri.path).toBe('/agents/task');203expect(agentItems[0].groupKey).toBeUndefined();204});205206it('uses displayName from agents when available', async () => {207mockCopilotCLIAgents.setAgents([makeAgentInfo('code-review', 'Reviews code', 'Code Review')]);208209const items = await provider.provideChatSessionCustomizations(undefined!);210expect(items[0].name).toBe('Code Review');211});212213it('returns instructions with on-demand groupKey when no applyTo pattern', async () => {214const uri = URI.file('/workspace/.github/copilot-instructions.md');215mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]);216217const items = await provider.provideChatSessionCustomizations(undefined!);218expect(items).toHaveLength(1);219expect(items[0].uri).toBe(uri);220expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions);221expect(items[0].groupKey).toBe('on-demand-instructions');222});223224it('returns skills', async () => {225const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md');226mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]);227228const items = await provider.provideChatSessionCustomizations(undefined!);229expect(items).toHaveLength(1);230expect(items[0].uri).toBe(uri);231expect(items[0].type).toBe(FakeChatSessionCustomizationType.Skill);232expect(items[0].name).toBe('lint-check');233});234235it('derives skill name from parent directory for SKILL.md files', async () => {236const uri = URI.file('/workspace/.copilot/skills/my-skill/SKILL.md');237mockPromptsService.setSkills([makeSkill(uri, 'my-skill')]);238239const items = await provider.provideChatSessionCustomizations(undefined!);240expect(items).toHaveLength(1);241expect(items[0].name).toBe('my-skill');242});243244it('returns all matching types combined', async () => {245mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);246mockPromptsService.setInstructions([makeInstruction(URI.file('/workspace/.github/b.instructions.md'), 'b instructions', undefined)]);247mockPromptsService.setSkills([makeSkill(URI.file('/workspace/.github/skills/c/SKILL.md'), 'c')]);248mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/pre-commit.json'))]);249mockPromptsService.setPlugins([makePlugin(URI.file('/workspace/.copilot/plugins/my-plugin'))]);250251const items = await provider.provideChatSessionCustomizations(undefined!);252expect(items).toHaveLength(5);253});254255it('returns hooks with correct type and name', async () => {256const uri = URI.file('/workspace/.copilot/hooks/diagnostics.json');257mockPromptsService.setHooks([makeHook(uri)]);258259const items = await provider.provideChatSessionCustomizations(undefined!);260expect(items).toHaveLength(1);261expect(items[0].uri).toBe(uri);262expect(items[0].type).toBe(FakeChatSessionCustomizationType.Hook);263expect(items[0].name).toBe('diagnostics');264});265266it('strips .json extension from hook file name', async () => {267mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/security-checks.json'))]);268269const items = await provider.provideChatSessionCustomizations(undefined!);270expect(items[0].name).toBe('security-checks');271});272273it('returns multiple hooks', async () => {274mockPromptsService.setHooks([275makeHook(URI.file('/workspace/.copilot/hooks/hooks.json')),276makeHook(URI.file('/workspace/.copilot/hooks/diagnostics.json')),277]);278279const items = await provider.provideChatSessionCustomizations(undefined!);280const hookItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Hook);281expect(hookItems).toHaveLength(2);282});283284it('returns plugins with correct type and name derived from URI', async () => {285const uri = URI.file('/workspace/.copilot/plugins/lint-rules');286mockPromptsService.setPlugins([makePlugin(uri)]);287288const items = await provider.provideChatSessionCustomizations(undefined!);289expect(items).toHaveLength(1);290expect(items[0].uri).toEqual(uri);291expect(items[0].type).toBe(FakeChatSessionCustomizationType.Plugins);292expect(items[0].name).toBe('lint-rules');293});294});295296describe('instruction groupKeys and badges', () => {297it('uses agent-instructions groupKey for copilot-instructions.md files', async () => {298const uri = URI.file('/workspace/.github/copilot-instructions.md');299mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]);300mockCustomInstructionsService.setAgentInstructionUris([uri]);301302const items = await provider.provideChatSessionCustomizations(undefined!);303const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);304expect(instrItems).toHaveLength(1);305expect(instrItems[0].groupKey).toBe('agent-instructions');306expect(instrItems[0].badge).toBeUndefined();307});308309it('emits agent instructions not in chatPromptFileService.instructions', async () => {310const agentsUri = URI.file('/workspace/AGENTS.md');311const claudeUri = URI.file('/workspace/CLAUDE.md');312const copilotUri = URI.file('/workspace/.github/copilot-instructions.md');313// Agent instructions are NOT in chatPromptFileService.instructions —314// they come only from customInstructionsService.getAgentInstructions().315mockPromptsService.setInstructions([]);316mockCustomInstructionsService.setAgentInstructionUris([agentsUri, claudeUri, copilotUri]);317318const items = await provider.provideChatSessionCustomizations(undefined!);319const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);320expect(instrItems).toHaveLength(3);321expect(instrItems.every(i => i.groupKey === 'agent-instructions')).toBe(true);322expect(instrItems.map(i => i.name)).toEqual(['AGENTS.md', 'CLAUDE.md', 'copilot-instructions.md']);323});324325it('discovers AGENTS.md and CLAUDE.md from workspace roots via filesystem', async () => {326const workspaceRoot = URI.file('/workspace');327const agentsUri = URI.file('/workspace/AGENTS.md');328const claudeUri = URI.file('/workspace/CLAUDE.md');329const existingUris = new Set([agentsUri.toString(), claudeUri.toString()]);330331const testProvider = disposables.add(new CopilotCLICustomizationProvider(332mockCopilotCLIAgents,333mockCustomInstructionsService,334mockPromptsService,335new TestLogService(),336{ getWorkspaceFolders: () => [workspaceRoot] } as any,337{338stat: (uri: URI) => existingUris.has(uri.toString())339? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 })340: Promise.reject(new Error('not found')),341} as any,342));343344mockPromptsService.setInstructions([]);345mockCustomInstructionsService.setAgentInstructionUris([]);346347const items = await testProvider.provideChatSessionCustomizations(undefined!);348const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);349expect(instrItems).toHaveLength(2);350expect(instrItems.every(i => i.groupKey === 'agent-instructions')).toBe(true);351expect(instrItems.map(i => i.name)).toEqual(['AGENTS.md', 'CLAUDE.md']);352});353354it('uses context-instructions groupKey with badge for instructions with applyTo pattern', async () => {355const uri = URI.file('/workspace/.github/style.instructions.md');356mockPromptsService.setInstructions([makeInstruction(uri, 'style instructions', 'src/**/*.ts')]);357358const items = await provider.provideChatSessionCustomizations(undefined!);359const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);360expect(instrItems).toHaveLength(1);361expect(instrItems[0].groupKey).toBe('context-instructions');362expect(instrItems[0].badge).toBe('src/**/*.ts');363expect(instrItems[0].badgeTooltip).toContain('src/**/*.ts');364});365366it('uses "always added" badge when applyTo is **', async () => {367const uri = URI.file('/workspace/.github/global.instructions.md');368mockPromptsService.setInstructions([makeInstruction(uri, 'global instructions', '**')]);369370const items = await provider.provideChatSessionCustomizations(undefined!);371const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);372expect(instrItems).toHaveLength(1);373expect(instrItems[0].groupKey).toBe('context-instructions');374expect(instrItems[0].badge).toBe('always added');375expect(instrItems[0].badgeTooltip).toContain('every interaction');376});377378it('uses on-demand-instructions groupKey for instructions without applyTo', async () => {379const uri = URI.file('/workspace/.github/refactor.instructions.md');380mockPromptsService.setInstructions([makeInstruction(uri, 'refactor instructions', undefined, 'Refactoring guidelines')]);381382const items = await provider.provideChatSessionCustomizations(undefined!);383const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);384expect(instrItems).toHaveLength(1);385expect(instrItems[0].groupKey).toBe('on-demand-instructions');386expect(instrItems[0].badge).toBeUndefined();387expect(instrItems[0].description).toBe('Refactoring guidelines');388});389390it('includes description from parsed header', async () => {391const uri = URI.file('/workspace/.github/testing.instructions.md');392mockPromptsService.setInstructions([makeInstruction(uri, 'testing instructions', '**/*.spec.ts', 'Testing standards')]);393394const items = await provider.provideChatSessionCustomizations(undefined!);395const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);396expect(instrItems).toHaveLength(1);397expect(instrItems[0].description).toBe('Testing standards');398expect(instrItems[0].badge).toBe('**/*.spec.ts');399});400401it('categorizes mixed instructions correctly', async () => {402const agentUri = URI.file('/workspace/.github/copilot-instructions.md');403const contextUri = URI.file('/workspace/.github/style.instructions.md');404const onDemandUri = URI.file('/workspace/.github/refactor.instructions.md');405mockPromptsService.setInstructions([makeInstruction(agentUri, 'copilot instructions', undefined), makeInstruction(contextUri, 'style instructions', 'src/**'), makeInstruction(onDemandUri, 'refactor instructions', undefined)]);406mockCustomInstructionsService.setAgentInstructionUris([agentUri]);407mockPromptsService.setFileContent(contextUri, '---\napplyTo: \'src/**\'\n---\nStyle rules.');408mockPromptsService.setFileContent(onDemandUri, '---\ndescription: Refactoring\n---\nRefactor tips.');409410const items = await provider.provideChatSessionCustomizations(undefined!);411const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);412expect(instrItems).toHaveLength(3);413414const agent = instrItems.find(i => i.groupKey === 'agent-instructions');415const context = instrItems.find(i => i.groupKey === 'context-instructions');416const onDemand = instrItems.find(i => i.groupKey === 'on-demand-instructions');417418expect(agent).toBeDefined();419expect(agent!.uri).toBe(agentUri);420421expect(context).toBeDefined();422expect(context!.badge).toBe('src/**');423424expect(onDemand).toBeDefined();425expect(onDemand!.badge).toBeUndefined();426});427428it('falls back to on-demand-instructions when file has no YAML header', async () => {429const uri = URI.file('/workspace/.github/plain.instructions.md');430mockPromptsService.setInstructions([makeInstruction(uri, 'plain instructions', undefined)]);431mockPromptsService.setFileContent(uri, 'Just plain text, no frontmatter.');432433const items = await provider.provideChatSessionCustomizations(undefined!);434const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);435expect(instrItems).toHaveLength(1);436expect(instrItems[0].groupKey).toBe('on-demand-instructions');437expect(instrItems[0].badge).toBeUndefined();438});439});440441describe('onDidChange', () => {442it('fires when custom agents change', () => {443let fired = false;444disposables.add(provider.onDidChange(() => { fired = true; }));445446mockPromptsService.fireCustomAgentsChanged();447expect(fired).toBe(true);448});449450it('fires when instructions change', () => {451let fired = false;452disposables.add(provider.onDidChange(() => { fired = true; }));453454mockPromptsService.fireInstructionsChanged();455expect(fired).toBe(true);456});457458it('fires when skills change', () => {459let fired = false;460disposables.add(provider.onDidChange(() => { fired = true; }));461462mockPromptsService.fireSkillsChanged();463expect(fired).toBe(true);464});465466it('fires when hooks change', () => {467let fired = false;468disposables.add(provider.onDidChange(() => { fired = true; }));469470mockPromptsService.fireHooksChanged();471expect(fired).toBe(true);472});473474it('fires when plugins change', () => {475let fired = false;476disposables.add(provider.onDidChange(() => { fired = true; }));477478mockPromptsService.firePluginsChanged();479expect(fired).toBe(true);480});481482it('fires when ICopilotCLIAgents agents change', () => {483let fired = false;484disposables.add(provider.onDidChange(() => { fired = true; }));485486mockCopilotCLIAgents.fireAgentsChanged();487expect(fired).toBe(true);488});489});490});491492493