Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAgents.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, vi } from 'vitest';7import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';8import { ILogService } from '../../../../../platform/log/common/logService';9import { PromptFileParser } from '../../../../../platform/promptFiles/common/promptsService';10import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';11import { Event } from '../../../../../util/vs/base/common/event';12import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';13import { URI } from '../../../../../util/vs/base/common/uri';14import { createExtensionUnitTestingServices } from '../../../../test/node/services';15import { CopilotCLIAgents, type ICopilotCLISDK } from '../copilotCli';16import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';17import type { ChatCustomAgent } from 'vscode';1819function createMockExtensionContext(): IVSCodeExtensionContext {20const workspaceState = new Map<string, unknown>();21return {22extensionPath: '/mock',23globalState: {24get: <T>(_key: string, defaultValue?: T) => defaultValue as T,25update: async () => { },26keys: () => []27},28workspaceState: {29get: <T>(key: string, defaultValue?: T) => (workspaceState.get(key) as T) ?? defaultValue,30update: async (key: string, value: unknown) => {31workspaceState.set(key, value);32},33keys: () => [...workspaceState.keys()]34}35} as unknown as IVSCodeExtensionContext;36}3738interface PromptFileInfo {39readonly uri: URI;40readonly content: string;41}4243function mockPromptFile(fileName: string, content: string): PromptFileInfo {44return { uri: URI.file(`/workspace/.github/agents/${fileName}`), content };45}4647function createMockSDK(agentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>): ICopilotCLISDK {48let index = 0;49const getCustomAgents = vi.fn(async () => {50const result = agentsByCall[Math.min(index, agentsByCall.length - 1)] ?? [];51index += 1;52return result;53});5455return {56_serviceBrand: undefined,57getPackage: vi.fn(async () => ({ getCustomAgents })),58getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),59getRequestId: vi.fn(() => undefined),60setRequestId: vi.fn(),61} as unknown as ICopilotCLISDK;62}6364function createWorkspaceService(): IWorkspaceService {65return {66_serviceBrand: undefined,67onDidChangeWorkspaceFolders: Event.None,68getWorkspaceFolders: () => [URI.file('/workspace')]69} as unknown as IWorkspaceService;70}7172describe('CopilotCLIAgents', () => {73const disposables = new DisposableStore();74let logService: ILogService;7576beforeEach(() => {77const services = disposables.add(createExtensionUnitTestingServices());78logService = services.createTestingAccessor().get(ILogService);79});8081afterEach(() => {82disposables.clear();83});8485function createChatCustomAgent(mock: PromptFileInfo): ChatCustomAgent {86const parsed = new PromptFileParser().parse(mock.uri, mock.content);87return {88uri: mock.uri,89source: 'local',90name: parsed.header?.name ?? mock.uri.path.split('/').pop()?.replace('.agent.md', '') ?? 'unknown',91description: parsed.header?.description ?? '',92model: parsed.header?.model,93tools: parsed.header?.tools,94userInvocable: parsed.header?.userInvokable ?? true,95disableModelInvocation: parsed.header?.disableModelInvocation ?? false,96enabled: true97};98}99100function createAgents(options: { sdkAgentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService; sdk: ICopilotCLISDK } {101const promptsService = disposables.add(new MockPromptsService());102if (options.customAgents) {103const customAgents = [];104for (const ca of options.customAgents) {105promptsService.setFileContent(ca.uri, ca.content);106customAgents.push(createChatCustomAgent(ca));107}108promptsService.setCustomAgents(customAgents);109}110const sdk = createMockSDK(options.sdkAgentsByCall);111const agents = new CopilotCLIAgents(112promptsService,113sdk,114createMockExtensionContext(),115logService,116createWorkspaceService(),117);118disposables.add(agents);119return { agents, promptsService, sdk };120}121122it('prefers prompt-derived agents over SDK agents with the same name', async () => {123const promptAgent = mockPromptFile('merge.agent.md', `---124name: MergeMe125description: Prompt description126tools: []127model: ['gpt-4.1', 'gpt-4o']128disable-model-invocation: true129---130Prompt body`);131const { agents } = createAgents({132sdkAgentsByCall: [[{133name: 'mergeme',134displayName: 'SDK MergeMe',135description: 'SDK description',136tools: ['sdk-tool'],137prompt: async () => 'sdk body',138disableModelInvocation: false,139}]],140customAgents: [promptAgent]141});142143const result = await agents.getAgents();144145expect(result).toHaveLength(1);146expect(result[0].agent.name).toBe('MergeMe');147expect(result[0].agent.displayName).toBe('MergeMe');148expect(result[0].agent.description).toBe('Prompt description');149expect(result[0].agent.tools).toBeNull();150expect(result[0].agent.model).toBe('gpt-4.1');151expect(result[0].agent.disableModelInvocation).toBe(true);152expect(await result[0].agent.prompt()).toBe('Prompt body');153expect(result[0].sourceUri.scheme).toBe('file');154});155156it('derives agent name from filename when frontmatter name is missing', async () => {157const { agents } = createAgents({158sdkAgentsByCall: [[]],159customAgents: [mockPromptFile('invalid.agent.md', `---160description: Missing name161tools: ['read_file']162---163Body`)]164});165166const result = await agents.getAgents();167expect(result).toHaveLength(1);168expect(result[0].agent.name).toBe('invalid');169expect(result[0].agent.displayName).toBe('invalid');170expect(result[0].agent.description).toBe('Missing name');171expect(result[0].agent.tools).toEqual(['read_file']);172});173174it('refreshes cached agents when custom agents change', async () => {175const { agents, promptsService, sdk } = createAgents({176sdkAgentsByCall: [[], []],177customAgents: [mockPromptFile('first.agent.md', `---178name: First179description: First prompt agent180---181First body`)]182});183184const first = await agents.getAgents();185promptsService.setCustomAgents([createChatCustomAgent(mockPromptFile('second.agent.md', `---186name: Second187description: Second prompt agent188---189Second body`))]);190const second = await agents.getAgents();191192expect(first.map(a => a.agent.name)).toEqual(['First']);193expect(second.map(a => a.agent.name)).toEqual(['Second']);194expect(sdk.getPackage).toHaveBeenCalled();195});196197it('filters out legacy .chatmode.md files', async () => {198const chatmodeFile = {199uri:200URI.file('/workspace/.github/chatmodes/test.chatmode.md'),201content: `---202name: TestMode203description: A legacy chatmode204---205Body`206};207const agentFile = mockPromptFile('real.agent.md', `---208name: RealAgent209description: A real agent210---211Body`);212const { agents } = createAgents({213sdkAgentsByCall: [[]],214customAgents: [chatmodeFile, agentFile]215});216217const result = await agents.getAgents();218expect(result).toHaveLength(1);219expect(result[0].agent.name).toBe('RealAgent');220});221});222223224