Path: blob/main/extensions/copilot/src/extension/agents/vscode-node/test/planAgentProvider.spec.ts
13405 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 { assert } from 'chai';6import * as os from 'os';7import * as path from 'path';8import { afterEach, beforeEach, suite, test } from 'vitest';9import * as vscode from 'vscode';10import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';11import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';12import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';13import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';14import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';15import { ITestingServicesAccessor } from '../../../../platform/test/node/services';16import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';17import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';18import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';19import { createExtensionUnitTestingServices } from '../../../test/node/services';20import { buildAgentMarkdown, DEFAULT_READ_TOOLS } from '../agentTypes';21import { PlanAgentProvider } from '../planAgentProvider';2223suite('PlanAgentProvider', () => {24let disposables: DisposableStore;25let mockConfigurationService: InMemoryConfigurationService;26let fileSystemService: IFileSystemService;27let accessor: ITestingServicesAccessor;28let instantiationService: IInstantiationService;2930beforeEach(() => {31disposables = new DisposableStore();3233// Set up testing services with a mock extension context that has globalStorageUri34const testingServiceCollection = createExtensionUnitTestingServices(disposables);35const globalStoragePath = path.join(os.tmpdir(), 'plan-agent-test-' + Date.now());36testingServiceCollection.define(IVSCodeExtensionContext, new SyncDescriptor(MockExtensionContext, [globalStoragePath]));37accessor = testingServiceCollection.createTestingAccessor();38disposables.add(accessor);39instantiationService = accessor.get(IInstantiationService);4041mockConfigurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService;42fileSystemService = accessor.get(IFileSystemService);43});4445afterEach(() => {46disposables.dispose();47});4849function createProvider() {50const provider = instantiationService.createInstance(PlanAgentProvider);51disposables.add(provider);52return provider;53}5455async function getAgentContent(agent: vscode.ChatResource): Promise<string> {56const content = await fileSystemService.readFile(agent.uri);57return new TextDecoder().decode(content);58}5960test('provideCustomAgents() returns a Plan agent with correct structure', async () => {61const provider = createProvider();6263const agents = await provider.provideCustomAgents({}, {} as any);6465assert.equal(agents.length, 1);66assert.ok(agents[0].uri, 'Agent should have a URI');67assert.ok(agents[0].uri.path.endsWith('.agent.md'), 'Agent URI should end with .agent.md');68});6970test('returns agent content with base frontmatter when no settings configured', async () => {71const provider = createProvider();7273const agents = await provider.provideCustomAgents({}, {} as any);7475assert.equal(agents.length, 1);76const content = await getAgentContent(agents[0]);7778// Should contain base tools79assert.ok(content.includes('github/issue_read'));80assert.ok(content.includes('agent'));81assert.ok(content.includes('search'));82assert.ok(content.includes('read'));83assert.ok(content.includes('memory'));8485// Should not have model override (not in base content)86assert.ok(content.includes('name: Plan'));87assert.ok(content.includes('description: Researches and outlines multi-step plans'));88});8990test('merges additionalTools setting with base tools', async () => {91await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['customTool1', 'customTool2']);9293const provider = createProvider();94const agents = await provider.provideCustomAgents({}, {} as any);9596assert.equal(agents.length, 1);97const content = await getAgentContent(agents[0]);9899// Should contain base tools100assert.ok(content.includes('github/issue_read'));101assert.ok(content.includes('agent'));102103// Should contain additional tools104assert.ok(content.includes('customTool1'));105assert.ok(content.includes('customTool2'));106});107108test('deduplicates tools when additionalTools overlaps with base tools', async () => {109// Add a tool that already exists in base110await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['agent', 'newTool']);111112const provider = createProvider();113const agents = await provider.provideCustomAgents({}, {} as any);114115assert.equal(agents.length, 1);116const content = await getAgentContent(agents[0]);117118// Count occurrences of 'agent' in tools list (flow-style array)119// Should appear only once due to deduplication120const toolsMatch = content.match(/tools: \[([^\]]+)\]/);121assert.ok(toolsMatch, 'Tools list not found in agent content');122const toolsSection = toolsMatch[1];123const agentCount = (toolsSection.match(/'agent'/g) || []).length;124assert.equal(agentCount, 1, 'agent tool should appear only once after deduplication');125126// Should contain new tool127assert.ok(content.includes('newTool'));128});129130test('applies model override from settings', async () => {131await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'Claude Haiku 4.5 (copilot)');132133const provider = createProvider();134const agents = await provider.provideCustomAgents({}, {} as any);135136assert.equal(agents.length, 1);137const content = await getAgentContent(agents[0]);138139// Should contain model override140assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)'));141});142143test('applies core default model when configured', async () => {144await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', 'Claude Haiku 4.5 (copilot)');145146const provider = createProvider();147const agents = await provider.provideCustomAgents({}, {} as any);148149assert.equal(agents.length, 1);150const content = await getAgentContent(agents[0]);151152// Should contain model override from core setting153assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)'));154});155156test('prefers core default model over extension setting', async () => {157await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', 'core-model');158await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'extension-model');159160const provider = createProvider();161const agents = await provider.provideCustomAgents({}, {} as any);162163assert.equal(agents.length, 1);164const content = await getAgentContent(agents[0]);165166// Should contain core model override167assert.ok(content.includes('model: core-model'));168assert.ok(!content.includes('model: extension-model'));169});170171test('applies both additionalTools and model settings together', async () => {172await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['extraTool']);173await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'claude-3-sonnet');174175const provider = createProvider();176const agents = await provider.provideCustomAgents({}, {} as any);177178assert.equal(agents.length, 1);179const content = await getAgentContent(agents[0]);180181// Should contain additional tool182assert.ok(content.includes('extraTool'));183184// Should contain model override185assert.ok(content.includes('model: claude-3-sonnet'));186});187188test('fires onDidChangeCustomAgents when additionalTools setting changes', async () => {189const provider = createProvider();190191let eventFired = false;192provider.onDidChangeCustomAgents(() => {193eventFired = true;194});195196await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, ['newTool']);197198assert.equal(eventFired, true);199});200201test('fires onDidChangeCustomAgents when model setting changes', async () => {202const provider = createProvider();203204let eventFired = false;205provider.onDidChangeCustomAgents(() => {206eventFired = true;207});208209await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'new-model');210211assert.equal(eventFired, true);212});213214test('fires onDidChangeCustomAgents when core default model changes', async () => {215const provider = createProvider();216217let eventFired = false;218provider.onDidChangeCustomAgents(() => {219eventFired = true;220});221222await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', 'core-model');223224assert.equal(eventFired, true);225});226227test('does not fire onDidChangeCustomAgents for unrelated setting changes', async () => {228const provider = createProvider();229230let eventFired = false;231provider.onDidChangeCustomAgents(() => {232eventFired = true;233});234235// Set an unrelated config (using a different config key)236await mockConfigurationService.setConfig(ConfigKey.Advanced.FeedbackOnChange, true);237238assert.equal(eventFired, false);239});240241test('always includes askQuestions tool in generated content', async () => {242const provider = createProvider();243const agents = await provider.provideCustomAgents({}, {} as any);244245assert.equal(agents.length, 1);246const content = await getAgentContent(agents[0]);247248assert.ok(content.includes('vscode/askQuestions'));249});250251test('exposes only default read tools plus agent and askQuestions in plan mode by default', async () => {252const provider = createProvider();253const agents = await provider.provideCustomAgents({}, {} as any);254255assert.equal(agents.length, 1);256const content = await getAgentContent(agents[0]);257258const toolsMatch = content.match(/tools: \[([^\]]+)\]/);259assert.ok(toolsMatch, 'Tools list not found in agent content');260const actualTools = (toolsMatch[1].match(/'([^']+)'/g) || []).map(tool => tool.slice(1, -1)).sort();261const expectedTools = [...DEFAULT_READ_TOOLS, 'agent', 'vscode/askQuestions'].sort();262263assert.deepStrictEqual(actualTools, expectedTools);264assert.ok(!actualTools.includes('edit'));265assert.ok(!actualTools.includes('createFile'));266assert.ok(!actualTools.includes('apply_patch'));267});268269test('has correct label property', () => {270const provider = createProvider();271assert.ok(provider.label.includes('Plan'));272});273274test('preserves body content after frontmatter when applying settings', async () => {275await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'test-model');276277const provider = createProvider();278const agents = await provider.provideCustomAgents({}, {} as any);279280const content = await getAgentContent(agents[0]);281282// Should preserve body content283assert.ok(content.includes('You are a PLANNING AGENT, pairing with the user'));284assert.ok(content.includes('Your SOLE responsibility is planning. NEVER start implementation.'));285});286287test('handles empty additionalTools array gracefully', async () => {288await mockConfigurationService.setConfig(ConfigKey.PlanAgentAdditionalTools, []);289290const provider = createProvider();291const agents = await provider.provideCustomAgents({}, {} as any);292293assert.equal(agents.length, 1);294const content = await getAgentContent(agents[0]);295296// Should have base tools only297assert.ok(content.includes('github/issue_read'));298assert.ok(content.includes('agent'));299});300301test('handles empty model string gracefully', async () => {302await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, '');303304const provider = createProvider();305const agents = await provider.provideCustomAgents({}, {} as any);306307assert.equal(agents.length, 1);308const content = await getAgentContent(agents[0]);309310// Should not have model field added311assert.ok(!content.includes('model:'));312});313314test('falls back to extension setting when core default model is empty string', async () => {315await mockConfigurationService.setNonExtensionConfig('chat.planAgent.defaultModel', '');316await mockConfigurationService.setConfig(ConfigKey.Deprecated.PlanAgentModel, 'fallback-model');317318const provider = createProvider();319const agents = await provider.provideCustomAgents({}, {} as any);320321assert.equal(agents.length, 1);322const content = await getAgentContent(agents[0]);323324// Empty core setting should fall through to extension setting325assert.ok(content.includes('model: fallback-model'));326});327328test('includes handoffs in generated content', async () => {329const provider = createProvider();330const agents = await provider.provideCustomAgents({}, {} as any);331332const content = await getAgentContent(agents[0]);333334// Should contain handoffs335assert.ok(content.includes('handoffs:'));336assert.ok(content.includes('label: Start Implementation'));337assert.ok(content.includes('label: Open in Editor'));338assert.ok(content.includes('agent: agent'));339assert.ok(content.includes('send: true'));340});341342test('applies ImplementAgentModel to Start Implementation handoff', async () => {343await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'Claude Haiku 4.5 (copilot)');344345const provider = createProvider();346const agents = await provider.provideCustomAgents({}, {} as any);347348assert.equal(agents.length, 1);349const content = await getAgentContent(agents[0]);350351// Should contain Start Implementation handoff with model override352assert.ok(content.includes('label: Start Implementation'));353assert.ok(content.includes('model: Claude Haiku 4.5 (copilot)'));354});355356test('does not include model in handoff when ImplementAgentModel is not set', async () => {357const provider = createProvider();358const agents = await provider.provideCustomAgents({}, {} as any);359360const content = await getAgentContent(agents[0]);361362// Find the Start Implementation handoff section363const handoffsStart = content.indexOf('handoffs:');364const handoffsSection = content.slice(handoffsStart, content.indexOf('---', handoffsStart));365366// Should not contain model field in handoffs when not configured367assert.ok(!handoffsSection.includes('model:'), 'Should not have model field in handoffs when ImplementAgentModel is not set');368});369370test('fires onDidChangeCustomAgents when ImplementAgentModel setting changes', async () => {371const provider = createProvider();372373let eventFired = false;374provider.onDidChangeCustomAgents(() => {375eventFired = true;376});377378await mockConfigurationService.setConfig(ConfigKey.ImplementAgentModel, 'new-model');379380assert.equal(eventFired, true);381});382383test('fires onDidChangeCustomAgents when SearchSubagentToolEnabled setting changes', async () => {384const provider = createProvider();385386let eventFired = false;387provider.onDidChangeCustomAgents(() => {388eventFired = true;389});390391await mockConfigurationService.setConfig(ConfigKey.Advanced.SearchSubagentToolEnabled, true);392393assert.equal(eventFired, true);394});395396test('buildAgentBody uses Explore discovery when explore is enabled', () => {397const body = PlanAgentProvider.buildAgentBody(true, true);398assert.ok(body.includes('Run the *Explore* subagent'));399assert.ok(!body.includes('#tool:searchSubagent'));400});401402test('buildAgentBody uses search subagent discovery when explore is disabled but search is enabled', () => {403const body = PlanAgentProvider.buildAgentBody(false, true);404assert.ok(body.includes('#tool:searchSubagent'));405assert.ok(!body.includes('Run the *Explore* subagent'));406});407408test('buildAgentBody uses generic discovery when both explore and search are disabled', () => {409const body = PlanAgentProvider.buildAgentBody(false, false);410assert.ok(body.includes('Search the codebase to gather context'));411assert.ok(!body.includes('Run the *Explore* subagent'));412assert.ok(!body.includes('#tool:searchSubagent'));413});414415test('excludes agent tool and Explore subagent when explore is disabled', async () => {416await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, false);417418const provider = createProvider();419const agents = await provider.provideCustomAgents({}, {} as any);420const content = await getAgentContent(agents[0]);421422// Should not have the 'agent' tool423const toolsMatch = content.match(/tools: \[([^\]]+)\]/);424assert.ok(toolsMatch);425assert.ok(!toolsMatch[1].includes('\'agent\''), 'Should not include agent tool when explore is disabled');426427// Should not have agents field428assert.ok(!content.includes('agents:'), 'Should not include agents field when explore is disabled');429});430431test('includes agent tool and Explore subagent when explore is enabled', async () => {432await mockConfigurationService.setConfig(ConfigKey.ExploreAgentEnabled, true);433434const provider = createProvider();435const agents = await provider.provideCustomAgents({}, {} as any);436const content = await getAgentContent(agents[0]);437438// Should have the 'agent' tool439const toolsMatch = content.match(/tools: \[([^\]]+)\]/);440assert.ok(toolsMatch);441assert.ok(toolsMatch[1].includes('\'agent\''), 'Should include agent tool when explore is enabled');442443// Should have agents field with Explore444assert.ok(content.includes('agents:'), 'Should include agents field when explore is enabled');445});446});447448suite('buildAgentMarkdown', () => {449test('generates expected full content for Plan agent (snapshot test)', () => {450// This test outputs the full generated content for easy visual review of format changes451const config = {452name: 'Plan',453description: 'Researches and outlines multi-step plans',454argumentHint: 'Outline the goal or problem to research',455tools: ['github/issue_read', 'agent', 'search', 'memory'],456model: 'Claude Haiku 4.5 (copilot)',457handoffs: [458{459label: 'Start Implementation',460agent: 'agent',461prompt: 'Start implementation',462send: true463}464],465body: 'You are a PLANNING AGENT.'466};467468const result = buildAgentMarkdown(config);469470assert.deepStrictEqual(result,471`---472name: Plan473description: Researches and outlines multi-step plans474argument-hint: Outline the goal or problem to research475model: Claude Haiku 4.5 (copilot)476tools: ['github/issue_read', 'agent', 'search', 'memory']477handoffs:478- label: Start Implementation479agent: agent480prompt: 'Start implementation'481send: true482---483You are a PLANNING AGENT.`);484});485486test('generates valid YAML frontmatter with basic config', () => {487const config = {488name: 'TestAgent',489description: 'Test description',490argumentHint: 'Test hint',491tools: ['tool1', 'tool2'],492handoffs: [],493body: 'Test body content'494};495496const result = buildAgentMarkdown(config);497498assert.ok(result.startsWith('---\n'));499assert.ok(result.includes('name: TestAgent'));500assert.ok(result.includes('description: Test description'));501assert.ok(result.includes('argument-hint: Test hint'));502assert.ok(result.includes('tools: [\'tool1\', \'tool2\']'));503assert.ok(result.includes('---\nTest body content'));504});505506test('includes model when provided', () => {507const config = {508name: 'TestAgent',509description: 'Test',510argumentHint: 'Test',511tools: [],512model: 'Claude Haiku 4.5 (copilot)',513handoffs: [],514body: 'Body'515};516517const result = buildAgentMarkdown(config);518519assert.ok(result.includes('model: Claude Haiku 4.5 (copilot)'));520});521522test('omits model when not provided', () => {523const config = {524name: 'TestAgent',525description: 'Test',526argumentHint: 'Test',527tools: [],528handoffs: [],529body: 'Body'530};531532const result = buildAgentMarkdown(config);533534assert.ok(!result.includes('model:'));535});536537test('generates handoffs in block style', () => {538const config = {539name: 'TestAgent',540description: 'Test',541argumentHint: 'Test',542tools: [],543handoffs: [544{545label: 'Continue',546agent: 'agent',547prompt: 'Do the thing',548send: true549},550{551label: 'Save',552agent: 'editor',553prompt: 'Save it',554showContinueOn: false555}556],557body: 'Body'558};559560const result = buildAgentMarkdown(config);561562assert.ok(result.includes('handoffs:'));563assert.ok(result.includes(' - label: Continue'));564assert.ok(result.includes(' agent: agent'));565assert.ok(result.includes(' prompt: \'Do the thing\''));566assert.ok(result.includes(' send: true'));567assert.ok(result.includes(' - label: Save'));568assert.ok(result.includes(' prompt: \'Save it\''));569assert.ok(result.includes(' showContinueOn: false'));570});571572test('handles empty tools array', () => {573const config = {574name: 'TestAgent',575description: 'Test',576argumentHint: 'Test',577tools: [],578handoffs: [],579body: 'Body'580};581582const result = buildAgentMarkdown(config);583584// Should not have tools line when empty585assert.ok(!result.includes('tools:'));586});587588test('quotes tool names in flow-style array', () => {589const config = {590name: 'TestAgent',591description: 'Test',592argumentHint: 'Test',593tools: ['github/issue_read', 'mcp_server/custom_tool'],594handoffs: [],595body: 'Body'596};597598const result = buildAgentMarkdown(config);599600assert.ok(result.includes('tools: [\'github/issue_read\', \'mcp_server/custom_tool\']'));601});602603test('escapes single quotes in tool names', () => {604const config = {605name: 'TestAgent',606description: 'Test',607argumentHint: 'Test',608tools: ['tool\'s_name', 'another'],609handoffs: [],610body: 'Body'611};612613const result = buildAgentMarkdown(config);614615// Single quotes should be doubled for YAML escaping616assert.ok(result.includes('\'tool\'\'s_name\''), 'Single quote should be escaped by doubling');617});618619test('escapes single quotes in handoff prompts', () => {620const config = {621name: 'TestAgent',622description: 'Test',623argumentHint: 'Test',624tools: [],625handoffs: [626{627label: 'Test',628agent: 'agent',629prompt: 'It\'s a test prompt with \'quotes\''630}631],632body: 'Body'633};634635const result = buildAgentMarkdown(config);636637// Single quotes in prompt should be doubled for YAML escaping638assert.ok(result.includes('prompt: \'It\'\'s a test prompt with \'\'quotes\'\'\''), 'Single quotes should be escaped by doubling');639});640});641642643