Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts
13405 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 'chai';
7
import * as os from 'os';
8
import * as path from 'path';
9
import { afterEach, beforeEach, suite, test } from 'vitest';
10
import * as vscode from 'vscode';
11
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
12
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
13
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
14
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
15
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
16
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
17
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
18
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
19
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
20
import { createExtensionUnitTestingServices } from '../../../test/node/services';
21
import { buildAgentMarkdown, DEFAULT_READ_TOOLS } from '../agentTypes';
22
import { PlanAgentProvider } from '../planAgentProvider';
23
24
suite('PlanAgentProvider', () => {
25
let disposables: DisposableStore;
26
let mockConfigurationService: InMemoryConfigurationService;
27
let fileSystemService: IFileSystemService;
28
let accessor: ITestingServicesAccessor;
29
let instantiationService: IInstantiationService;
30
31
beforeEach(() => {
32
disposables = new DisposableStore();
33
34
// Set up testing services with a mock extension context that has globalStorageUri
35
const testingServiceCollection = createExtensionUnitTestingServices(disposables);
36
const globalStoragePath = path.join(os.tmpdir(), 'plan-agent-test-' + Date.now());
37
testingServiceCollection.define(IVSCodeExtensionContext, new SyncDescriptor(MockExtensionContext, [globalStoragePath]));
38
accessor = testingServiceCollection.createTestingAccessor();
39
disposables.add(accessor);
40
instantiationService = accessor.get(IInstantiationService);
41
42
mockConfigurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService;
43
fileSystemService = accessor.get(IFileSystemService);
44
});
45
46
afterEach(() => {
47
disposables.dispose();
48
});
49
50
function createProvider() {
51
const provider = instantiationService.createInstance(PlanAgentProvider);
52
disposables.add(provider);
53
return provider;
54
}
55
56
async function getAgentContent(agent: vscode.ChatResource): Promise<string> {
57
const content = await fileSystemService.readFile(agent.uri);
58
return new TextDecoder().decode(content);
59
}
60
61
test('provideCustomAgents() returns a Plan agent with correct structure', async () => {
62
const provider = createProvider();
63
64
const agents = await provider.provideCustomAgents({}, {} as any);
65
66
assert.equal(agents.length, 1);
67
assert.ok(agents[0].uri, 'Agent should have a URI');
68
assert.ok(agents[0].uri.path.endsWith('.agent.md'), 'Agent URI should end with .agent.md');
69
});
70
71
test('returns agent content with base frontmatter when no settings configured', async () => {
72
const provider = createProvider();
73
74
const agents = await provider.provideCustomAgents({}, {} as any);
75
76
assert.equal(agents.length, 1);
77
const content = await getAgentContent(agents[0]);
78
79
// Should contain base tools
80
assert.ok(content.includes('github/issue_read'));
81
assert.ok(content.includes('agent'));
82
assert.ok(content.includes('search'));
83
assert.ok(content.includes('read'));
84
assert.ok(content.includes('memory'));
85
86
// Should not have model override (not in base content)
87
assert.ok(content.includes('name: Plan'));
88
assert.ok(content.includes('description: Researches and outlines multi-step plans'));
89
});
90
91
test('merges additionalTools setting with base tools', async () => {
92
await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['customTool1', 'customTool2']);
93
94
const provider = createProvider();
95
const agents = await provider.provideCustomAgents({}, {} as any);
96
97
assert.equal(agents.length, 1);
98
const content = await getAgentContent(agents[0]);
99
100
// Should contain base tools
101
assert.ok(content.includes('github/issue_read'));
102
assert.ok(content.includes('agent'));
103
104
// Should contain additional tools
105
assert.ok(content.includes('customTool1'));
106
assert.ok(content.includes('customTool2'));
107
});
108
109
test('deduplicates tools when additionalTools overlaps with base tools', async () => {
110
// Add a tool that already exists in base
111
await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['agent', 'newTool']);
112
113
const provider = createProvider();
114
const agents = await provider.provideCustomAgents({}, {} as any);
115
116
assert.equal(agents.length, 1);
117
const content = await getAgentContent(agents[0]);
118
119
// Count occurrences of 'agent' in tools list (flow-style array)
120
// Should appear only once due to deduplication
121
const toolsMatch = content.match(/tools: \[([^\]]+)\]/);
122
assert.ok(toolsMatch, 'Tools list not found in agent content');
123
const toolsSection = toolsMatch[1];
124
const agentCount = (toolsSection.match(/'agent'/g) || []).length;
125
assert.equal(agentCount, 1, 'agent tool should appear only once after deduplication');
126
127
// Should contain new tool
128
assert.ok(content.includes('newTool'));
129
});
130
131
test('applies model override from settings', async () => {
132
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'Claude Haiku 4.5 (copilot)');
133
134
const provider = createProvider();
135
const agents = await provider.provideCustomAgents({}, {} as any);
136
137
assert.equal(agents.length, 1);
138
const content = await getAgentContent(agents[0]);
139
140
// Should contain model override
141
assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)'));
142
});
143
144
test('applies core default model when configured', async () => {
145
await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', 'Claude Haiku 4.5 (copilot)');
146
147
const provider = createProvider();
148
const agents = await provider.provideCustomAgents({}, {} as any);
149
150
assert.equal(agents.length, 1);
151
const content = await getAgentContent(agents[0]);
152
153
// Should contain model override from core setting
154
assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)'));
155
});
156
157
test('prefers core default model over extension setting', async () => {
158
await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', 'core-model');
159
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'extension-model');
160
161
const provider = createProvider();
162
const agents = await provider.provideCustomAgents({}, {} as any);
163
164
assert.equal(agents.length, 1);
165
const content = await getAgentContent(agents[0]);
166
167
// Should contain core model override
168
assert.ok(content.includes('model: core-model'));
169
assert.ok(!content.includes('model: extension-model'));
170
});
171
172
test('applies both additionalTools and model settings together', async () => {
173
await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['extraTool']);
174
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'claude-3-sonnet');
175
176
const provider = createProvider();
177
const agents = await provider.provideCustomAgents({}, {} as any);
178
179
assert.equal(agents.length, 1);
180
const content = await getAgentContent(agents[0]);
181
182
// Should contain additional tool
183
assert.ok(content.includes('extraTool'));
184
185
// Should contain model override
186
assert.ok(content.includes('model: claude-3-sonnet'));
187
});
188
189
test('fires onDidChangeCustomAgents when additionalTools setting changes', async () => {
190
const provider = createProvider();
191
192
let eventFired = false;
193
provider.onDidChangeCustomAgents(() => {
194
eventFired = true;
195
});
196
197
await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['newTool']);
198
199
assert.equal(eventFired, true);
200
});
201
202
test('fires onDidChangeCustomAgents when model setting changes', async () => {
203
const provider = createProvider();
204
205
let eventFired = false;
206
provider.onDidChangeCustomAgents(() => {
207
eventFired = true;
208
});
209
210
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'new-model');
211
212
assert.equal(eventFired, true);
213
});
214
215
test('fires onDidChangeCustomAgents when core default model changes', async () => {
216
const provider = createProvider();
217
218
let eventFired = false;
219
provider.onDidChangeCustomAgents(() => {
220
eventFired = true;
221
});
222
223
await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', 'core-model');
224
225
assert.equal(eventFired, true);
226
});
227
228
test('does not fire onDidChangeCustomAgents for unrelated setting changes', async () => {
229
const provider = createProvider();
230
231
let eventFired = false;
232
provider.onDidChangeCustomAgents(() => {
233
eventFired = true;
234
});
235
236
// Set an unrelated config (using a different config key)
237
await mockConfigurationService.setConfig(ConfigKey.Advanced.FeedbackOnChange, true);
238
239
assert.equal(eventFired, false);
240
});
241
242
test('always includes askQuestions tool in generated content', async () => {
243
const provider = createProvider();
244
const agents = await provider.provideCustomAgents({}, {} as any);
245
246
assert.equal(agents.length, 1);
247
const content = await getAgentContent(agents[0]);
248
249
assert.ok(content.includes('vscode/askQuestions'));
250
});
251
252
test('exposes only default read tools plus agent and askQuestions in plan mode by default', async () => {
253
const provider = createProvider();
254
const agents = await provider.provideCustomAgents({}, {} as any);
255
256
assert.equal(agents.length, 1);
257
const content = await getAgentContent(agents[0]);
258
259
const toolsMatch = content.match(/tools: \[([^\]]+)\]/);
260
assert.ok(toolsMatch, 'Tools list not found in agent content');
261
const actualTools = (toolsMatch[1].match(/'([^']+)'/g) || []).map(tool => tool.slice(1, -1)).sort();
262
const expectedTools = [...DEFAULT_READ_TOOLS, 'agent', 'vscode/askQuestions'].sort();
263
264
assert.deepStrictEqual(actualTools, expectedTools);
265
assert.ok(!actualTools.includes('edit'));
266
assert.ok(!actualTools.includes('createFile'));
267
assert.ok(!actualTools.includes('apply_patch'));
268
});
269
270
test('has correct label property', () => {
271
const provider = createProvider();
272
assert.ok(provider.label.includes('Plan'));
273
});
274
275
test('preserves body content after frontmatter when applying settings', async () => {
276
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'test-model');
277
278
const provider = createProvider();
279
const agents = await provider.provideCustomAgents({}, {} as any);
280
281
const content = await getAgentContent(agents[0]);
282
283
// Should preserve body content
284
assert.ok(content.includes('You are a PLANNING AGENT, pairing with the user'));
285
assert.ok(content.includes('Your SOLE responsibility is planning. NEVER start implementation.'));
286
});
287
288
test('handles empty additionalTools array gracefully', async () => {
289
await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, []);
290
291
const provider = createProvider();
292
const agents = await provider.provideCustomAgents({}, {} as any);
293
294
assert.equal(agents.length, 1);
295
const content = await getAgentContent(agents[0]);
296
297
// Should have base tools only
298
assert.ok(content.includes('github/issue_read'));
299
assert.ok(content.includes('agent'));
300
});
301
302
test('handles empty model string gracefully', async () => {
303
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, '');
304
305
const provider = createProvider();
306
const agents = await provider.provideCustomAgents({}, {} as any);
307
308
assert.equal(agents.length, 1);
309
const content = await getAgentContent(agents[0]);
310
311
// Should not have model field added
312
assert.ok(!content.includes('model:'));
313
});
314
315
test('falls back to extension setting when core default model is empty string', async () => {
316
await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', '');
317
await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'fallback-model');
318
319
const provider = createProvider();
320
const agents = await provider.provideCustomAgents({}, {} as any);
321
322
assert.equal(agents.length, 1);
323
const content = await getAgentContent(agents[0]);
324
325
// Empty core setting should fall through to extension setting
326
assert.ok(content.includes('model: fallback-model'));
327
});
328
329
test('includes handoffs in generated content', async () => {
330
const provider = createProvider();
331
const agents = await provider.provideCustomAgents({}, {} as any);
332
333
const content = await getAgentContent(agents[0]);
334
335
// Should contain handoffs
336
assert.ok(content.includes('handoffs:'));
337
assert.ok(content.includes('label: Start Implementation'));
338
assert.ok(content.includes('label: Open in Editor'));
339
assert.ok(content.includes('agent: agent'));
340
assert.ok(content.includes('send: true'));
341
});
342
343
test('applies ImplementAgentModel to Start Implementation handoff', async () => {
344
await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'Claude Haiku 4.5 (copilot)');
345
346
const provider = createProvider();
347
const agents = await provider.provideCustomAgents({}, {} as any);
348
349
assert.equal(agents.length, 1);
350
const content = await getAgentContent(agents[0]);
351
352
// Should contain Start Implementation handoff with model override
353
assert.ok(content.includes('label: Start Implementation'));
354
assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)'));
355
});
356
357
test('does not include model in handoff when ImplementAgentModel is not set', async () => {
358
const provider = createProvider();
359
const agents = await provider.provideCustomAgents({}, {} as any);
360
361
const content = await getAgentContent(agents[0]);
362
363
// Find the Start Implementation handoff section
364
const handoffsStart = content.indexOf('handoffs:');
365
const handoffsSection = content.slice(handoffsStart, content.indexOf('---', handoffsStart));
366
367
// Should not contain model field in handoffs when not configured
368
assert.ok(!handoffsSection.includes('model:'), 'Should not have model field in handoffs when ImplementAgentModel is not set');
369
});
370
371
test('fires onDidChangeCustomAgents when ImplementAgentModel setting changes', async () => {
372
const provider = createProvider();
373
374
let eventFired = false;
375
provider.onDidChangeCustomAgents(() => {
376
eventFired = true;
377
});
378
379
await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'new-model');
380
381
assert.equal(eventFired, true);
382
});
383
384
test('fires onDidChangeCustomAgents when SearchSubagentToolEnabled setting changes', async () => {
385
const provider = createProvider();
386
387
let eventFired = false;
388
provider.onDidChangeCustomAgents(() => {
389
eventFired = true;
390
});
391
392
await mockConfigurationService.setConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, true);
393
394
assert.equal(eventFired, true);
395
});
396
397
test('buildAgentBody uses Explore discovery when explore is enabled', () => {
398
const body = PlanAgentProvider.buildAgentBody(true, true);
399
assert.ok(body.includes('Run the *Explore* subagent'));
400
assert.ok(!body.includes('#tool:searchSubagent'));
401
});
402
403
test('buildAgentBody uses search subagent discovery when explore is disabled but search is enabled', () => {
404
const body = PlanAgentProvider.buildAgentBody(false, true);
405
assert.ok(body.includes('#tool:searchSubagent'));
406
assert.ok(!body.includes('Run the *Explore* subagent'));
407
});
408
409
test('buildAgentBody uses generic discovery when both explore and search are disabled', () => {
410
const body = PlanAgentProvider.buildAgentBody(false, false);
411
assert.ok(body.includes('Search the codebase to gather context'));
412
assert.ok(!body.includes('Run the *Explore* subagent'));
413
assert.ok(!body.includes('#tool:searchSubagent'));
414
});
415
416
test('excludes agent tool and Explore subagent when explore is disabled', async () => {
417
await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, false);
418
419
const provider = createProvider();
420
const agents = await provider.provideCustomAgents({}, {} as any);
421
const content = await getAgentContent(agents[0]);
422
423
// Should not have the 'agent' tool
424
const toolsMatch = content.match(/tools: \[([^\]]+)\]/);
425
assert.ok(toolsMatch);
426
assert.ok(!toolsMatch[1].includes('\'agent\''), 'Should not include agent tool when explore is disabled');
427
428
// Should not have agents field
429
assert.ok(!content.includes('agents:'), 'Should not include agents field when explore is disabled');
430
});
431
432
test('includes agent tool and Explore subagent when explore is enabled', async () => {
433
await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, true);
434
435
const provider = createProvider();
436
const agents = await provider.provideCustomAgents({}, {} as any);
437
const content = await getAgentContent(agents[0]);
438
439
// Should have the 'agent' tool
440
const toolsMatch = content.match(/tools: \[([^\]]+)\]/);
441
assert.ok(toolsMatch);
442
assert.ok(toolsMatch[1].includes('\'agent\''), 'Should include agent tool when explore is enabled');
443
444
// Should have agents field with Explore
445
assert.ok(content.includes('agents:'), 'Should include agents field when explore is enabled');
446
});
447
});
448
449
suite('buildAgentMarkdown', () => {
450
test('generates expected full content for Plan agent (snapshot test)', () => {
451
// This test outputs the full generated content for easy visual review of format changes
452
const config = {
453
name: 'Plan',
454
description: 'Researches and outlines multi-step plans',
455
argumentHint: 'Outline the goal or problem to research',
456
tools: ['github/issue_read', 'agent', 'search', 'memory'],
457
model: 'Claude Haiku 4.5 (copilot)',
458
handoffs: [
459
{
460
label: 'Start Implementation',
461
agent: 'agent',
462
prompt: 'Start implementation',
463
send: true
464
}
465
],
466
body: 'You are a PLANNING AGENT.'
467
};
468
469
const result = buildAgentMarkdown(config);
470
471
assert.deepStrictEqual(result,
472
`---
473
name: Plan
474
description: Researches and outlines multi-step plans
475
argument-hint: Outline the goal or problem to research
476
model: Claude Haiku 4.5 (copilot)
477
tools: ['github/issue_read', 'agent', 'search', 'memory']
478
handoffs:
479
- label: Start Implementation
480
agent: agent
481
prompt: 'Start implementation'
482
send: true
483
---
484
You are a PLANNING AGENT.`);
485
});
486
487
test('generates valid YAML frontmatter with basic config', () => {
488
const config = {
489
name: 'TestAgent',
490
description: 'Test description',
491
argumentHint: 'Test hint',
492
tools: ['tool1', 'tool2'],
493
handoffs: [],
494
body: 'Test body content'
495
};
496
497
const result = buildAgentMarkdown(config);
498
499
assert.ok(result.startsWith('---\n'));
500
assert.ok(result.includes('name: TestAgent'));
501
assert.ok(result.includes('description: Test description'));
502
assert.ok(result.includes('argument-hint: Test hint'));
503
assert.ok(result.includes('tools: [\'tool1\', \'tool2\']'));
504
assert.ok(result.includes('---\nTest body content'));
505
});
506
507
test('includes model when provided', () => {
508
const config = {
509
name: 'TestAgent',
510
description: 'Test',
511
argumentHint: 'Test',
512
tools: [],
513
model: 'Claude Haiku 4.5 (copilot)',
514
handoffs: [],
515
body: 'Body'
516
};
517
518
const result = buildAgentMarkdown(config);
519
520
assert.ok(result.includes('model: Claude Haiku 4.5 (copilot)'));
521
});
522
523
test('omits model when not provided', () => {
524
const config = {
525
name: 'TestAgent',
526
description: 'Test',
527
argumentHint: 'Test',
528
tools: [],
529
handoffs: [],
530
body: 'Body'
531
};
532
533
const result = buildAgentMarkdown(config);
534
535
assert.ok(!result.includes('model:'));
536
});
537
538
test('generates handoffs in block style', () => {
539
const config = {
540
name: 'TestAgent',
541
description: 'Test',
542
argumentHint: 'Test',
543
tools: [],
544
handoffs: [
545
{
546
label: 'Continue',
547
agent: 'agent',
548
prompt: 'Do the thing',
549
send: true
550
},
551
{
552
label: 'Save',
553
agent: 'editor',
554
prompt: 'Save it',
555
showContinueOn: false
556
}
557
],
558
body: 'Body'
559
};
560
561
const result = buildAgentMarkdown(config);
562
563
assert.ok(result.includes('handoffs:'));
564
assert.ok(result.includes(' - label: Continue'));
565
assert.ok(result.includes(' agent: agent'));
566
assert.ok(result.includes(' prompt: \'Do the thing\''));
567
assert.ok(result.includes(' send: true'));
568
assert.ok(result.includes(' - label: Save'));
569
assert.ok(result.includes(' prompt: \'Save it\''));
570
assert.ok(result.includes(' showContinueOn: false'));
571
});
572
573
test('handles empty tools array', () => {
574
const config = {
575
name: 'TestAgent',
576
description: 'Test',
577
argumentHint: 'Test',
578
tools: [],
579
handoffs: [],
580
body: 'Body'
581
};
582
583
const result = buildAgentMarkdown(config);
584
585
// Should not have tools line when empty
586
assert.ok(!result.includes('tools:'));
587
});
588
589
test('quotes tool names in flow-style array', () => {
590
const config = {
591
name: 'TestAgent',
592
description: 'Test',
593
argumentHint: 'Test',
594
tools: ['github/issue_read', 'mcp_server/custom_tool'],
595
handoffs: [],
596
body: 'Body'
597
};
598
599
const result = buildAgentMarkdown(config);
600
601
assert.ok(result.includes('tools: [\'github/issue_read\', \'mcp_server/custom_tool\']'));
602
});
603
604
test('escapes single quotes in tool names', () => {
605
const config = {
606
name: 'TestAgent',
607
description: 'Test',
608
argumentHint: 'Test',
609
tools: ['tool\'s_name', 'another'],
610
handoffs: [],
611
body: 'Body'
612
};
613
614
const result = buildAgentMarkdown(config);
615
616
// Single quotes should be doubled for YAML escaping
617
assert.ok(result.includes('\'tool\'\'s_name\''), 'Single quote should be escaped by doubling');
618
});
619
620
test('escapes single quotes in handoff prompts', () => {
621
const config = {
622
name: 'TestAgent',
623
description: 'Test',
624
argumentHint: 'Test',
625
tools: [],
626
handoffs: [
627
{
628
label: 'Test',
629
agent: 'agent',
630
prompt: 'It\'s a test prompt with \'quotes\''
631
}
632
],
633
body: 'Body'
634
};
635
636
const result = buildAgentMarkdown(config);
637
638
// Single quotes in prompt should be doubled for YAML escaping
639
assert.ok(result.includes('prompt: \'It\'\'s a test prompt with \'\'quotes\'\'\''), 'Single quotes should be escaped by doubling');
640
});
641
});
642
643