Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.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 assert from 'assert';
7
import { Emitter } from '../../../../../base/common/event.js';
8
import { URI } from '../../../../../base/common/uri.js';
9
import { ThemeIcon } from '../../../../../base/common/themables.js';
10
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
11
import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js';
12
import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js';
13
import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';
14
import { CancellationToken } from '../../../../../base/common/cancellation.js';
15
import { SessionType } from '../../common/chatSessionsService.js';
16
import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js';
17
18
suite('CustomizationHarnessService', () => {
19
const store = ensureNoDisposablesAreLeakedInTestSuite();
20
21
function createService(...harnesses: IHarnessDescriptor[]): CustomizationHarnessServiceBase {
22
if (harnesses.length === 0) {
23
harnesses = [createVSCodeHarnessDescriptor([PromptsStorage.extension])];
24
}
25
const promptsService: IPromptsService = new MockPromptsService();
26
const service = new CustomizationHarnessServiceBase(harnesses, harnesses[0].id, promptsService);
27
store.add(service);
28
return service;
29
}
30
31
suite('registerExternalHarness', () => {
32
test('forwards item provider changes via onDidChangeSlashCommands with sessionType', () => {
33
const service = createService();
34
const emitter = new Emitter<void>();
35
store.add(emitter);
36
const harnessId = 'test-harness';
37
const externalDescriptor: IHarnessDescriptor = {
38
id: harnessId,
39
label: 'Test Harness',
40
icon: ThemeIcon.fromId('extensions'),
41
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
42
itemProvider: {
43
onDidChange: emitter.event,
44
provideChatSessionCustomizations: async () => [],
45
},
46
};
47
48
store.add(service.registerExternalHarness(externalDescriptor));
49
50
let firedSessionType: string | undefined;
51
const listener = store.add(service.onDidChangeSlashCommands(e => firedSessionType = e.sessionType));
52
store.add(listener);
53
54
emitter.fire();
55
assert.strictEqual(firedSessionType, harnessId);
56
});
57
58
test('forwards item provider changes via onDidChangeCustomAgents with sessionType', () => {
59
const service = createService();
60
const emitter = new Emitter<void>();
61
store.add(emitter);
62
const harnessId = 'test-harness';
63
const externalDescriptor: IHarnessDescriptor = {
64
id: harnessId,
65
label: 'Test Harness',
66
icon: ThemeIcon.fromId('extensions'),
67
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
68
itemProvider: {
69
onDidChange: emitter.event,
70
provideChatSessionCustomizations: async () => [],
71
},
72
};
73
74
store.add(service.registerExternalHarness(externalDescriptor));
75
76
let firedSessionType: string | undefined;
77
const listener = store.add(service.onDidChangeCustomAgents(e => firedSessionType = e.sessionType));
78
store.add(listener);
79
80
emitter.fire();
81
assert.strictEqual(firedSessionType, harnessId);
82
});
83
84
test('adds harness to available list', () => {
85
const service = createService();
86
assert.strictEqual(service.availableHarnesses.get().length, 1);
87
88
const emitter = new Emitter<void>();
89
store.add(emitter);
90
const externalDescriptor: IHarnessDescriptor = {
91
id: 'test-ext',
92
label: 'Test Extension',
93
icon: ThemeIcon.fromId('extensions'),
94
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
95
itemProvider: {
96
onDidChange: emitter.event,
97
provideChatSessionCustomizations: async () => [],
98
},
99
};
100
101
const reg = service.registerExternalHarness(externalDescriptor);
102
store.add(reg);
103
104
assert.strictEqual(service.availableHarnesses.get().length, 2);
105
assert.strictEqual(service.availableHarnesses.get()[1].id, 'test-ext');
106
});
107
108
test('removes harness on dispose', () => {
109
const service = createService();
110
const emitter = new Emitter<void>();
111
store.add(emitter);
112
const externalDescriptor: IHarnessDescriptor = {
113
id: 'test-ext',
114
label: 'Test Extension',
115
icon: ThemeIcon.fromId('extensions'),
116
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
117
itemProvider: {
118
onDidChange: emitter.event,
119
provideChatSessionCustomizations: async () => [],
120
},
121
};
122
123
const reg = service.registerExternalHarness(externalDescriptor);
124
assert.strictEqual(service.availableHarnesses.get().length, 2);
125
126
reg.dispose();
127
assert.strictEqual(service.availableHarnesses.get().length, 1);
128
});
129
130
test('falls back to first harness when active external harness is removed', () => {
131
const service = createService();
132
const emitter = new Emitter<void>();
133
store.add(emitter);
134
const externalDescriptor: IHarnessDescriptor = {
135
id: 'test-ext',
136
label: 'Test Extension',
137
icon: ThemeIcon.fromId('extensions'),
138
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
139
itemProvider: {
140
onDidChange: emitter.event,
141
provideChatSessionCustomizations: async () => [],
142
},
143
};
144
145
const reg = service.registerExternalHarness(externalDescriptor);
146
service.setActiveHarness('test-ext');
147
assert.strictEqual(service.activeHarness.get(), 'test-ext');
148
149
reg.dispose();
150
assert.strictEqual(service.activeHarness.get(), SessionType.Local);
151
});
152
153
test('allows switching to external harness', () => {
154
const service = createService();
155
const emitter = new Emitter<void>();
156
store.add(emitter);
157
const externalDescriptor: IHarnessDescriptor = {
158
id: 'test-ext',
159
label: 'Test Extension',
160
icon: ThemeIcon.fromId('extensions'),
161
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
162
itemProvider: {
163
onDidChange: emitter.event,
164
provideChatSessionCustomizations: async () => [],
165
},
166
};
167
168
store.add(service.registerExternalHarness(externalDescriptor));
169
service.setActiveHarness('test-ext');
170
assert.strictEqual(service.activeHarness.get(), 'test-ext');
171
172
const activeDescriptor = service.getActiveDescriptor();
173
assert.strictEqual(activeDescriptor.id, 'test-ext');
174
assert.strictEqual(activeDescriptor.label, 'Test Extension');
175
assert.ok(activeDescriptor.itemProvider);
176
});
177
178
test('external harness provides storage filter', () => {
179
const service = createService();
180
const emitter = new Emitter<void>();
181
store.add(emitter);
182
const customFilter = { sources: [PromptsStorage.local, PromptsStorage.user] };
183
const externalDescriptor: IHarnessDescriptor = {
184
id: 'test-ext',
185
label: 'Test Extension',
186
icon: ThemeIcon.fromId('extensions'),
187
getStorageSourceFilter: () => customFilter,
188
itemProvider: {
189
onDidChange: emitter.event,
190
provideChatSessionCustomizations: async () => [],
191
},
192
};
193
194
store.add(service.registerExternalHarness(externalDescriptor));
195
service.setActiveHarness('test-ext');
196
assert.deepStrictEqual(service.getStorageSourceFilter(PromptsType.agent), customFilter);
197
});
198
199
test('external harness item provider returns items', async () => {
200
const service = createService();
201
const emitter = new Emitter<void>();
202
store.add(emitter);
203
const testItems = [
204
{ uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
205
];
206
207
const itemProvider: ICustomizationItemProvider = {
208
onDidChange: emitter.event,
209
provideChatSessionCustomizations: async () => testItems,
210
};
211
212
const externalDescriptor: IHarnessDescriptor = {
213
id: 'test-ext',
214
label: 'Test Extension',
215
icon: ThemeIcon.fromId('extensions'),
216
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
217
itemProvider,
218
};
219
220
store.add(service.registerExternalHarness(externalDescriptor));
221
service.setActiveHarness('test-ext');
222
223
const items = await service.getActiveDescriptor().itemProvider!.provideChatSessionCustomizations(CancellationToken.None);
224
assert.strictEqual(items?.length, 1);
225
assert.strictEqual(items![0].name, 'Test Skill');
226
assert.strictEqual(items![0].type, 'skill');
227
});
228
229
test('external harness with hidden sections and workspace subpaths', () => {
230
const service = createService();
231
const emitter = new Emitter<void>();
232
store.add(emitter);
233
const externalDescriptor: IHarnessDescriptor = {
234
id: 'test-ext',
235
label: 'Test Extension',
236
icon: ThemeIcon.fromId('extensions'),
237
hiddenSections: ['agents', 'prompts'],
238
workspaceSubpaths: ['.test-ext'],
239
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
240
itemProvider: {
241
onDidChange: emitter.event,
242
provideChatSessionCustomizations: async () => [],
243
},
244
};
245
246
store.add(service.registerExternalHarness(externalDescriptor));
247
service.setActiveHarness('test-ext');
248
249
const descriptor = service.getActiveDescriptor();
250
assert.deepStrictEqual(descriptor.hiddenSections, ['agents', 'prompts']);
251
assert.deepStrictEqual(descriptor.workspaceSubpaths, ['.test-ext']);
252
});
253
254
test('external harness with same id as static harness replaces it', () => {
255
const staticDescriptor: IHarnessDescriptor = {
256
id: 'cli',
257
label: 'Copilot CLI (static)',
258
icon: ThemeIcon.fromId('extensions'),
259
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
260
};
261
const service = createService(
262
createVSCodeHarnessDescriptor([PromptsStorage.extension]),
263
staticDescriptor,
264
);
265
assert.strictEqual(service.availableHarnesses.get().length, 2);
266
267
const emitter = new Emitter<void>();
268
store.add(emitter);
269
const externalDescriptor: IHarnessDescriptor = {
270
id: 'cli',
271
label: 'Copilot CLI (from API)',
272
icon: ThemeIcon.fromId('extensions'),
273
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
274
itemProvider: {
275
onDidChange: emitter.event,
276
provideChatSessionCustomizations: async () => [],
277
},
278
};
279
280
const reg = service.registerExternalHarness(externalDescriptor);
281
store.add(reg);
282
283
// Should still be 2, not 3 — the external shadows the static
284
assert.strictEqual(service.availableHarnesses.get().length, 2);
285
const cliHarness = service.availableHarnesses.get().find(h => h.id === 'cli')!;
286
assert.strictEqual(cliHarness.label, 'Copilot CLI (from API)');
287
});
288
289
test('static harness reappears when shadowing external harness is disposed', () => {
290
const staticDescriptor: IHarnessDescriptor = {
291
id: 'cli',
292
label: 'Copilot CLI (static)',
293
icon: ThemeIcon.fromId('extensions'),
294
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
295
};
296
const service = createService(
297
createVSCodeHarnessDescriptor([PromptsStorage.extension]),
298
staticDescriptor,
299
);
300
301
const emitter = new Emitter<void>();
302
store.add(emitter);
303
const externalDescriptor: IHarnessDescriptor = {
304
id: 'cli',
305
label: 'Copilot CLI (from API)',
306
icon: ThemeIcon.fromId('extensions'),
307
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
308
itemProvider: {
309
onDidChange: emitter.event,
310
provideChatSessionCustomizations: async () => [],
311
},
312
};
313
314
const reg = service.registerExternalHarness(externalDescriptor);
315
reg.dispose();
316
317
// Static harness should be back
318
assert.strictEqual(service.availableHarnesses.get().length, 2);
319
const cliHarness = service.availableHarnesses.get().find(h => h.id === 'cli')!;
320
assert.strictEqual(cliHarness.label, 'Copilot CLI (static)');
321
});
322
323
test('active harness stays when shadowing external harness is disposed (static restored)', () => {
324
const staticDescriptor: IHarnessDescriptor = {
325
id: 'cli',
326
label: 'Copilot CLI (static)',
327
icon: ThemeIcon.fromId('extensions'),
328
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
329
};
330
const service = createService(
331
createVSCodeHarnessDescriptor([PromptsStorage.extension]),
332
staticDescriptor,
333
);
334
335
const emitter = new Emitter<void>();
336
store.add(emitter);
337
const externalDescriptor: IHarnessDescriptor = {
338
id: 'cli',
339
label: 'Copilot CLI (from API)',
340
icon: ThemeIcon.fromId('extensions'),
341
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
342
itemProvider: {
343
onDidChange: emitter.event,
344
provideChatSessionCustomizations: async () => [],
345
},
346
};
347
348
const reg = service.registerExternalHarness(externalDescriptor);
349
service.setActiveHarness('cli');
350
assert.strictEqual(service.activeHarness.get(), 'cli');
351
352
reg.dispose();
353
354
// Active stays on 'cli' because the static harness with the same id is restored
355
assert.strictEqual(service.activeHarness.get(), 'cli');
356
});
357
});
358
359
suite('getSlashCommands', () => {
360
test('uses the active harness provider for prompt and skill items', async () => {
361
362
363
const testSessionType = 'test-session-type';
364
365
const emitter = new Emitter<void>();
366
store.add(emitter);
367
const service = createService({
368
id: testSessionType,
369
label: 'Test Extension',
370
icon: ThemeIcon.fromId('extensions'),
371
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
372
itemProvider: {
373
onDidChange: emitter.event,
374
provideChatSessionCustomizations: async () => [
375
{ uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
376
{ uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
377
{ uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
378
{ uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
379
],
380
},
381
});
382
383
const commands = await service.getSlashCommands(testSessionType, CancellationToken.None);
384
assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type })), [
385
{ name: 'fix', type: PromptsType.prompt },
386
{ name: 'lint', type: PromptsType.skill },
387
]);
388
});
389
390
test('falls back to promptsService when the active harness has no provider', async () => {
391
392
const testSessionType = 'test-session-type';
393
const promptsService = new class extends MockPromptsService {
394
override async getPromptSlashCommands() {
395
return [
396
{ uri: URI.parse('file:///workspace/.github/prompts/explain.prompt.md'), name: 'explain', type: PromptsType.prompt, storage: PromptsStorage.local, userInvocable: false, sessionTypes: [testSessionType] },
397
{ uri: URI.parse('file:///workspace/.github/skills/review/SKILL.md'), name: 'review', type: PromptsType.skill, storage: PromptsStorage.user, userInvocable: true },
398
];
399
}
400
override isValidSlashCommandName() { return true; }
401
};
402
const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService);
403
store.add(service);
404
{
405
const commands = await service.getSlashCommands(testSessionType, CancellationToken.None);
406
assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [
407
{ name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: [testSessionType] },
408
{ name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined },
409
]);
410
}
411
{
412
const commands = await service.getSlashCommands(SessionType.Local, CancellationToken.None);
413
assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [
414
{ name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined },
415
]);
416
}
417
});
418
});
419
420
suite('getCustomAgents', () => {
421
const createAgent = (name: string, path: string, sessionTypes: readonly string[] | undefined, enabled: boolean): ICustomAgent => ({
422
uri: URI.parse(path),
423
name,
424
target: Target.GitHubCopilot,
425
visibility: { userInvocable: true, agentInvocable: true },
426
agentInstructions: { content: '', toolReferences: [] },
427
source: { storage: PromptsStorage.local },
428
sessionTypes,
429
enabled,
430
});
431
432
test('falls back to promptsService and filters by session type', async () => {
433
const testSessionType = 'test-session-type';
434
const promptsService = new MockPromptsService();
435
promptsService.setCustomModes([
436
createAgent('matching', 'file:///workspace/.github/agents/matching.agent.md', [testSessionType], true),
437
createAgent('global', 'file:///workspace/.github/agents/global.agent.md', undefined, true),
438
createAgent('other', 'file:///workspace/.github/agents/other.agent.md', ['other-session'], true),
439
]);
440
const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService);
441
store.add(service);
442
443
const agents = await service.getCustomAgents(testSessionType, CancellationToken.None);
444
assert.deepStrictEqual(agents.map(agent => agent.name), ['matching', 'global']);
445
});
446
447
test('uses provider item URIs to scope resolved custom agents', async () => {
448
const testSessionType1 = 'test-session-type1';
449
const testSessionType2 = 'test-session-type2';
450
const promptsService = new MockPromptsService();
451
promptsService.setCustomModes([
452
createAgent('selected', 'file:///workspace/.test/agents/selected.agent.md', undefined, true),
453
createAgent('not-selected', 'file:///workspace/.test/agents/not-selected.agent.md', undefined, false),
454
]);
455
456
const emitter = new Emitter<void>();
457
store.add(emitter);
458
const service = new CustomizationHarnessServiceBase([{
459
id: testSessionType1,
460
label: 'Test Extension',
461
icon: ThemeIcon.fromId('extensions'),
462
getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),
463
itemProvider: {
464
onDidChange: emitter.event,
465
provideChatSessionCustomizations: async () => [
466
{ uri: URI.parse('file:///workspace/.test/agents/enabled.agent.md'), type: PromptsType.agent, name: 'enabled', enabled: true, extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
467
{ uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined },
468
],
469
},
470
}], testSessionType1, promptsService);
471
store.add(service);
472
{
473
const agents = (await service.getCustomAgents(testSessionType1, CancellationToken.None));
474
assert.deepStrictEqual(agents.map(agent => [agent.name, agent.enabled]), [['enabled', true], ['disabled', false]]);
475
}
476
{
477
const agents = (await service.getCustomAgents(testSessionType2, CancellationToken.None));
478
assert.deepStrictEqual(agents.map(agent => [agent.name, agent.enabled]), [['selected', true], ['not-selected', false]]);
479
}
480
});
481
});
482
483
suite('matchesWorkspaceSubpath', () => {
484
test('matches segment boundary', () => {
485
assert.ok(matchesWorkspaceSubpath('/workspace/.claude/skills/SKILL.md', ['.claude']));
486
assert.ok(matchesWorkspaceSubpath('/workspace/.github/instructions.md', ['.github']));
487
});
488
489
test('does not match partial segment', () => {
490
assert.ok(!matchesWorkspaceSubpath('/workspace/not.claude/file.md', ['.claude']));
491
});
492
493
test('matches path ending with subpath', () => {
494
assert.ok(matchesWorkspaceSubpath('/workspace/.claude', ['.claude']));
495
});
496
497
test('matches any of multiple subpaths', () => {
498
assert.ok(matchesWorkspaceSubpath('/workspace/.copilot/file.md', ['.github', '.copilot']));
499
assert.ok(matchesWorkspaceSubpath('/workspace/.github/file.md', ['.github', '.copilot']));
500
});
501
});
502
});
503
504