Path: blob/main/src/vs/workbench/contrib/chat/test/common/customizationHarnessService.test.ts
13406 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 'assert';6import { Emitter } from '../../../../../base/common/event.js';7import { URI } from '../../../../../base/common/uri.js';8import { ThemeIcon } from '../../../../../base/common/themables.js';9import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';10import { CustomizationHarnessServiceBase, createVSCodeHarnessDescriptor, ICustomizationItemProvider, IHarnessDescriptor, matchesWorkspaceSubpath } from '../../common/customizationHarnessService.js';11import { PromptsType, Target } from '../../common/promptSyntax/promptTypes.js';12import { ICustomAgent, IPromptsService, PromptsStorage } from '../../common/promptSyntax/service/promptsService.js';13import { CancellationToken } from '../../../../../base/common/cancellation.js';14import { SessionType } from '../../common/chatSessionsService.js';15import { MockPromptsService } from './promptSyntax/service/mockPromptsService.js';1617suite('CustomizationHarnessService', () => {18const store = ensureNoDisposablesAreLeakedInTestSuite();1920function createService(...harnesses: IHarnessDescriptor[]): CustomizationHarnessServiceBase {21if (harnesses.length === 0) {22harnesses = [createVSCodeHarnessDescriptor([PromptsStorage.extension])];23}24const promptsService: IPromptsService = new MockPromptsService();25const service = new CustomizationHarnessServiceBase(harnesses, harnesses[0].id, promptsService);26store.add(service);27return service;28}2930suite('registerExternalHarness', () => {31test('forwards item provider changes via onDidChangeSlashCommands with sessionType', () => {32const service = createService();33const emitter = new Emitter<void>();34store.add(emitter);35const harnessId = 'test-harness';36const externalDescriptor: IHarnessDescriptor = {37id: harnessId,38label: 'Test Harness',39icon: ThemeIcon.fromId('extensions'),40getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),41itemProvider: {42onDidChange: emitter.event,43provideChatSessionCustomizations: async () => [],44},45};4647store.add(service.registerExternalHarness(externalDescriptor));4849let firedSessionType: string | undefined;50const listener = store.add(service.onDidChangeSlashCommands(e => firedSessionType = e.sessionType));51store.add(listener);5253emitter.fire();54assert.strictEqual(firedSessionType, harnessId);55});5657test('forwards item provider changes via onDidChangeCustomAgents with sessionType', () => {58const service = createService();59const emitter = new Emitter<void>();60store.add(emitter);61const harnessId = 'test-harness';62const externalDescriptor: IHarnessDescriptor = {63id: harnessId,64label: 'Test Harness',65icon: ThemeIcon.fromId('extensions'),66getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),67itemProvider: {68onDidChange: emitter.event,69provideChatSessionCustomizations: async () => [],70},71};7273store.add(service.registerExternalHarness(externalDescriptor));7475let firedSessionType: string | undefined;76const listener = store.add(service.onDidChangeCustomAgents(e => firedSessionType = e.sessionType));77store.add(listener);7879emitter.fire();80assert.strictEqual(firedSessionType, harnessId);81});8283test('adds harness to available list', () => {84const service = createService();85assert.strictEqual(service.availableHarnesses.get().length, 1);8687const emitter = new Emitter<void>();88store.add(emitter);89const externalDescriptor: IHarnessDescriptor = {90id: 'test-ext',91label: 'Test Extension',92icon: ThemeIcon.fromId('extensions'),93getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),94itemProvider: {95onDidChange: emitter.event,96provideChatSessionCustomizations: async () => [],97},98};99100const reg = service.registerExternalHarness(externalDescriptor);101store.add(reg);102103assert.strictEqual(service.availableHarnesses.get().length, 2);104assert.strictEqual(service.availableHarnesses.get()[1].id, 'test-ext');105});106107test('removes harness on dispose', () => {108const service = createService();109const emitter = new Emitter<void>();110store.add(emitter);111const externalDescriptor: IHarnessDescriptor = {112id: 'test-ext',113label: 'Test Extension',114icon: ThemeIcon.fromId('extensions'),115getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),116itemProvider: {117onDidChange: emitter.event,118provideChatSessionCustomizations: async () => [],119},120};121122const reg = service.registerExternalHarness(externalDescriptor);123assert.strictEqual(service.availableHarnesses.get().length, 2);124125reg.dispose();126assert.strictEqual(service.availableHarnesses.get().length, 1);127});128129test('falls back to first harness when active external harness is removed', () => {130const service = createService();131const emitter = new Emitter<void>();132store.add(emitter);133const externalDescriptor: IHarnessDescriptor = {134id: 'test-ext',135label: 'Test Extension',136icon: ThemeIcon.fromId('extensions'),137getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),138itemProvider: {139onDidChange: emitter.event,140provideChatSessionCustomizations: async () => [],141},142};143144const reg = service.registerExternalHarness(externalDescriptor);145service.setActiveHarness('test-ext');146assert.strictEqual(service.activeHarness.get(), 'test-ext');147148reg.dispose();149assert.strictEqual(service.activeHarness.get(), SessionType.Local);150});151152test('allows switching to external harness', () => {153const service = createService();154const emitter = new Emitter<void>();155store.add(emitter);156const externalDescriptor: IHarnessDescriptor = {157id: 'test-ext',158label: 'Test Extension',159icon: ThemeIcon.fromId('extensions'),160getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),161itemProvider: {162onDidChange: emitter.event,163provideChatSessionCustomizations: async () => [],164},165};166167store.add(service.registerExternalHarness(externalDescriptor));168service.setActiveHarness('test-ext');169assert.strictEqual(service.activeHarness.get(), 'test-ext');170171const activeDescriptor = service.getActiveDescriptor();172assert.strictEqual(activeDescriptor.id, 'test-ext');173assert.strictEqual(activeDescriptor.label, 'Test Extension');174assert.ok(activeDescriptor.itemProvider);175});176177test('external harness provides storage filter', () => {178const service = createService();179const emitter = new Emitter<void>();180store.add(emitter);181const customFilter = { sources: [PromptsStorage.local, PromptsStorage.user] };182const externalDescriptor: IHarnessDescriptor = {183id: 'test-ext',184label: 'Test Extension',185icon: ThemeIcon.fromId('extensions'),186getStorageSourceFilter: () => customFilter,187itemProvider: {188onDidChange: emitter.event,189provideChatSessionCustomizations: async () => [],190},191};192193store.add(service.registerExternalHarness(externalDescriptor));194service.setActiveHarness('test-ext');195assert.deepStrictEqual(service.getStorageSourceFilter(PromptsType.agent), customFilter);196});197198test('external harness item provider returns items', async () => {199const service = createService();200const emitter = new Emitter<void>();201store.add(emitter);202const testItems = [203{ uri: URI.parse('file:///workspace/.claude/SKILL.md'), type: 'skill', name: 'Test Skill', description: 'A test skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },204];205206const itemProvider: ICustomizationItemProvider = {207onDidChange: emitter.event,208provideChatSessionCustomizations: async () => testItems,209};210211const externalDescriptor: IHarnessDescriptor = {212id: 'test-ext',213label: 'Test Extension',214icon: ThemeIcon.fromId('extensions'),215getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),216itemProvider,217};218219store.add(service.registerExternalHarness(externalDescriptor));220service.setActiveHarness('test-ext');221222const items = await service.getActiveDescriptor().itemProvider!.provideChatSessionCustomizations(CancellationToken.None);223assert.strictEqual(items?.length, 1);224assert.strictEqual(items![0].name, 'Test Skill');225assert.strictEqual(items![0].type, 'skill');226});227228test('external harness with hidden sections and workspace subpaths', () => {229const service = createService();230const emitter = new Emitter<void>();231store.add(emitter);232const externalDescriptor: IHarnessDescriptor = {233id: 'test-ext',234label: 'Test Extension',235icon: ThemeIcon.fromId('extensions'),236hiddenSections: ['agents', 'prompts'],237workspaceSubpaths: ['.test-ext'],238getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),239itemProvider: {240onDidChange: emitter.event,241provideChatSessionCustomizations: async () => [],242},243};244245store.add(service.registerExternalHarness(externalDescriptor));246service.setActiveHarness('test-ext');247248const descriptor = service.getActiveDescriptor();249assert.deepStrictEqual(descriptor.hiddenSections, ['agents', 'prompts']);250assert.deepStrictEqual(descriptor.workspaceSubpaths, ['.test-ext']);251});252253test('external harness with same id as static harness replaces it', () => {254const staticDescriptor: IHarnessDescriptor = {255id: 'cli',256label: 'Copilot CLI (static)',257icon: ThemeIcon.fromId('extensions'),258getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),259};260const service = createService(261createVSCodeHarnessDescriptor([PromptsStorage.extension]),262staticDescriptor,263);264assert.strictEqual(service.availableHarnesses.get().length, 2);265266const emitter = new Emitter<void>();267store.add(emitter);268const externalDescriptor: IHarnessDescriptor = {269id: 'cli',270label: 'Copilot CLI (from API)',271icon: ThemeIcon.fromId('extensions'),272getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),273itemProvider: {274onDidChange: emitter.event,275provideChatSessionCustomizations: async () => [],276},277};278279const reg = service.registerExternalHarness(externalDescriptor);280store.add(reg);281282// Should still be 2, not 3 — the external shadows the static283assert.strictEqual(service.availableHarnesses.get().length, 2);284const cliHarness = service.availableHarnesses.get().find(h => h.id === 'cli')!;285assert.strictEqual(cliHarness.label, 'Copilot CLI (from API)');286});287288test('static harness reappears when shadowing external harness is disposed', () => {289const staticDescriptor: IHarnessDescriptor = {290id: 'cli',291label: 'Copilot CLI (static)',292icon: ThemeIcon.fromId('extensions'),293getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),294};295const service = createService(296createVSCodeHarnessDescriptor([PromptsStorage.extension]),297staticDescriptor,298);299300const emitter = new Emitter<void>();301store.add(emitter);302const externalDescriptor: IHarnessDescriptor = {303id: 'cli',304label: 'Copilot CLI (from API)',305icon: ThemeIcon.fromId('extensions'),306getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),307itemProvider: {308onDidChange: emitter.event,309provideChatSessionCustomizations: async () => [],310},311};312313const reg = service.registerExternalHarness(externalDescriptor);314reg.dispose();315316// Static harness should be back317assert.strictEqual(service.availableHarnesses.get().length, 2);318const cliHarness = service.availableHarnesses.get().find(h => h.id === 'cli')!;319assert.strictEqual(cliHarness.label, 'Copilot CLI (static)');320});321322test('active harness stays when shadowing external harness is disposed (static restored)', () => {323const staticDescriptor: IHarnessDescriptor = {324id: 'cli',325label: 'Copilot CLI (static)',326icon: ThemeIcon.fromId('extensions'),327getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),328};329const service = createService(330createVSCodeHarnessDescriptor([PromptsStorage.extension]),331staticDescriptor,332);333334const emitter = new Emitter<void>();335store.add(emitter);336const externalDescriptor: IHarnessDescriptor = {337id: 'cli',338label: 'Copilot CLI (from API)',339icon: ThemeIcon.fromId('extensions'),340getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),341itemProvider: {342onDidChange: emitter.event,343provideChatSessionCustomizations: async () => [],344},345};346347const reg = service.registerExternalHarness(externalDescriptor);348service.setActiveHarness('cli');349assert.strictEqual(service.activeHarness.get(), 'cli');350351reg.dispose();352353// Active stays on 'cli' because the static harness with the same id is restored354assert.strictEqual(service.activeHarness.get(), 'cli');355});356});357358suite('getSlashCommands', () => {359test('uses the active harness provider for prompt and skill items', async () => {360361362const testSessionType = 'test-session-type';363364const emitter = new Emitter<void>();365store.add(emitter);366const service = createService({367id: testSessionType,368label: 'Test Extension',369icon: ThemeIcon.fromId('extensions'),370getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),371itemProvider: {372onDidChange: emitter.event,373provideChatSessionCustomizations: async () => [374{ uri: URI.parse('file:///workspace/.test/prompts/fix.prompt.md'), type: PromptsType.prompt, name: 'fix', description: 'Fix something', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },375{ uri: URI.parse('file:///workspace/.test/skills/lint/SKILL.md'), type: PromptsType.skill, name: 'lint', description: 'Lint skill', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },376{ uri: URI.parse('file:///workspace/.test/instructions/rule.instructions.md'), type: PromptsType.instructions, name: 'rule', description: 'Ignore me', extensionId: undefined, pluginUri: undefined, userInvocable: undefined },377{ uri: URI.parse('file:///workspace/.test/skills/disabled/SKILL.md'), type: PromptsType.skill, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined },378],379},380});381382const commands = await service.getSlashCommands(testSessionType, CancellationToken.None);383assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type })), [384{ name: 'fix', type: PromptsType.prompt },385{ name: 'lint', type: PromptsType.skill },386]);387});388389test('falls back to promptsService when the active harness has no provider', async () => {390391const testSessionType = 'test-session-type';392const promptsService = new class extends MockPromptsService {393override async getPromptSlashCommands() {394return [395{ uri: URI.parse('file:///workspace/.github/prompts/explain.prompt.md'), name: 'explain', type: PromptsType.prompt, storage: PromptsStorage.local, userInvocable: false, sessionTypes: [testSessionType] },396{ uri: URI.parse('file:///workspace/.github/skills/review/SKILL.md'), name: 'review', type: PromptsType.skill, storage: PromptsStorage.user, userInvocable: true },397];398}399override isValidSlashCommandName() { return true; }400};401const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService);402store.add(service);403{404const commands = await service.getSlashCommands(testSessionType, CancellationToken.None);405assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [406{ name: 'explain', type: PromptsType.prompt, userInvocable: false, sessionTypes: [testSessionType] },407{ name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined },408]);409}410{411const commands = await service.getSlashCommands(SessionType.Local, CancellationToken.None);412assert.deepStrictEqual(commands.map(command => ({ name: command.name, type: command.type, userInvocable: command.userInvocable, sessionTypes: command.sessionTypes })), [413{ name: 'review', type: PromptsType.skill, userInvocable: true, sessionTypes: undefined },414]);415}416});417});418419suite('getCustomAgents', () => {420const createAgent = (name: string, path: string, sessionTypes: readonly string[] | undefined, enabled: boolean): ICustomAgent => ({421uri: URI.parse(path),422name,423target: Target.GitHubCopilot,424visibility: { userInvocable: true, agentInvocable: true },425agentInstructions: { content: '', toolReferences: [] },426source: { storage: PromptsStorage.local },427sessionTypes,428enabled,429});430431test('falls back to promptsService and filters by session type', async () => {432const testSessionType = 'test-session-type';433const promptsService = new MockPromptsService();434promptsService.setCustomModes([435createAgent('matching', 'file:///workspace/.github/agents/matching.agent.md', [testSessionType], true),436createAgent('global', 'file:///workspace/.github/agents/global.agent.md', undefined, true),437createAgent('other', 'file:///workspace/.github/agents/other.agent.md', ['other-session'], true),438]);439const service = new CustomizationHarnessServiceBase([createVSCodeHarnessDescriptor([PromptsStorage.extension])], SessionType.Local, promptsService);440store.add(service);441442const agents = await service.getCustomAgents(testSessionType, CancellationToken.None);443assert.deepStrictEqual(agents.map(agent => agent.name), ['matching', 'global']);444});445446test('uses provider item URIs to scope resolved custom agents', async () => {447const testSessionType1 = 'test-session-type1';448const testSessionType2 = 'test-session-type2';449const promptsService = new MockPromptsService();450promptsService.setCustomModes([451createAgent('selected', 'file:///workspace/.test/agents/selected.agent.md', undefined, true),452createAgent('not-selected', 'file:///workspace/.test/agents/not-selected.agent.md', undefined, false),453]);454455const emitter = new Emitter<void>();456store.add(emitter);457const service = new CustomizationHarnessServiceBase([{458id: testSessionType1,459label: 'Test Extension',460icon: ThemeIcon.fromId('extensions'),461getStorageSourceFilter: () => ({ sources: [PromptsStorage.local] }),462itemProvider: {463onDidChange: emitter.event,464provideChatSessionCustomizations: async () => [465{ uri: URI.parse('file:///workspace/.test/agents/enabled.agent.md'), type: PromptsType.agent, name: 'enabled', enabled: true, extensionId: undefined, pluginUri: undefined, userInvocable: undefined },466{ uri: URI.parse('file:///workspace/.test/agents/disabled.agent.md'), type: PromptsType.agent, name: 'disabled', enabled: false, extensionId: undefined, pluginUri: undefined, userInvocable: undefined },467],468},469}], testSessionType1, promptsService);470store.add(service);471{472const agents = (await service.getCustomAgents(testSessionType1, CancellationToken.None));473assert.deepStrictEqual(agents.map(agent => [agent.name, agent.enabled]), [['enabled', true], ['disabled', false]]);474}475{476const agents = (await service.getCustomAgents(testSessionType2, CancellationToken.None));477assert.deepStrictEqual(agents.map(agent => [agent.name, agent.enabled]), [['selected', true], ['not-selected', false]]);478}479});480});481482suite('matchesWorkspaceSubpath', () => {483test('matches segment boundary', () => {484assert.ok(matchesWorkspaceSubpath('/workspace/.claude/skills/SKILL.md', ['.claude']));485assert.ok(matchesWorkspaceSubpath('/workspace/.github/instructions.md', ['.github']));486});487488test('does not match partial segment', () => {489assert.ok(!matchesWorkspaceSubpath('/workspace/not.claude/file.md', ['.claude']));490});491492test('matches path ending with subpath', () => {493assert.ok(matchesWorkspaceSubpath('/workspace/.claude', ['.claude']));494});495496test('matches any of multiple subpaths', () => {497assert.ok(matchesWorkspaceSubpath('/workspace/.copilot/file.md', ['.github', '.copilot']));498assert.ok(matchesWorkspaceSubpath('/workspace/.github/file.md', ['.github', '.copilot']));499});500});501});502503504