Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/test/copilotCLICustomizationProvider.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 } from 'vitest';
8
import * as vscode from 'vscode';
9
import { ILogService } from '../../../../../platform/log/common/logService';
10
import { MockCustomInstructionsService } from '../../../../../platform/test/common/testCustomInstructionsService';
11
import { mock } from '../../../../../util/common/test/simpleMock';
12
import { Emitter } 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 { CLIAgentInfo, ICopilotCLIAgents } from '../../../copilotcli/node/copilotCli';
16
import { CopilotCLICustomizationProvider } from '../copilotCLICustomizationProvider';
17
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
18
19
class FakeChatSessionCustomizationType {
20
static readonly Agent = new FakeChatSessionCustomizationType('agent');
21
static readonly Skill = new FakeChatSessionCustomizationType('skill');
22
static readonly Instructions = new FakeChatSessionCustomizationType('instructions');
23
static readonly Prompt = new FakeChatSessionCustomizationType('prompt');
24
static readonly Hook = new FakeChatSessionCustomizationType('hook');
25
static readonly Plugins = new FakeChatSessionCustomizationType('plugins');
26
constructor(readonly id: string) { }
27
}
28
29
function makeSweAgent(name: string, description = '', displayName?: string): Readonly<SweCustomAgent> {
30
return {
31
name,
32
displayName: displayName ?? name,
33
description,
34
tools: null,
35
prompt: () => Promise.resolve(''),
36
disableModelInvocation: false,
37
};
38
}
39
40
/** Creates a CLIAgentInfo with a synthetic copilotcli: URI (SDK-only agent). */
41
function makeAgentInfo(name: string, description = '', displayName?: string): CLIAgentInfo {
42
return {
43
agent: makeSweAgent(name, description, displayName),
44
sourceUri: URI.from({ scheme: 'copilotcli', path: `/agents/${name}` }),
45
};
46
}
47
48
/** Creates a CLIAgentInfo with a file: URI (prompt-file-backed agent). */
49
function makeFileAgentInfo(name: string, fileUri: URI, description = ''): CLIAgentInfo {
50
return {
51
agent: makeSweAgent(name, description),
52
sourceUri: fileUri,
53
};
54
}
55
56
/** Creates a ChatInstruction stub with the required name and source fields. */
57
function makeInstruction(uri: URI, name: string, pattern: string | undefined, description?: string): vscode.ChatInstruction {
58
return { uri, name, pattern, source: 'local', description };
59
}
60
61
/** Creates a ChatSkill stub, deriving the name from the parent directory for SKILL.md files. */
62
function makeSkill(uri: URI, name: string): vscode.ChatSkill {
63
return { uri, name: name, source: 'local' };
64
}
65
66
/** Creates a ChatHook stub. */
67
function makeHook(uri: URI): vscode.ChatHook {
68
return { uri, source: 'local' };
69
}
70
71
/** Creates a ChatPlugin stub. */
72
function makePlugin(uri: URI): vscode.ChatPlugin {
73
return { uri };
74
}
75
76
class MockCopilotCLIAgents extends mock<ICopilotCLIAgents>() {
77
private readonly _onDidChangeAgents = new Emitter<void>();
78
override readonly onDidChangeAgents = this._onDidChangeAgents.event;
79
private _agents: CLIAgentInfo[] = [];
80
81
setAgents(agents: CLIAgentInfo[]) { this._agents = agents; }
82
override async getAgents(): Promise<readonly CLIAgentInfo[]> { return this._agents; }
83
fireAgentsChanged() { this._onDidChangeAgents.fire(); }
84
dispose() { this._onDidChangeAgents.dispose(); }
85
}
86
87
class TestLogService extends mock<ILogService>() {
88
override trace() { }
89
override debug() { }
90
}
91
92
class TestCustomInstructionsService extends MockCustomInstructionsService {
93
private _agentInstructions: URI[] = [];
94
95
setAgentInstructionUris(uris: URI[]) { this._agentInstructions = uris; }
96
override getAgentInstructions(): Promise<URI[]> { return Promise.resolve(this._agentInstructions); }
97
}
98
99
describe('CopilotCLICustomizationProvider', () => {
100
let disposables: DisposableStore;
101
let mockPromptsService: MockPromptsService;
102
let mockCopilotCLIAgents: MockCopilotCLIAgents;
103
let mockCustomInstructionsService: TestCustomInstructionsService;
104
let provider: CopilotCLICustomizationProvider;
105
106
let originalChatSessionCustomizationType: unknown;
107
108
beforeEach(() => {
109
originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;
110
(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;
111
disposables = new DisposableStore();
112
mockPromptsService = disposables.add(new MockPromptsService());
113
mockCopilotCLIAgents = disposables.add(new MockCopilotCLIAgents());
114
mockCustomInstructionsService = new TestCustomInstructionsService();
115
provider = disposables.add(new CopilotCLICustomizationProvider(
116
mockCopilotCLIAgents,
117
mockCustomInstructionsService,
118
mockPromptsService,
119
new TestLogService(),
120
{ getWorkspaceFolders: () => [] } as any,
121
{ stat: () => Promise.reject(new Error('not found')) } as any,
122
));
123
});
124
125
afterEach(() => {
126
disposables.dispose();
127
(vscode as Record<string, unknown>).ChatSessionCustomizationType = originalChatSessionCustomizationType;
128
});
129
130
describe('metadata', () => {
131
it('has correct label and icon', () => {
132
expect(CopilotCLICustomizationProvider.metadata.label).toBe('Copilot CLI');
133
expect(CopilotCLICustomizationProvider.metadata.iconId).toBe('copilot');
134
});
135
136
it('supports Agent, Skill, Instructions, Hook, and Plugins types', () => {
137
const supported = CopilotCLICustomizationProvider.metadata.supportedTypes;
138
expect(supported).toBeDefined();
139
expect(supported).toHaveLength(5);
140
expect(supported).toContain(FakeChatSessionCustomizationType.Agent);
141
expect(supported).toContain(FakeChatSessionCustomizationType.Skill);
142
expect(supported).toContain(FakeChatSessionCustomizationType.Instructions);
143
expect(supported).toContain(FakeChatSessionCustomizationType.Hook);
144
expect(supported).toContain(FakeChatSessionCustomizationType.Plugins);
145
});
146
147
it('only returns items whose type is in supportedTypes', async () => {
148
mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);
149
const items = await provider.provideChatSessionCustomizations(undefined!);
150
const supported = new Set(CopilotCLICustomizationProvider.metadata.supportedTypes!.map(t => t.id));
151
for (const item of items) {
152
expect(supported.has(item.type.id), `item "${item.name}" has type "${item.type.id}" not in supportedTypes`).toBe(true);
153
}
154
});
155
156
it('does not set groupKey for items with synthetic URIs (vscode infers grouping)', async () => {
157
mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);
158
const items = await provider.provideChatSessionCustomizations(undefined!);
159
const builtinItems = items.filter(i => i.uri.scheme !== 'file');
160
for (const item of builtinItems) {
161
expect(item.groupKey, `item "${item.name}" should not have groupKey (vscode infers)`).toBeUndefined();
162
}
163
});
164
});
165
166
describe('provideChatSessionCustomizations', () => {
167
it('returns empty array when no files exist', async () => {
168
const items = await provider.provideChatSessionCustomizations(undefined!);
169
expect(items).toEqual([]);
170
});
171
172
it('returns agents from ICopilotCLIAgents with source URIs', async () => {
173
mockCopilotCLIAgents.setAgents([
174
makeAgentInfo('explore', 'Fast code exploration'),
175
makeAgentInfo('task', 'Multi-step tasks'),
176
]);
177
178
const items = await provider.provideChatSessionCustomizations(undefined!);
179
const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);
180
expect(agentItems).toHaveLength(2);
181
expect(agentItems[0].name).toBe('explore');
182
expect(agentItems[0].description).toBe('Fast code exploration');
183
});
184
185
it('uses file URI from sourceUri for file-backed agents', async () => {
186
const fileUri = URI.file('/workspace/.github/explore.agent.md');
187
mockCopilotCLIAgents.setAgents([makeFileAgentInfo('explore', fileUri, 'Explore agent')]);
188
189
const items = await provider.provideChatSessionCustomizations(undefined!);
190
const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);
191
expect(agentItems).toHaveLength(1);
192
expect(agentItems[0].uri).toEqual(fileUri);
193
expect(agentItems[0].groupKey).toBeUndefined();
194
});
195
196
it('uses synthetic URI for SDK-only agents', async () => {
197
mockCopilotCLIAgents.setAgents([makeAgentInfo('task', 'Task agent')]);
198
199
const items = await provider.provideChatSessionCustomizations(undefined!);
200
const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);
201
expect(agentItems).toHaveLength(1);
202
expect(agentItems[0].uri.scheme).toBe('copilotcli');
203
expect(agentItems[0].uri.path).toBe('/agents/task');
204
expect(agentItems[0].groupKey).toBeUndefined();
205
});
206
207
it('uses displayName from agents when available', async () => {
208
mockCopilotCLIAgents.setAgents([makeAgentInfo('code-review', 'Reviews code', 'Code Review')]);
209
210
const items = await provider.provideChatSessionCustomizations(undefined!);
211
expect(items[0].name).toBe('Code Review');
212
});
213
214
it('returns instructions with on-demand groupKey when no applyTo pattern', async () => {
215
const uri = URI.file('/workspace/.github/copilot-instructions.md');
216
mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]);
217
218
const items = await provider.provideChatSessionCustomizations(undefined!);
219
expect(items).toHaveLength(1);
220
expect(items[0].uri).toBe(uri);
221
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions);
222
expect(items[0].groupKey).toBe('on-demand-instructions');
223
});
224
225
it('returns skills', async () => {
226
const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md');
227
mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]);
228
229
const items = await provider.provideChatSessionCustomizations(undefined!);
230
expect(items).toHaveLength(1);
231
expect(items[0].uri).toBe(uri);
232
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Skill);
233
expect(items[0].name).toBe('lint-check');
234
});
235
236
it('derives skill name from parent directory for SKILL.md files', async () => {
237
const uri = URI.file('/workspace/.copilot/skills/my-skill/SKILL.md');
238
mockPromptsService.setSkills([makeSkill(uri, 'my-skill')]);
239
240
const items = await provider.provideChatSessionCustomizations(undefined!);
241
expect(items).toHaveLength(1);
242
expect(items[0].name).toBe('my-skill');
243
});
244
245
it('returns all matching types combined', async () => {
246
mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);
247
mockPromptsService.setInstructions([makeInstruction(URI.file('/workspace/.github/b.instructions.md'), 'b instructions', undefined)]);
248
mockPromptsService.setSkills([makeSkill(URI.file('/workspace/.github/skills/c/SKILL.md'), 'c')]);
249
mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/pre-commit.json'))]);
250
mockPromptsService.setPlugins([makePlugin(URI.file('/workspace/.copilot/plugins/my-plugin'))]);
251
252
const items = await provider.provideChatSessionCustomizations(undefined!);
253
expect(items).toHaveLength(5);
254
});
255
256
it('returns hooks with correct type and name', async () => {
257
const uri = URI.file('/workspace/.copilot/hooks/diagnostics.json');
258
mockPromptsService.setHooks([makeHook(uri)]);
259
260
const items = await provider.provideChatSessionCustomizations(undefined!);
261
expect(items).toHaveLength(1);
262
expect(items[0].uri).toBe(uri);
263
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Hook);
264
expect(items[0].name).toBe('diagnostics');
265
});
266
267
it('strips .json extension from hook file name', async () => {
268
mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/security-checks.json'))]);
269
270
const items = await provider.provideChatSessionCustomizations(undefined!);
271
expect(items[0].name).toBe('security-checks');
272
});
273
274
it('returns multiple hooks', async () => {
275
mockPromptsService.setHooks([
276
makeHook(URI.file('/workspace/.copilot/hooks/hooks.json')),
277
makeHook(URI.file('/workspace/.copilot/hooks/diagnostics.json')),
278
]);
279
280
const items = await provider.provideChatSessionCustomizations(undefined!);
281
const hookItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Hook);
282
expect(hookItems).toHaveLength(2);
283
});
284
285
it('returns plugins with correct type and name derived from URI', async () => {
286
const uri = URI.file('/workspace/.copilot/plugins/lint-rules');
287
mockPromptsService.setPlugins([makePlugin(uri)]);
288
289
const items = await provider.provideChatSessionCustomizations(undefined!);
290
expect(items).toHaveLength(1);
291
expect(items[0].uri).toEqual(uri);
292
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Plugins);
293
expect(items[0].name).toBe('lint-rules');
294
});
295
});
296
297
describe('instruction groupKeys and badges', () => {
298
it('uses agent-instructions groupKey for copilot-instructions.md files', async () => {
299
const uri = URI.file('/workspace/.github/copilot-instructions.md');
300
mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]);
301
mockCustomInstructionsService.setAgentInstructionUris([uri]);
302
303
const items = await provider.provideChatSessionCustomizations(undefined!);
304
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
305
expect(instrItems).toHaveLength(1);
306
expect(instrItems[0].groupKey).toBe('agent-instructions');
307
expect(instrItems[0].badge).toBeUndefined();
308
});
309
310
it('emits agent instructions not in chatPromptFileService.instructions', async () => {
311
const agentsUri = URI.file('/workspace/AGENTS.md');
312
const claudeUri = URI.file('/workspace/CLAUDE.md');
313
const copilotUri = URI.file('/workspace/.github/copilot-instructions.md');
314
// Agent instructions are NOT in chatPromptFileService.instructions —
315
// they come only from customInstructionsService.getAgentInstructions().
316
mockPromptsService.setInstructions([]);
317
mockCustomInstructionsService.setAgentInstructionUris([agentsUri, claudeUri, copilotUri]);
318
319
const items = await provider.provideChatSessionCustomizations(undefined!);
320
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
321
expect(instrItems).toHaveLength(3);
322
expect(instrItems.every(i => i.groupKey === 'agent-instructions')).toBe(true);
323
expect(instrItems.map(i => i.name)).toEqual(['AGENTS.md', 'CLAUDE.md', 'copilot-instructions.md']);
324
});
325
326
it('discovers AGENTS.md and CLAUDE.md from workspace roots via filesystem', async () => {
327
const workspaceRoot = URI.file('/workspace');
328
const agentsUri = URI.file('/workspace/AGENTS.md');
329
const claudeUri = URI.file('/workspace/CLAUDE.md');
330
const existingUris = new Set([agentsUri.toString(), claudeUri.toString()]);
331
332
const testProvider = disposables.add(new CopilotCLICustomizationProvider(
333
mockCopilotCLIAgents,
334
mockCustomInstructionsService,
335
mockPromptsService,
336
new TestLogService(),
337
{ getWorkspaceFolders: () => [workspaceRoot] } as any,
338
{
339
stat: (uri: URI) => existingUris.has(uri.toString())
340
? Promise.resolve({ type: 1, ctime: 0, mtime: 0, size: 0 })
341
: Promise.reject(new Error('not found')),
342
} as any,
343
));
344
345
mockPromptsService.setInstructions([]);
346
mockCustomInstructionsService.setAgentInstructionUris([]);
347
348
const items = await testProvider.provideChatSessionCustomizations(undefined!);
349
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
350
expect(instrItems).toHaveLength(2);
351
expect(instrItems.every(i => i.groupKey === 'agent-instructions')).toBe(true);
352
expect(instrItems.map(i => i.name)).toEqual(['AGENTS.md', 'CLAUDE.md']);
353
});
354
355
it('uses context-instructions groupKey with badge for instructions with applyTo pattern', async () => {
356
const uri = URI.file('/workspace/.github/style.instructions.md');
357
mockPromptsService.setInstructions([makeInstruction(uri, 'style instructions', 'src/**/*.ts')]);
358
359
const items = await provider.provideChatSessionCustomizations(undefined!);
360
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
361
expect(instrItems).toHaveLength(1);
362
expect(instrItems[0].groupKey).toBe('context-instructions');
363
expect(instrItems[0].badge).toBe('src/**/*.ts');
364
expect(instrItems[0].badgeTooltip).toContain('src/**/*.ts');
365
});
366
367
it('uses "always added" badge when applyTo is **', async () => {
368
const uri = URI.file('/workspace/.github/global.instructions.md');
369
mockPromptsService.setInstructions([makeInstruction(uri, 'global instructions', '**')]);
370
371
const items = await provider.provideChatSessionCustomizations(undefined!);
372
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
373
expect(instrItems).toHaveLength(1);
374
expect(instrItems[0].groupKey).toBe('context-instructions');
375
expect(instrItems[0].badge).toBe('always added');
376
expect(instrItems[0].badgeTooltip).toContain('every interaction');
377
});
378
379
it('uses on-demand-instructions groupKey for instructions without applyTo', async () => {
380
const uri = URI.file('/workspace/.github/refactor.instructions.md');
381
mockPromptsService.setInstructions([makeInstruction(uri, 'refactor instructions', undefined, 'Refactoring guidelines')]);
382
383
const items = await provider.provideChatSessionCustomizations(undefined!);
384
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
385
expect(instrItems).toHaveLength(1);
386
expect(instrItems[0].groupKey).toBe('on-demand-instructions');
387
expect(instrItems[0].badge).toBeUndefined();
388
expect(instrItems[0].description).toBe('Refactoring guidelines');
389
});
390
391
it('includes description from parsed header', async () => {
392
const uri = URI.file('/workspace/.github/testing.instructions.md');
393
mockPromptsService.setInstructions([makeInstruction(uri, 'testing instructions', '**/*.spec.ts', 'Testing standards')]);
394
395
const items = await provider.provideChatSessionCustomizations(undefined!);
396
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
397
expect(instrItems).toHaveLength(1);
398
expect(instrItems[0].description).toBe('Testing standards');
399
expect(instrItems[0].badge).toBe('**/*.spec.ts');
400
});
401
402
it('categorizes mixed instructions correctly', async () => {
403
const agentUri = URI.file('/workspace/.github/copilot-instructions.md');
404
const contextUri = URI.file('/workspace/.github/style.instructions.md');
405
const onDemandUri = URI.file('/workspace/.github/refactor.instructions.md');
406
mockPromptsService.setInstructions([makeInstruction(agentUri, 'copilot instructions', undefined), makeInstruction(contextUri, 'style instructions', 'src/**'), makeInstruction(onDemandUri, 'refactor instructions', undefined)]);
407
mockCustomInstructionsService.setAgentInstructionUris([agentUri]);
408
mockPromptsService.setFileContent(contextUri, '---\napplyTo: \'src/**\'\n---\nStyle rules.');
409
mockPromptsService.setFileContent(onDemandUri, '---\ndescription: Refactoring\n---\nRefactor tips.');
410
411
const items = await provider.provideChatSessionCustomizations(undefined!);
412
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
413
expect(instrItems).toHaveLength(3);
414
415
const agent = instrItems.find(i => i.groupKey === 'agent-instructions');
416
const context = instrItems.find(i => i.groupKey === 'context-instructions');
417
const onDemand = instrItems.find(i => i.groupKey === 'on-demand-instructions');
418
419
expect(agent).toBeDefined();
420
expect(agent!.uri).toBe(agentUri);
421
422
expect(context).toBeDefined();
423
expect(context!.badge).toBe('src/**');
424
425
expect(onDemand).toBeDefined();
426
expect(onDemand!.badge).toBeUndefined();
427
});
428
429
it('falls back to on-demand-instructions when file has no YAML header', async () => {
430
const uri = URI.file('/workspace/.github/plain.instructions.md');
431
mockPromptsService.setInstructions([makeInstruction(uri, 'plain instructions', undefined)]);
432
mockPromptsService.setFileContent(uri, 'Just plain text, no frontmatter.');
433
434
const items = await provider.provideChatSessionCustomizations(undefined!);
435
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
436
expect(instrItems).toHaveLength(1);
437
expect(instrItems[0].groupKey).toBe('on-demand-instructions');
438
expect(instrItems[0].badge).toBeUndefined();
439
});
440
});
441
442
describe('onDidChange', () => {
443
it('fires when custom agents change', () => {
444
let fired = false;
445
disposables.add(provider.onDidChange(() => { fired = true; }));
446
447
mockPromptsService.fireCustomAgentsChanged();
448
expect(fired).toBe(true);
449
});
450
451
it('fires when instructions change', () => {
452
let fired = false;
453
disposables.add(provider.onDidChange(() => { fired = true; }));
454
455
mockPromptsService.fireInstructionsChanged();
456
expect(fired).toBe(true);
457
});
458
459
it('fires when skills change', () => {
460
let fired = false;
461
disposables.add(provider.onDidChange(() => { fired = true; }));
462
463
mockPromptsService.fireSkillsChanged();
464
expect(fired).toBe(true);
465
});
466
467
it('fires when hooks change', () => {
468
let fired = false;
469
disposables.add(provider.onDidChange(() => { fired = true; }));
470
471
mockPromptsService.fireHooksChanged();
472
expect(fired).toBe(true);
473
});
474
475
it('fires when plugins change', () => {
476
let fired = false;
477
disposables.add(provider.onDidChange(() => { fired = true; }));
478
479
mockPromptsService.firePluginsChanged();
480
expect(fired).toBe(true);
481
});
482
483
it('fires when ICopilotCLIAgents agents change', () => {
484
let fired = false;
485
disposables.add(provider.onDidChange(() => { fired = true; }));
486
487
mockCopilotCLIAgents.fireAgentsChanged();
488
expect(fired).toBe(true);
489
});
490
});
491
});
492
493