Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliAgents.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { SweCustomAgent } from '@github/copilot/sdk';
7
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
9
import { ILogService } from '../../../../../platform/log/common/logService';
10
import { PromptFileParser } from '../../../../../platform/promptFiles/common/promptsService';
11
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
12
import { Event } from '../../../../../util/vs/base/common/event';
13
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
14
import { URI } from '../../../../../util/vs/base/common/uri';
15
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
16
import { CopilotCLIAgents, type ICopilotCLISDK } from '../copilotCli';
17
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
18
import type { ChatCustomAgent } from 'vscode';
19
20
function createMockExtensionContext(): IVSCodeExtensionContext {
21
const workspaceState = new Map<string, unknown>();
22
return {
23
extensionPath: '/mock',
24
globalState: {
25
get: <T>(_key: string, defaultValue?: T) => defaultValue as T,
26
update: async () => { },
27
keys: () => []
28
},
29
workspaceState: {
30
get: <T>(key: string, defaultValue?: T) => (workspaceState.get(key) as T) ?? defaultValue,
31
update: async (key: string, value: unknown) => {
32
workspaceState.set(key, value);
33
},
34
keys: () => [...workspaceState.keys()]
35
}
36
} as unknown as IVSCodeExtensionContext;
37
}
38
39
interface PromptFileInfo {
40
readonly uri: URI;
41
readonly content: string;
42
}
43
44
function mockPromptFile(fileName: string, content: string): PromptFileInfo {
45
return { uri: URI.file(`/workspace/.github/agents/${fileName}`), content };
46
}
47
48
function createMockSDK(agentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>): ICopilotCLISDK {
49
let index = 0;
50
const getCustomAgents = vi.fn(async () => {
51
const result = agentsByCall[Math.min(index, agentsByCall.length - 1)] ?? [];
52
index += 1;
53
return result;
54
});
55
56
return {
57
_serviceBrand: undefined,
58
getPackage: vi.fn(async () => ({ getCustomAgents })),
59
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'test-token', host: 'https://github.com' })),
60
getRequestId: vi.fn(() => undefined),
61
setRequestId: vi.fn(),
62
} as unknown as ICopilotCLISDK;
63
}
64
65
function createWorkspaceService(): IWorkspaceService {
66
return {
67
_serviceBrand: undefined,
68
onDidChangeWorkspaceFolders: Event.None,
69
getWorkspaceFolders: () => [URI.file('/workspace')]
70
} as unknown as IWorkspaceService;
71
}
72
73
describe('CopilotCLIAgents', () => {
74
const disposables = new DisposableStore();
75
let logService: ILogService;
76
77
beforeEach(() => {
78
const services = disposables.add(createExtensionUnitTestingServices());
79
logService = services.createTestingAccessor().get(ILogService);
80
});
81
82
afterEach(() => {
83
disposables.clear();
84
});
85
86
function createChatCustomAgent(mock: PromptFileInfo): ChatCustomAgent {
87
const parsed = new PromptFileParser().parse(mock.uri, mock.content);
88
return {
89
uri: mock.uri,
90
source: 'local',
91
name: parsed.header?.name ?? mock.uri.path.split('/').pop()?.replace('.agent.md', '') ?? 'unknown',
92
description: parsed.header?.description ?? '',
93
model: parsed.header?.model,
94
tools: parsed.header?.tools,
95
userInvocable: parsed.header?.userInvokable ?? true,
96
disableModelInvocation: parsed.header?.disableModelInvocation ?? false,
97
enabled: true
98
};
99
}
100
101
function createAgents(options: { sdkAgentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService; sdk: ICopilotCLISDK } {
102
const promptsService = disposables.add(new MockPromptsService());
103
if (options.customAgents) {
104
const customAgents = [];
105
for (const ca of options.customAgents) {
106
promptsService.setFileContent(ca.uri, ca.content);
107
customAgents.push(createChatCustomAgent(ca));
108
}
109
promptsService.setCustomAgents(customAgents);
110
}
111
const sdk = createMockSDK(options.sdkAgentsByCall);
112
const agents = new CopilotCLIAgents(
113
promptsService,
114
sdk,
115
createMockExtensionContext(),
116
logService,
117
createWorkspaceService(),
118
);
119
disposables.add(agents);
120
return { agents, promptsService, sdk };
121
}
122
123
it('prefers prompt-derived agents over SDK agents with the same name', async () => {
124
const promptAgent = mockPromptFile('merge.agent.md', `---
125
name: MergeMe
126
description: Prompt description
127
tools: []
128
model: ['gpt-4.1', 'gpt-4o']
129
disable-model-invocation: true
130
---
131
Prompt body`);
132
const { agents } = createAgents({
133
sdkAgentsByCall: [[{
134
name: 'mergeme',
135
displayName: 'SDK MergeMe',
136
description: 'SDK description',
137
tools: ['sdk-tool'],
138
prompt: async () => 'sdk body',
139
disableModelInvocation: false,
140
}]],
141
customAgents: [promptAgent]
142
});
143
144
const result = await agents.getAgents();
145
146
expect(result).toHaveLength(1);
147
expect(result[0].agent.name).toBe('MergeMe');
148
expect(result[0].agent.displayName).toBe('MergeMe');
149
expect(result[0].agent.description).toBe('Prompt description');
150
expect(result[0].agent.tools).toBeNull();
151
expect(result[0].agent.model).toBe('gpt-4.1');
152
expect(result[0].agent.disableModelInvocation).toBe(true);
153
expect(await result[0].agent.prompt()).toBe('Prompt body');
154
expect(result[0].sourceUri.scheme).toBe('file');
155
});
156
157
it('derives agent name from filename when frontmatter name is missing', async () => {
158
const { agents } = createAgents({
159
sdkAgentsByCall: [[]],
160
customAgents: [mockPromptFile('invalid.agent.md', `---
161
description: Missing name
162
tools: ['read_file']
163
---
164
Body`)]
165
});
166
167
const result = await agents.getAgents();
168
expect(result).toHaveLength(1);
169
expect(result[0].agent.name).toBe('invalid');
170
expect(result[0].agent.displayName).toBe('invalid');
171
expect(result[0].agent.description).toBe('Missing name');
172
expect(result[0].agent.tools).toEqual(['read_file']);
173
});
174
175
it('refreshes cached agents when custom agents change', async () => {
176
const { agents, promptsService, sdk } = createAgents({
177
sdkAgentsByCall: [[], []],
178
customAgents: [mockPromptFile('first.agent.md', `---
179
name: First
180
description: First prompt agent
181
---
182
First body`)]
183
});
184
185
const first = await agents.getAgents();
186
promptsService.setCustomAgents([createChatCustomAgent(mockPromptFile('second.agent.md', `---
187
name: Second
188
description: Second prompt agent
189
---
190
Second body`))]);
191
const second = await agents.getAgents();
192
193
expect(first.map(a => a.agent.name)).toEqual(['First']);
194
expect(second.map(a => a.agent.name)).toEqual(['Second']);
195
expect(sdk.getPackage).toHaveBeenCalled();
196
});
197
198
it('filters out legacy .chatmode.md files', async () => {
199
const chatmodeFile = {
200
uri:
201
URI.file('/workspace/.github/chatmodes/test.chatmode.md'),
202
content: `---
203
name: TestMode
204
description: A legacy chatmode
205
---
206
Body`
207
};
208
const agentFile = mockPromptFile('real.agent.md', `---
209
name: RealAgent
210
description: A real agent
211
---
212
Body`);
213
const { agents } = createAgents({
214
sdkAgentsByCall: [[]],
215
customAgents: [chatmodeFile, agentFile]
216
});
217
218
const result = await agents.getAgents();
219
expect(result).toHaveLength(1);
220
expect(result[0].agent.name).toBe('RealAgent');
221
});
222
});
223
224