use IPromptsService to provide customizations (#309873)

* use IPromptsService to provide customizations

* update

* update

* update

* update

* Update extensions/copilot/src/platform/promptFiles/test/common/mockPromptsService.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* update

* dispose MockPromptsService

* update

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Martin Aeschlimann
2026-04-15 09:40:03 +02:00
committed by GitHub
parent 721ebb8ef6
commit bf8712457a
26 changed files with 559 additions and 825 deletions
@@ -1,17 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { ChatCustomAgent } from 'vscode';
import { createServiceIdentifier } from '../../../util/common/services';
import { Event } from '../../../util/vs/base/common/event';
import { IDisposable } from '../../../util/vs/base/common/lifecycle';
export const IChatCustomAgentsService = createServiceIdentifier<IChatCustomAgentsService>('IChatCustomAgentsService');
export interface IChatCustomAgentsService extends IDisposable {
readonly _serviceBrand: undefined;
readonly onDidChangeCustomAgents: Event<void>;
getCustomAgents(): readonly ChatCustomAgent[];
}
@@ -1,75 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { ChatResource } from 'vscode';
import { ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';
import { createServiceIdentifier } from '../../../util/common/services';
import { Event } from '../../../util/vs/base/common/event';
import { IDisposable } from '../../../util/vs/base/common/lifecycle';
export const IChatPromptFileService = createServiceIdentifier<IChatPromptFileService>('IChatPromptFileService');
export interface IChatPromptFileService extends IDisposable {
readonly _serviceBrand: undefined;
/**
* An event that fires when the list of {@link customAgents custom agents} changes.
*/
readonly onDidChangeCustomAgents: Event<void>;
/**
* The list of currently available custom agents. These are `.agent.md` files
* from all sources (workspace, user, and extension-provided).
*/
readonly customAgents: readonly ChatResource[];
/**
* Returns the parsed prompt files for the custom agent.
*/
readonly customAgentPromptFiles: readonly ParsedPromptFile[];
/**
* An event that fires when the list of {@link instructions instructions} changes.
*/
readonly onDidChangeInstructions: Event<void>;
/**
* The list of currently available instructions. These are `.instructions.md` files
* from all sources (workspace, user, and extension-provided).
*/
readonly instructions: readonly ChatResource[];
/**
* An event that fires when the list of {@link skills skills} changes.
*/
readonly onDidChangeSkills: Event<void>;
/**
* The list of currently available skills. These are `SKILL.md` files
* from all sources (workspace, user, and extension-provided).
*/
readonly skills: readonly ChatResource[];
/**
* An event that fires when the list of {@link hooks hooks} changes.
*/
readonly onDidChangeHooks: Event<void>;
/**
* The list of currently available hook configuration files.
* These are JSON files that define lifecycle hooks from all sources
* (workspace, user, and extension-provided).
*/
readonly hooks: readonly ChatResource[];
/**
* An event that fires when the list of {@link plugins plugins} changes.
*/
readonly onDidChangePlugins: Event<void>;
/**
* The list of currently installed agent plugins.
*/
readonly plugins: readonly ChatResource[];
}
@@ -20,11 +20,12 @@ import {
import { isObject } from '../../../../util/vs/base/common/types';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { IChatPromptFileService } from '../../common/chatPromptFileService';
import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
export interface ICopilotCLISkills {
readonly _serviceBrand: undefined;
getSkillsLocations(): Uri[];
getSkillsLocations(token: CancellationToken): Promise<Uri[]>;
}
export const ICopilotCLISkills = createServiceIdentifier<ICopilotCLISkills>('ICopilotCLISkills');
@@ -37,12 +38,12 @@ export class CopilotCLISkills extends Disposable implements ICopilotCLISkills {
@IConfigurationService private readonly configurationService: IConfigurationService,
@INativeEnvService private readonly envService: INativeEnvService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IChatPromptFileService private readonly chatPromptFileService: IChatPromptFileService,
@IPromptsService private readonly promptsService: IPromptsService,
) {
super();
}
public getSkillsLocations(): Uri[] {
public async getSkillsLocations(token: CancellationToken): Promise<Uri[]> {
// Get additional skill locations from config
const configSkillLocationUris = new ResourceSet();
const locations = this.configurationService.getNonExtensionConfig<Record<string, boolean>>(SKILLS_LOCATION_KEY);
@@ -68,7 +69,7 @@ export class CopilotCLISkills extends Disposable implements ICopilotCLISkills {
}
}
}
this.chatPromptFileService.skills
(await this.promptsService.getSkills(token))
.filter(s => s.uri.scheme === Schemas.file)
.map(s => s.uri)
.map(uri => dirname(dirname(uri)))
@@ -13,7 +13,7 @@ import { ConfigKey, IConfigurationService } from '../../../../platform/configura
import { IEnvService } from '../../../../platform/env/common/envService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../../platform/log/common/logService';
import { type ParsedPromptFile } from '../../../../platform/promptFiles/common/promptsService';
import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../../util/common/services';
import { Emitter, Event } from '../../../../util/vs/base/common/event';
@@ -22,10 +22,10 @@ import { Disposable } from '../../../../util/vs/base/common/lifecycle';
import { basename } from '../../../../util/vs/base/common/resources';
import { URI } from '../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { IChatPromptFileService } from '../../common/chatPromptFileService';
import { getCopilotLogger } from './logger';
import { ensureNodePtyShim } from './nodePtyShim';
import { ensureRipgrepShim } from './ripgrepShim';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
export const COPILOT_CLI_REASONING_EFFORT_PROPERTY = 'reasoningEffort';
const COPILOT_CLI_MODEL_MEMENTO_KEY = 'github.copilot.cli.sessionModel';
@@ -245,7 +245,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
private readonly _onDidChangeAgents = this._register(new Emitter<void>());
readonly onDidChangeAgents: Event<void> = this._onDidChangeAgents.event;
constructor(
@IChatPromptFileService private readonly chatPromptFileService: IChatPromptFileService,
@IPromptsService private readonly promptsService: IPromptsService,
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
@IVSCodeExtensionContext private readonly extensionContext: IVSCodeExtensionContext,
@ILogService private readonly logService: ILogService,
@@ -253,7 +253,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
) {
super();
void this.getAgents();
this._register(this.chatPromptFileService.onDidChangeCustomAgents(() => {
this._register(this.promptsService.onDidChangeCustomAgents(() => {
this._refreshAgents();
}));
this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => {
@@ -302,7 +302,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
}
async resolveAgent(agentId: string): Promise<SweCustomAgent | undefined> {
for (const promptFile of this.chatPromptFileService.customAgentPromptFiles) {
for (const promptFile of await this.promptsService.getCustomAgents(CancellationToken.None)) {
if (agentId === promptFile.uri.toString()) {
return this.toCustomAgent(promptFile)?.agent;
}
@@ -334,7 +334,7 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
sourceUri: URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` }),
});
}
for (const promptFile of this.chatPromptFileService.customAgentPromptFiles) {
for (const promptFile of await this.promptsService.getCustomAgents(CancellationToken.None)) {
// Skip legacy .chatmode.md files — they are a deprecated format
// and should not appear in the Copilot CLI agent list.
if (promptFile.uri.path.toLowerCase().endsWith('.chatmode.md')) {
@@ -362,28 +362,31 @@ export class CopilotCLIAgents extends Disposable implements ICopilotCLIAgents {
return agents.map(agent => this.cloneAgent(agent));
}
private toCustomAgent(promptFile: ParsedPromptFile): CLIAgentInfo | undefined {
const agentName = getAgentFileNameFromFilePath(promptFile.uri);
const headerName = promptFile.header?.name?.trim();
private toCustomAgent(customAgent: vscode.ChatCustomAgent): CLIAgentInfo | undefined {
const agentName = getAgentFileNameFromFilePath(customAgent.uri);
const headerName = customAgent.name;
const name = headerName === undefined || headerName === '' ? agentName : headerName;
if (!name) {
return undefined;
}
const tools = promptFile.header?.tools?.filter(tool => !!tool) ?? [];
const model = promptFile.header?.model?.[0];
const tools = customAgent.tools?.filter(tool => !!tool) ?? [];
const model = customAgent.model?.[0];
return {
agent: {
name,
displayName: name,
description: promptFile.header?.description ?? '',
description: customAgent.description ?? '',
tools: tools.length > 0 ? tools : null,
prompt: async () => promptFile.body?.getContent() ?? '',
disableModelInvocation: promptFile.header?.disableModelInvocation ?? false,
prompt: async () => {
const pf = await this.promptsService.parseFile(customAgent.uri, CancellationToken.None);
return pf.body?.getContent() ?? '';
},
disableModelInvocation: customAgent.disableModelInvocation ?? false,
...(model ? { model } : {}),
},
sourceUri: promptFile.uri,
sourceUri: customAgent.uri,
};
}
@@ -23,6 +23,7 @@ import { generateUserPrompt } from '../../../prompts/node/agent/copilotCLIPrompt
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../../common/workspaceInfo';
import { ICopilotCLIImageSupport, isImageMimeType } from './copilotCLIImageSupport';
import { ICopilotCLISkills } from './copilotCLISkills';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
export class CopilotCLIPromptResolver {
@@ -77,7 +78,7 @@ export class CopilotCLIPromptResolver {
const isolationEnabled = isIsolationEnabled(workspaceInfo) || additionalWorkspaces.some(ws => isIsolationEnabled(ws));
const folderToWorktreeMap = this.buildFolderToWorktreeMap(workspaceInfo, additionalWorkspaces);
const hasAnyWorkingDirectory = getWorkingDirectory(workspaceInfo) || additionalWorkspaces.some(ws => getWorkingDirectory(ws));
const knownSkillLocations = this.skillsService.getSkillsLocations();
const knownSkillLocations = await this.skillsService.getSkillsLocations(CancellationToken.None);
await Promise.all(Array.from(variables).map(async variable => {
// Unsupported references: prompt instructions, instruction files, and the customizations index.
if (isInstructionFile(variable) || isCustomizationsIndex(variable)) {
@@ -8,7 +8,7 @@ import * as l10n from '@vscode/l10n';
import { createReadStream } from 'node:fs';
import { devNull } from 'node:os';
import { createInterface } from 'node:readline';
import type { ChatRequest, ChatSessionItem } from 'vscode';
import type { ChatCustomAgent, ChatRequest, ChatSessionItem } from 'vscode';
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { INativeEnvService } from '../../../../platform/env/common/envService';
@@ -18,7 +18,7 @@ import { RelativePattern } from '../../../../platform/filesystem/common/fileType
import { ILogService } from '../../../../platform/log/common/logService';
import { deriveCopilotCliOTelEnv } from '../../../../platform/otel/common/agentOTelEnv';
import { IOTelService } from '../../../../platform/otel/common/otelService';
import { ParsedPromptFile } from '../../../../platform/promptFiles/common/promptsService';
import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../../util/common/services';
import { coalesce } from '../../../../util/vs/base/common/arrays';
@@ -34,7 +34,6 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio
import { ChatRequestTurn2, ChatResponseTurn2, ChatSessionStatus, Uri } from '../../../../vscodeTypes';
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
import { IChatPromptFileService } from '../../common/chatPromptFileService';
import { IChatSessionMetadataStore, RequestDetails, StoredModeInstructions } from '../../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
@@ -165,7 +164,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
@IOTelService private readonly _otelService: IOTelService,
@IPromptVariablesService private readonly _promptVariablesService: IPromptVariablesService,
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
@IChatPromptFileService private readonly _chatPromptFileService: IChatPromptFileService,
@IPromptsService private readonly _promptsService: IPromptsService,
) {
super();
this.monitorSessionFiles();
@@ -647,7 +646,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
protected async createSessionsOptions(options: ICreateSessionOptions & { mcpServers?: SessionOptions['mcpServers'] }): Promise<Readonly<SessionOptions>> {
const [agentInfos, skillLocations] = await Promise.all([
this.agents.getAgents(),
this.copilotCLISkills.getSkillsLocations(),
this.copilotCLISkills.getSkillsLocations(CancellationToken.None),
]);
const customAgents = agentInfos.map(i => i.agent);
const variablesContext = this._promptVariablesService.buildTemplateVariablesContext(options.sessionId, options.debugTargetSessionIds);
@@ -791,14 +790,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
const [agentId, storedDetails] = await Promise.all([agentIdPromise, requestDetailsPromise]);
// Build lookup from copilotRequestId → RequestDetails for the callback
const customAgentLookup = this.createCustomAgentLookup();
const customAgentLookup = await this.createCustomAgentLookup();
const legacyMappings: RequestDetails[] = [];
const detailsByCopilotId = new Map<string, RequestIdDetails>();
const defaultModeInstructions = agentId ? this.resolveAgentModeInstructions(agentId, customAgentLookup) : undefined;
const defaultModeInstructions = agentId ? await this.resolveAgentModeInstructions(agentId, customAgentLookup) : undefined;
for (const d of storedDetails) {
if (d.copilotRequestId) {
const modeInstructions = d.modeInstructions ?? this.resolveAgentModeInstructions(d.agentId, customAgentLookup) ?? defaultModeInstructions;
const modeInstructions = d.modeInstructions ?? await this.resolveAgentModeInstructions(d.agentId, customAgentLookup) ?? defaultModeInstructions;
detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions });
}
}
@@ -831,36 +830,38 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return { history, events };
}
private createCustomAgentLookup(): Map<string, ParsedPromptFile> {
const agents = this._chatPromptFileService.customAgentPromptFiles;
const lookup = new Map<string, ParsedPromptFile>();
private async createCustomAgentLookup(): Promise<Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>> {
const agents = await this._promptsService.getCustomAgents(CancellationToken.None);
const lookup = new Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>();
for (const agent of agents) {
const lazyContent = new Lazy(() => this._promptsService.parseFile(agent.uri, CancellationToken.None).then(parsed => parsed.body?.getContent() ?? ''));
const keys = [
agent.header?.name?.trim(),
agent.name?.trim(),
agent.uri.toString(),
getAgentFileNameFromFilePath(agent.uri),
];
for (const key of keys) {
if (key && !lookup.has(key)) {
lookup.set(key, agent);
lookup.set(key, [agent, lazyContent]);
}
}
}
return lookup;
}
private resolveAgentModeInstructions(agentId: string | undefined, customAgentLookup: Map<string, ParsedPromptFile>): StoredModeInstructions | undefined {
private async resolveAgentModeInstructions(agentId: string | undefined, customAgentLookup: Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>): Promise<StoredModeInstructions | undefined> {
if (!agentId) {
return undefined;
}
const agent = customAgentLookup.get(agentId);
if (!agent) {
const agentEntry = customAgentLookup.get(agentId);
if (!agentEntry) {
return undefined;
}
const [agent, lazyContent] = agentEntry;
return {
uri: agent.uri.toString(),
name: agent.header?.name?.trim() || agentId,
content: agent.body?.getContent() ?? '',
name: agent.name?.trim() || agentId,
content: await lazyContent.value,
};
}
@@ -4,21 +4,22 @@
*--------------------------------------------------------------------------------------------*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import type { ChatResource } from 'vscode';
import type { ChatSkill } from 'vscode';
import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
import { InMemoryConfigurationService } from '../../../../../platform/configuration/test/common/inMemoryConfigurationService';
import { SKILLS_LOCATION_KEY } from '../../../../../platform/customInstructions/common/promptTypes';
import { INativeEnvService } from '../../../../../platform/env/common/envService';
import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
import { ILogService } from '../../../../../platform/log/common/logService';
import type { ParsedPromptFile } from '../../../../../platform/promptFiles/common/promptsService';
import type { IPromptsService } from '../../../../../platform/promptFiles/common/promptsService';
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
import { Event } from '../../../../../util/vs/base/common/event';
import { Disposable, DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../../util/vs/base/common/uri';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { IChatPromptFileService } from '../../../common/chatPromptFileService';
import { CopilotCLISkills } from '../copilotCLISkills';
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
const CopilotCLISkillsConstructor = CopilotCLISkills as unknown as new (
logService: ILogService,
@@ -26,24 +27,9 @@ const CopilotCLISkillsConstructor = CopilotCLISkills as unknown as new (
configurationService: IConfigurationService,
envService: INativeEnvService,
workspaceService: IWorkspaceService,
chatPromptFileService: IChatPromptFileService,
promptsService: IPromptsService,
) => CopilotCLISkills;
class TestChatPromptFileService extends Disposable implements IChatPromptFileService {
declare _serviceBrand: undefined;
readonly onDidChangeCustomAgents: Event<void> = Event.None;
readonly onDidChangeInstructions: Event<void> = Event.None;
readonly onDidChangeSkills: Event<void> = Event.None;
readonly onDidChangeHooks: Event<void> = Event.None;
readonly onDidChangePlugins: Event<void> = Event.None;
readonly customAgents: readonly ChatResource[] = [];
readonly customAgentPromptFiles: readonly ParsedPromptFile[] = [];
readonly instructions: readonly ChatResource[] = [];
skills: readonly ChatResource[] = [];
readonly hooks: readonly ChatResource[] = [];
readonly plugins: readonly ChatResource[] = [];
}
function createWorkspaceService(folders: URI[] = [URI.file('/workspace')]): IWorkspaceService {
return {
_serviceBrand: undefined,
@@ -71,7 +57,7 @@ describe('CopilotCLISkills', () => {
function createSkills(options?: {
configLocations?: Record<string, boolean>;
workspaceFolders?: URI[];
skills?: readonly ChatResource[];
skills?: readonly ChatSkill[];
userHome?: URI;
}): CopilotCLISkills {
const configService = new InMemoryConfigurationService(baseConfigurationService);
@@ -83,9 +69,9 @@ describe('CopilotCLISkills', () => {
? new class extends NullNativeEnvService { override get userHome() { return options.userHome!; } }()
: new NullNativeEnvService();
const chatPromptFileService = disposables.add(new TestChatPromptFileService());
const promptsService = disposables.add(new MockPromptsService());
if (options?.skills) {
(chatPromptFileService as { skills: readonly ChatResource[] }).skills = options.skills;
promptsService.setSkills(options.skills);
}
const skills = new CopilotCLISkillsConstructor(
@@ -94,52 +80,52 @@ describe('CopilotCLISkills', () => {
configService,
envService,
createWorkspaceService(options?.workspaceFolders),
chatPromptFileService,
promptsService,
);
disposables.add(skills);
return skills;
}
it('returns empty array when no config and no skills', () => {
it('returns empty array when no config and no skills', async () => {
const skills = createSkills();
expect(skills.getSkillsLocations()).toEqual([]);
expect((await skills.getSkillsLocations(CancellationToken.None))).toEqual([]);
});
it('expands tilde-prefixed paths using user home directory', () => {
it('expands tilde-prefixed paths using user home directory', async () => {
const userHome = URI.file('/home/user');
const skills = createSkills({
configLocations: { '~/my-skills': true },
userHome,
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/home/user/my-skills');
});
it('handles absolute paths', () => {
it('handles absolute paths', async () => {
const skills = createSkills({
configLocations: { '/absolute/skills/path': true },
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/absolute/skills/path');
});
it('joins relative paths to each workspace folder', () => {
it('joins relative paths to each workspace folder', async () => {
const skills = createSkills({
configLocations: { 'relative/skills': true },
workspaceFolders: [URI.file('/workspace1'), URI.file('/workspace2')],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(2);
expect(locations[0].path).toBe('/workspace1/relative/skills');
expect(locations[1].path).toBe('/workspace2/relative/skills');
});
it('ignores config entries with value !== true', () => {
it('ignores config entries with value !== true', async () => {
const skills = createSkills({
configLocations: {
'/included': true,
@@ -147,49 +133,49 @@ describe('CopilotCLISkills', () => {
},
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/included');
});
it('includes parent-of-parent directories of file-scheme skills', () => {
it('includes parent-of-parent directories of file-scheme skills', async () => {
const skills = createSkills({
skills: [
{ uri: URI.file('/skills/myskill/SKILL.md') } as ChatResource,
mockSkill('/skills/myskill/SKILL.md', 'myskill'),
],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/skills');
});
it('filters out non-file-scheme skills', () => {
it('filters out non-file-scheme skills', async () => {
const skills = createSkills({
skills: [
{ uri: URI.parse('copilot-skill:/remote/skill/SKILL.md') } as ChatResource,
mockSkill('copilot-skill:/remote/skill/SKILL.md', 'remoteSkill'),
],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(0);
});
it('deduplicates locations from config and skills', () => {
it('deduplicates locations from config and skills', async () => {
const skills = createSkills({
configLocations: { '/skills': true },
skills: [
// dirname(dirname("/skills/myskill/SKILL.md")) = "/skills"
{ uri: URI.file('/skills/myskill/SKILL.md') } as ChatResource,
mockSkill('/skills/myskill/SKILL.md', 'myskill'),
],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/skills');
});
it('deduplicates duplicate config entries', () => {
it('deduplicates duplicate config entries', async () => {
const skills = createSkills({
configLocations: {
'/same/path': true,
@@ -198,48 +184,48 @@ describe('CopilotCLISkills', () => {
workspaceFolders: [URI.file('/same')],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
// Absolute '/same/path' and relative 'path' joined to workspace '/same'
// both resolve to '/same/path', so the result should be deduplicated.
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/same/path');
});
it('handles multiple skills deriving to same parent directory', () => {
it('handles multiple skills deriving to same parent directory', async () => {
const skills = createSkills({
skills: [
{ uri: URI.file('/skills/skill1/SKILL.md') } as ChatResource,
{ uri: URI.file('/skills/skill2/SKILL.md') } as ChatResource,
mockSkill('/skills/skill1/SKILL.md', 'skill1'),
mockSkill('/skills/skill2/SKILL.md', 'skill2'),
],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
// Both resolve to /skills via dirname(dirname())
expect(locations).toHaveLength(1);
expect(locations[0].path).toBe('/skills');
});
it('combines config locations and skills locations', () => {
it('combines config locations and skills locations', async () => {
const skills = createSkills({
configLocations: { '/config-skills': true },
skills: [
{ uri: URI.file('/prompt-skills/myskill/SKILL.md') } as ChatResource,
mockSkill('/prompt-skills/myskill/SKILL.md', 'myskill'),
],
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
expect(locations).toHaveLength(2);
const paths = locations.map(l => l.path);
expect(paths).toContain('/config-skills');
expect(paths).toContain('/prompt-skills');
});
it('ignores empty or whitespace-only config keys', () => {
it('ignores empty or whitespace-only config keys', async () => {
const skills = createSkills({
configLocations: { ' ': true, '': true, '/valid': true },
});
const locations = skills.getSkillsLocations();
const locations = await skills.getSkillsLocations(CancellationToken.None);
// Empty string after trim is not absolute, not ~/,
// so goes to relative path. But it's just whitespace.
// The code trims and checks - empty string is not '~/' prefixed, not absolute,
@@ -249,21 +235,28 @@ describe('CopilotCLISkills', () => {
expect(validLocations).toHaveLength(1);
});
it('returns empty when config is not an object', () => {
it('returns empty when config is not an object', async () => {
const configService = new InMemoryConfigurationService(baseConfigurationService);
configService.setNonExtensionConfig(SKILLS_LOCATION_KEY, 'not-an-object');
const chatPromptFileService = disposables.add(new TestChatPromptFileService());
const mockPromptsService = disposables.add(new MockPromptsService());
const skillsService = new CopilotCLISkillsConstructor(
logService,
{} as unknown,
configService,
new NullNativeEnvService(),
createWorkspaceService(),
chatPromptFileService,
mockPromptsService,
);
disposables.add(skillsService);
expect(skillsService.getSkillsLocations()).toEqual([]);
expect(await skillsService.getSkillsLocations(CancellationToken.None)).toEqual([]);
});
function mockSkill(uri: string, name: string): ChatSkill {
return {
uri: URI.parse(uri),
name,
} as ChatSkill;
}
});
@@ -7,22 +7,15 @@ import type { SweCustomAgent } from '@github/copilot/sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { IVSCodeExtensionContext } from '../../../../../platform/extContext/common/extensionContext';
import { ILogService } from '../../../../../platform/log/common/logService';
import { PromptFileParser, type ParsedPromptFile } from '../../../../../platform/promptFiles/common/promptsService';
import { PromptFileParser } from '../../../../../platform/promptFiles/common/promptsService';
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
import { Emitter, Event } from '../../../../../util/vs/base/common/event';
import { Disposable, DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { Event } from '../../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../../util/vs/base/common/uri';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { IChatPromptFileService } from '../../../common/chatPromptFileService';
import { CopilotCLIAgents, type ICopilotCLISDK } from '../copilotCli';
const CopilotCLIAgentsConstructor = CopilotCLIAgents as unknown as new (
chatPromptFileService: IChatPromptFileService,
copilotCLISDK: ICopilotCLISDK,
extensionContext: IVSCodeExtensionContext,
logService: ILogService,
workspaceService: IWorkspaceService,
) => CopilotCLIAgents;
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
import type { ChatCustomAgent } from 'vscode';
function createMockExtensionContext(): IVSCodeExtensionContext {
const workspaceState = new Map<string, unknown>();
@@ -43,36 +36,13 @@ function createMockExtensionContext(): IVSCodeExtensionContext {
} as unknown as IVSCodeExtensionContext;
}
class TestChatPromptFileService extends Disposable implements IChatPromptFileService {
declare _serviceBrand: undefined;
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event;
readonly onDidChangeInstructions: Event<void> = Event.None;
readonly onDidChangeSkills: Event<void> = Event.None;
readonly onDidChangeHooks: Event<void> = Event.None;
readonly onDidChangePlugins: Event<void> = Event.None;
readonly customAgents: readonly import('vscode').ChatResource[] = [];
readonly instructions: readonly import('vscode').ChatResource[] = [];
readonly skills: readonly import('vscode').ChatResource[] = [];
readonly hooks: readonly import('vscode').ChatResource[] = [];
readonly plugins: readonly import('vscode').ChatResource[] = [];
constructor(private _customAgentPromptFiles: ParsedPromptFile[] = []) {
super();
}
get customAgentPromptFiles(): readonly ParsedPromptFile[] {
return [...this._customAgentPromptFiles];
}
setCustomAgents(customAgents: ParsedPromptFile[]): void {
this._customAgentPromptFiles = customAgents;
this._onDidChangeCustomAgents.fire();
}
interface PromptFileInfo {
readonly uri: URI;
readonly content: string;
}
function parsePromptFile(fileName: string, content: string): ParsedPromptFile {
return new PromptFileParser().parse(URI.file(`/workspace/.github/agents/${fileName}`), content);
function mockPromptFile(fileName: string, content: string): PromptFileInfo {
return { uri: URI.file(`/workspace/.github/agents/${fileName}`), content };
}
function createMockSDK(agentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>): ICopilotCLISDK {
@@ -113,23 +83,44 @@ describe('CopilotCLIAgents', () => {
disposables.clear();
});
function createAgents(options: { sdkAgentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>; promptAgents?: ParsedPromptFile[] }): { agents: CopilotCLIAgents; chatPromptFileService: TestChatPromptFileService; sdk: ICopilotCLISDK } {
const chatPromptFileService = new TestChatPromptFileService(options.promptAgents ?? []);
function createChatCustomAgent(mock: PromptFileInfo): ChatCustomAgent {
const parsed = new PromptFileParser().parse(mock.uri, mock.content);
return {
uri: mock.uri,
source: 'local',
name: parsed.header?.name ?? mock.uri.path.split('/').pop()?.replace('.agent.md', '') ?? 'unknown',
description: parsed.header?.description ?? '',
model: parsed.header?.model,
tools: parsed.header?.tools,
userInvocable: parsed.header?.userInvokable ?? true,
disableModelInvocation: parsed.header?.disableModelInvocation ?? false,
};
}
function createAgents(options: { sdkAgentsByCall: ReadonlyArray<ReadonlyArray<SweCustomAgent>>; customAgents?: PromptFileInfo[] }): { agents: CopilotCLIAgents; promptsService: MockPromptsService; sdk: ICopilotCLISDK } {
const promptsService = disposables.add(new MockPromptsService());
if (options.customAgents) {
const customAgents = [];
for (const ca of options.customAgents) {
promptsService.setFileContent(ca.uri, ca.content);
customAgents.push(createChatCustomAgent(ca));
}
promptsService.setCustomAgents(customAgents);
}
const sdk = createMockSDK(options.sdkAgentsByCall);
const agents = new CopilotCLIAgentsConstructor(
chatPromptFileService,
const agents = new CopilotCLIAgents(
promptsService,
sdk,
createMockExtensionContext(),
logService,
createWorkspaceService(),
);
disposables.add(chatPromptFileService);
disposables.add(agents);
return { agents, chatPromptFileService, sdk };
return { agents, promptsService, sdk };
}
it('prefers prompt-derived agents over SDK agents with the same name', async () => {
const promptAgent = parsePromptFile('merge.agent.md', `---
const promptAgent = mockPromptFile('merge.agent.md', `---
name: MergeMe
description: Prompt description
tools: []
@@ -146,7 +137,7 @@ Prompt body`);
prompt: async () => 'sdk body',
disableModelInvocation: false,
}]],
promptAgents: [promptAgent]
customAgents: [promptAgent]
});
const result = await agents.getAgents();
@@ -165,7 +156,7 @@ Prompt body`);
it('derives agent name from filename when frontmatter name is missing', async () => {
const { agents } = createAgents({
sdkAgentsByCall: [[]],
promptAgents: [parsePromptFile('invalid.agent.md', `---
customAgents: [mockPromptFile('invalid.agent.md', `---
description: Missing name
tools: ['read_file']
---
@@ -181,9 +172,9 @@ Body`)]
});
it('refreshes cached agents when custom agents change', async () => {
const { agents, chatPromptFileService, sdk } = createAgents({
const { agents, promptsService, sdk } = createAgents({
sdkAgentsByCall: [[], []],
promptAgents: [parsePromptFile('first.agent.md', `---
customAgents: [mockPromptFile('first.agent.md', `---
name: First
description: First prompt agent
---
@@ -191,11 +182,11 @@ First body`)]
});
const first = await agents.getAgents();
chatPromptFileService.setCustomAgents([parsePromptFile('second.agent.md', `---
promptsService.setCustomAgents([createChatCustomAgent(mockPromptFile('second.agent.md', `---
name: Second
description: Second prompt agent
---
Second body`)]);
Second body`))]);
const second = await agents.getAgents();
expect(first.map(a => a.agent.name)).toEqual(['First']);
@@ -204,22 +195,23 @@ Second body`)]);
});
it('filters out legacy .chatmode.md files', async () => {
const chatmodeFile = new PromptFileParser().parse(
URI.file('/workspace/.github/chatmodes/test.chatmode.md'),
`---
const chatmodeFile = {
uri:
URI.file('/workspace/.github/chatmodes/test.chatmode.md'),
content: `---
name: TestMode
description: A legacy chatmode
---
Body`
);
const agentFile = parsePromptFile('real.agent.md', `---
};
const agentFile = mockPromptFile('real.agent.md', `---
name: RealAgent
description: A real agent
---
Body`);
const { agents } = createAgents({
sdkAgentsByCall: [[]],
promptAgents: [chatmodeFile, agentFile]
customAgents: [chatmodeFile, agentFile]
});
const result = await agents.getAgents();
@@ -27,7 +27,6 @@ import { IInstantiationService } from '../../../../../util/vs/platform/instantia
import { NullPromptVariablesService } from '../../../../prompt/node/promptVariablesService';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { IAgentSessionsWorkspace } from '../../../common/agentSessionsWorkspace';
import { IChatPromptFileService } from '../../../common/chatPromptFileService';
import { IChatSessionWorkspaceFolderService } from '../../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../../../common/chatSessionWorktreeService';
import { MockChatSessionMetadataStore } from '../../../common/test/mockChatSessionMetadataStore';
@@ -42,6 +41,7 @@ import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCL
import { CopilotCLIMCPHandler } from '../mcpHandler';
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers';
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers';
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
// Re-export for backward compatibility with other spec files
export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers';
@@ -153,7 +153,7 @@ describe('CopilotCLISessionService', () => {
const configurationService = accessor.get(IConfigurationService);
const nullMcpServer = disposables.add(new NullMcpService());
const titleService = new NullCustomSessionTitleService();
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;
});
@@ -356,7 +356,7 @@ describe('CopilotCLISessionService', () => {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
await mkdir(sessionDir.fsPath, { recursive: true });
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
@@ -391,7 +391,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
await mkdir(sessionDir.fsPath, { recursive: true });
const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl');
@@ -460,7 +460,7 @@ describe('CopilotCLISessionService', () => {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
@@ -502,7 +502,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
// Session has a summary with '<' (which forces the session-load fallback path)
@@ -753,7 +753,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager;
localManager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));
@@ -797,7 +797,7 @@ describe('CopilotCLISessionService', () => {
}();
const metadataStore = new MockChatSessionMetadataStore();
await metadataStore.updateRequestDetails(sourceId, [{ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1', toolIdEditMap: {} }]);
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), new class extends mock<IChatPromptFileService>() { override get customAgentPromptFiles() { return []; } }()));
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager;
localManager.sessions.set(sourceId, sdkSession);
const forkSpy = vi.spyOn(localManager, 'forkSession');
@@ -4,7 +4,7 @@
*--------------------------------------------------------------------------------------------*/
import type { SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
import type { Uri } from 'vscode';
import type { CancellationToken, Uri } from 'vscode';
import { Event } from '../../../../../util/vs/base/common/event';
import { Disposable, IDisposable } from '../../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../../util/vs/base/common/uri';
@@ -43,7 +43,7 @@ export class MockSkillLocations implements ICopilotCLISkills {
constructor(locations: Uri[] = []) {
this.locations = locations;
}
getSkillsLocations(): Uri[] {
async getSkillsLocations(_token: CancellationToken): Promise<Uri[]> {
return this.locations;
}
}
@@ -4,10 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import { vi } from 'vitest';
import type { ChatResource } from 'vscode';
import { Emitter } from '../../../../../util/vs/base/common/event';
import { Disposable, } from '../../../../../util/vs/base/common/lifecycle';
import { IChatPromptFileService } from '../../../common/chatPromptFileService';
import type { ICopilotCLISessionTracker } from '../copilotCLISessionTracker';
type ToolHandler = (args: Record<string, unknown>) => Promise<unknown>;
@@ -231,43 +227,3 @@ export class MockSessionTracker {
return this as unknown as ICopilotCLISessionTracker;
}
}
export class MockChatPromptFileService extends Disposable implements IChatPromptFileService {
declare _serviceBrand: undefined;
customAgents: ChatResource[] = [];
instructions: ChatResource[] = [];
skills: ChatResource[] = [];
hooks: ChatResource[] = [];
plugins: ChatResource[] = [];
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
private readonly _onDidChangeInstructions = this._register(new Emitter<void>());
private readonly _onDidChangeSkills = this._register(new Emitter<void>());
private readonly _onDidChangeHooks = this._register(new Emitter<void>());
private readonly _onDidChangePlugins = this._register(new Emitter<void>());
get onDidChangeCustomAgents() {
return this._onDidChangeCustomAgents.event;
}
get onDidChangeInstructions() {
return this._onDidChangeInstructions.event;
}
get onDidChangeSkills() {
return this._onDidChangeSkills.event;
}
get onDidChangeHooks() {
return this._onDidChangeHooks.event;
}
get onDidChangePlugins() {
return this._onDidChangePlugins.event;
}
get customAgentPromptFiles() {
return [];
}
constructor() {
super();
}
}
@@ -1,73 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
import { Emitter, Event } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IChatCustomAgentsService } from '../common/chatCustomAgentsService';
export class ChatCustomAgentsService extends Disposable implements IChatCustomAgentsService {
declare _serviceBrand: undefined;
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event;
private customAgents: readonly vscode.ChatCustomAgent[] = [];
private refreshCts: CancellationTokenSource | undefined;
constructor(
) {
super();
this._register(vscode.chat.onDidChangeCustomAgents(() => {
this.triggerRefreshCustomAgents();
}));
this.triggerRefreshCustomAgents();
}
getCustomAgents(): readonly vscode.ChatCustomAgent[] {
return this.customAgents;
}
override dispose(): void {
this.refreshCts?.dispose(true);
this.refreshCts = undefined;
super.dispose();
}
private triggerRefreshCustomAgents(): void {
this.refreshCts?.dispose(true);
const refreshCts = new CancellationTokenSource();
this.refreshCts = refreshCts;
void this.refreshCustomAgents(refreshCts.token).finally(() => {
if (this.refreshCts === refreshCts) {
this.refreshCts = undefined;
}
refreshCts.dispose();
});
}
private async refreshCustomAgents(token: CancellationToken): Promise<void> {
try {
const customAgents = await vscode.chat.getCustomAgents(token);
if (token.isCancellationRequested) {
return;
}
this.customAgents = customAgents;
this._onDidChangeCustomAgents.fire();
} catch (error) {
if (token.isCancellationRequested) {
return;
}
console.error('Failed to refresh custom agents', error);
}
}
}
@@ -1,129 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { ILogService } from '../../../platform/log/common/logService';
import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';
import { coalesce } from '../../../util/vs/base/common/arrays';
import { CancellationToken, CancellationTokenSource } from '../../../util/vs/base/common/cancellation';
import { isCancellationError } from '../../../util/vs/base/common/errors';
import { Emitter, Event } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IChatPromptFileService } from '../common/chatPromptFileService';
export class ChatPromptFileService extends Disposable implements IChatPromptFileService {
declare _serviceBrand: undefined;
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event;
private readonly _onDidChangeInstructions = this._register(new Emitter<void>());
readonly onDidChangeInstructions: Event<void> = this._onDidChangeInstructions.event;
private readonly _onDidChangeSkills = this._register(new Emitter<void>());
readonly onDidChangeSkills: Event<void> = this._onDidChangeSkills.event;
private readonly _onDidChangeHooks = this._register(new Emitter<void>());
readonly onDidChangeHooks: Event<void> = this._onDidChangeHooks.event;
private readonly _onDidChangePlugins = this._register(new Emitter<void>());
readonly onDidChangePlugins: Event<void> = this._onDidChangePlugins.event;
private _customAgents: ParsedPromptFile[] = [];
private refreshCts: CancellationTokenSource | undefined;
constructor(
@IPromptsService private readonly promptsService: IPromptsService,
@ILogService private readonly logService: ILogService,
) {
super();
this._register(vscode.chat.onDidChangeCustomAgents(() => {
this.triggerRefreshCustomAgents();
}));
this._register(vscode.chat.onDidChangeInstructions(() => {
this._onDidChangeInstructions.fire();
}));
this._register(vscode.chat.onDidChangeSkills(() => {
this._onDidChangeSkills.fire();
}));
if (vscode.chat.onDidChangeHooks) {
this._register(vscode.chat.onDidChangeHooks(() => {
this._onDidChangeHooks.fire();
}));
}
if (vscode.chat.onDidChangePlugins) {
this._register(vscode.chat.onDidChangePlugins(() => {
this._onDidChangePlugins.fire();
}));
}
this.triggerRefreshCustomAgents();
}
get customAgentPromptFiles(): readonly ParsedPromptFile[] {
return [...this._customAgents];
}
get customAgents(): readonly vscode.ChatResource[] {
return vscode.chat.customAgents;
}
get instructions(): readonly vscode.ChatResource[] {
return vscode.chat.instructions;
}
get skills(): readonly vscode.ChatResource[] {
return vscode.chat.skills;
}
get hooks(): readonly vscode.ChatResource[] {
return vscode.chat.hooks ?? [];
}
get plugins(): readonly vscode.ChatResource[] {
return vscode.chat.plugins ?? [];
}
override dispose(): void {
this.refreshCts?.dispose(true);
this.refreshCts = undefined;
super.dispose();
}
private triggerRefreshCustomAgents(): void {
this.refreshCts?.dispose(true);
const refreshCts = new CancellationTokenSource();
this.refreshCts = refreshCts;
void this.refreshCustomAgents(refreshCts.token).finally(() => {
if (this.refreshCts === refreshCts) {
this.refreshCts = undefined;
}
refreshCts.dispose();
});
}
private async refreshCustomAgents(token: CancellationToken): Promise<void> {
const parsedAgents = coalesce(await Promise.all(vscode.chat.customAgents.map(async resource => {
try {
return await this.promptsService.parseFile(resource.uri, token);
} catch (error) {
if (isCancellationError(error) || token.isCancellationRequested) {
return undefined;
}
this.logService.error(`[ChatPromptFileService] Failed to parse custom agent ${resource.uri.toString()}`, error);
return undefined;
}
})));
if (token.isCancellationRequested) {
return;
}
this._customAgents = parsedAgents;
this._onDidChangeCustomAgents.fire();
}
}
@@ -35,7 +35,6 @@ import { ClaudeSessionStateService } from '../claude/node/claudeSessionStateServ
import { ClaudeCodeSessionService, IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService';
import { ClaudeSlashCommandService, IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
import { IChatPromptFileService } from '../common/chatPromptFileService';
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
@@ -58,7 +57,6 @@ import { CustomSessionTitleService } from '../copilotcli/vscode-node/customSessi
import { GHPR_EXTENSION_ID } from '../vscode/chatSessionsUriHandler';
import { AgentSessionsWorkspace } from './agentSessionsWorkspace';
import { UserQuestionHandler } from './askUserQuestionHandler';
import { ChatPromptFileService } from './chatPromptFileService';
import { ChatSessionMetadataStore } from './chatSessionMetadataStoreImpl';
import { ChatSessionRepositoryTracker } from './chatSessionRepositoryTracker';
import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderServiceImpl';
@@ -144,7 +142,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
[IChatSessionWorktreeCheckpointService, new SyncDescriptor(ChatSessionWorktreeCheckpointService)],
[IChatSessionWorkspaceFolderService, new SyncDescriptor(ChatSessionWorkspaceFolderService)],
[IFolderRepositoryManager, new SyncDescriptor(ClaudeFolderRepositoryManager)],
[IChatPromptFileService, new SyncDescriptor(ChatPromptFileService)],
[IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService)],
));
const claudeAgentManager = this._register(claudeAgentInstaService.createInstance(ClaudeAgentManager));
@@ -168,7 +165,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const copilotcliAgentInstaService = instantiationService.createChild(
new ServiceCollection(
[IAgentSessionsWorkspace, new SyncDescriptor(AgentSessionsWorkspace)],
[IChatPromptFileService, new SyncDescriptor(ChatPromptFileService)],
[ICopilotCLIImageSupport, new SyncDescriptor(CopilotCLIImageSupport)],
[ICopilotCLISessionService, new SyncDescriptor(CopilotCLISessionService)],
[IChatDelegationSummaryService, delegationSummary],
@@ -222,7 +218,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatPromptFileService)));
this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib));
copilotModels.registerLanguageModelChatProvider(vscode.lm);
@@ -243,7 +238,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const copilotcliAgentInstaService = instantiationService.createChild(
new ServiceCollection(
[IAgentSessionsWorkspace, new SyncDescriptor(AgentSessionsWorkspace)],
[IChatPromptFileService, new SyncDescriptor(ChatPromptFileService)],
[ICopilotCLIImageSupport, new SyncDescriptor(CopilotCLIImageSupport)],
[ICopilotCLISessionService, new SyncDescriptor(CopilotCLISessionService)],
[IChatDelegationSummaryService, delegationSummary],
@@ -324,7 +318,6 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const copilotFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatPromptFileService)));
this._register(copilotcliAgentInstaService.createInstance(CopilotCLIContrib));
copilotModels.registerLanguageModelChatProvider(vscode.lm);
@@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { AGENT_FILE_EXTENSION, SKILL_FILENAME } from '../../../platform/customInstructions/common/promptTypes';
import { INativeEnvService } from '../../../platform/env/common/envService';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { ILogService } from '../../../platform/log/common/logService';
@@ -15,7 +14,7 @@ import { basename } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService';
import { ClaudeSessionUri } from '../claude/common/claudeSessionUri';
import { IChatPromptFileService } from '../common/chatPromptFileService';
import { IPromptsService } from '../../../platform/promptFiles/common/promptsService';
// TODO: Consider reporting Claude slash commands (from Query.supportedCommands()) when appropriate
// TODO: Report MCP servers when ChatSessionCustomizationType.Mcp is available (use Query.mcpServerStatus())
@@ -79,7 +78,7 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
}
constructor(
@IChatPromptFileService private readonly chatPromptFileService: IChatPromptFileService,
@IPromptsService private readonly promptsService: IPromptsService,
@IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@@ -89,12 +88,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
super();
this._register(this.runtimeDataService.onDidChange(() => this._onDidChange.fire()));
this._register(this.chatPromptFileService.onDidChangeCustomAgents(() => this._onDidChange.fire()));
this._register(this.chatPromptFileService.onDidChangeSkills(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangeSkills(() => this._onDidChange.fire()));
this._register(this.workspaceService.onDidChangeWorkspaceFolders(() => this._onDidChange.fire()));
}
async provideChatSessionCustomizations(_token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
const items: vscode.ChatSessionCustomizationItem[] = [];
// Agents: hybrid approach — file-based .claude/ agents merged with SDK-provided agents.
@@ -114,9 +113,9 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
}
// File-based agents from .claude/ paths — shown pre-session, deduplicated with SDK
for (const agent of this.chatPromptFileService.customAgents) {
for (const agent of await this.promptsService.getCustomAgents(token)) {
if (this.isClaudePath(agent.uri)) {
const name = deriveNameFromUri(agent.uri, AGENT_FILE_EXTENSION);
const name = agent.name;
if (!sdkAgentNames.has(name.toLowerCase())) {
items.push({
uri: agent.uri,
@@ -137,12 +136,12 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
// Skills from .claude/skills/ directories (user-defined SKILL.md files)
const skillItems: vscode.ChatSessionCustomizationItem[] = [];
for (const skill of this.chatPromptFileService.skills) {
for (const skill of await this.promptsService.getSkills(token)) {
if (this.isClaudePath(skill.uri)) {
const item: vscode.ChatSessionCustomizationItem = {
uri: skill.uri,
type: vscode.ChatSessionCustomizationType.Skill,
name: deriveNameFromUri(skill.uri, SKILL_FILENAME),
name: skill.name,
};
skillItems.push(item);
}
@@ -276,15 +275,3 @@ export class ClaudeCustomizationProvider extends Disposable implements vscode.Ch
}
}
function deriveNameFromUri(uri: vscode.Uri, extensionOrFilename: string): string {
const filename = basename(uri);
if (filename.toLowerCase() === extensionOrFilename.toLowerCase()) {
// For files like SKILL.md, use the parent directory name
const parts = uri.path.split('/');
return parts.length >= 2 ? parts[parts.length - 2] : filename;
}
if (filename.endsWith(extensionOrFilename)) {
return filename.slice(0, -extensionOrFilename.length);
}
return filename;
}
@@ -6,7 +6,6 @@
import * as l10n from '@vscode/l10n';
import * as vscode from 'vscode';
import { ICustomInstructionsService } from '../../../platform/customInstructions/common/customInstructionsService';
import { INSTRUCTION_FILE_EXTENSION, SKILL_FILENAME } from '../../../platform/customInstructions/common/promptTypes';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { ILogService } from '../../../platform/log/common/logService';
import { IPromptsService } from '../../../platform/promptFiles/common/promptsService';
@@ -17,7 +16,6 @@ import { Emitter } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { basename } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { IChatPromptFileService } from '../common/chatPromptFileService';
import { ICopilotCLIAgents } from '../copilotcli/node/copilotCli';
export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider {
@@ -40,7 +38,6 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
}
constructor(
@IChatPromptFileService private readonly chatPromptFileService: IChatPromptFileService,
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
@ICustomInstructionsService private readonly customInstructionsService: ICustomInstructionsService,
@IPromptsService private readonly promptsService: IPromptsService,
@@ -50,20 +47,28 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
) {
super();
this._register(this.chatPromptFileService.onDidChangeCustomAgents(() => this._onDidChange.fire()));
this._register(this.chatPromptFileService.onDidChangeInstructions(() => this._onDidChange.fire()));
this._register(this.chatPromptFileService.onDidChangeSkills(() => this._onDidChange.fire()));
this._register(this.chatPromptFileService.onDidChangeHooks(() => this._onDidChange.fire()));
this._register(this.chatPromptFileService.onDidChangePlugins(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangeCustomAgents(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangeInstructions(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangeSkills(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangeHooks(() => this._onDidChange.fire()));
this._register(this.promptsService.onDidChangePlugins(() => this._onDidChange.fire()));
this._register(this.copilotCLIAgents.onDidChangeAgents(() => this._onDidChange.fire()));
}
async provideChatSessionCustomizations(token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
const agents = await this.getAgentItems();
const instructions = await this.getInstructionItems(token);
const skills = this.getSkillItems();
const hooks = this.getHookItems();
const plugins = this.getPluginItems();
const [agents, instructions, skills, hooks, plugins] = await Promise.all([
this.getAgentItems(token),
this.getInstructionItems(token),
this.getSkillItems(token),
this.getHookItems(token),
this.getPluginItems(token),
].map(p => p.catch(err => {
if (isCancellationError(err) || token.isCancellationRequested) {
throw err;
}
this.logService.error(`[CopilotCLICustomizationProvider] failed to get customizations: ${err}`);
return [];
})));
this.logService.debug(`[CopilotCLICustomizationProvider] agents (${agents.length}): ${agents.map(a => a.name).join(', ') || '(none)'}`);
this.logService.debug(`[CopilotCLICustomizationProvider] instructions (${instructions.length}): ${instructions.map(i => i.name).join(', ') || '(none)'}`);
@@ -81,7 +86,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
* Builds agent items from ICopilotCLIAgents, which already merges SDK
* and prompt-file agents with source URIs.
*/
private async getAgentItems(): Promise<vscode.ChatSessionCustomizationItem[]> {
private async getAgentItems(_token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
const agentInfos = await this.copilotCLIAgents.getAgents();
return agentInfos.map(({ agent, sourceUri }) => ({
uri: sourceUri,
@@ -121,7 +126,7 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
// Emit agent instruction files (AGENTS.md, CLAUDE.md, copilot-instructions.md)
// that come from customInstructionsService but may not appear in
// chatPromptFileService.instructions.
// promptsService.getInstructions().
for (const uri of agentInstructionUriList) {
seenUris.add(uri.toString());
items.push({
@@ -132,27 +137,16 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
});
}
for (const instruction of this.chatPromptFileService.instructions) {
for (const instruction of await this.promptsService.getInstructions(token)) {
const uri = instruction.uri;
if (seenUris.has(uri.toString())) {
continue; // already emitted as agent instruction
}
const name = deriveNameFromUri(uri, INSTRUCTION_FILE_EXTENSION);
let pattern: string | undefined;
let description: string | undefined;
try {
const parsed = await this.promptsService.parseFile(uri, token);
pattern = parsed.header?.applyTo;
description = parsed.header?.description;
} catch (err) {
if (isCancellationError(err) || token.isCancellationRequested) {
throw err;
}
this.logService.debug(`[CopilotCLICustomizationProvider] failed to parse ${uri.toString()}: ${err}`);
}
const name = instruction.name;
const pattern = instruction.pattern;
const description = instruction.description;
if (pattern !== undefined) {
const badge = pattern === '**'
@@ -187,11 +181,11 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
/**
* Collects all skill items from the prompt file service.
*/
private getSkillItems(): vscode.ChatSessionCustomizationItem[] {
return this.chatPromptFileService.skills.map(s => ({
private async getSkillItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
return (await this.promptsService.getSkills(token)).map(s => ({
uri: s.uri,
type: vscode.ChatSessionCustomizationType.Skill,
name: deriveNameFromUri(s.uri, SKILL_FILENAME),
name: s.name,
}));
}
@@ -199,34 +193,22 @@ export class CopilotCLICustomizationProvider extends Disposable implements vscod
* Collects all hook items from the prompt file service.
* Each item is a hook configuration file (JSON).
*/
private getHookItems(): vscode.ChatSessionCustomizationItem[] {
return this.chatPromptFileService.hooks.map(h => ({
private async getHookItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
return (await this.promptsService.getHooks(token)).map(h => ({
uri: h.uri,
type: vscode.ChatSessionCustomizationType.Hook,
name: basename(h.uri).replace(/\.json$/i, ''),
}));
}
/** * Collects all plugin items from the prompt file service.
/**
* Collects all plugin items from the prompt file service.
*/
private getPluginItems(): vscode.ChatSessionCustomizationItem[] {
return this.chatPromptFileService.plugins.map(p => ({
private async getPluginItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
return (await this.promptsService.getPlugins(token)).map(p => ({
uri: p.uri,
type: vscode.ChatSessionCustomizationType.Plugins,
name: basename(p.uri),
}));
}
}
function deriveNameFromUri(uri: vscode.Uri, extensionOrFilename: string): string {
const filename = basename(uri);
if (filename.toLowerCase() === extensionOrFilename.toLowerCase()) {
// For files like SKILL.md, use the parent directory name
const parts = uri.path.split('/');
return parts.length >= 2 ? parts[parts.length - 2] : filename;
}
if (filename.endsWith(extensionOrFilename)) {
return filename.slice(0, -extensionOrFilename.length);
}
return filename;
}
@@ -15,8 +15,16 @@ import { Emitter, Event } from '../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../util/vs/base/common/uri';
import { IClaudeRuntimeDataService } from '../../claude/common/claudeRuntimeDataService';
import { IChatPromptFileService } from '../../common/chatPromptFileService';
import { ClaudeCustomizationProvider } from '../claudeCustomizationProvider';
import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';
function mockAgent(uri: URI, name: string): vscode.ChatCustomAgent {
return { uri, name, source: 'local', userInvocable: true, disableModelInvocation: false } as vscode.ChatCustomAgent;
}
function mockSkill(uri: URI, name: string): vscode.ChatSkill {
return { uri, name, source: 'local' } as vscode.ChatSkill;
}
class FakeChatSessionCustomizationType {
static readonly Agent = new FakeChatSessionCustomizationType('agent');
@@ -38,32 +46,6 @@ class MockRuntimeDataService extends mock<IClaudeRuntimeDataService>() {
dispose() { this._onDidChange.dispose(); }
}
class MockChatPromptFileService extends mock<IChatPromptFileService>() {
private readonly _onDidChangeCustomAgents = new Emitter<void>();
override readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event;
private readonly _onDidChangeInstructions = new Emitter<void>();
override readonly onDidChangeInstructions = this._onDidChangeInstructions.event;
private readonly _onDidChangeSkills = new Emitter<void>();
override readonly onDidChangeSkills = this._onDidChangeSkills.event;
private _customAgents: vscode.ChatResource[] = [];
private _skills: vscode.ChatResource[] = [];
override get customAgents(): readonly vscode.ChatResource[] { return this._customAgents; }
override get skills(): readonly vscode.ChatResource[] { return this._skills; }
setCustomAgents(agents: vscode.ChatResource[]) { this._customAgents = agents; }
setSkills(skills: vscode.ChatResource[]) { this._skills = skills; }
fireCustomAgentsChanged() { this._onDidChangeCustomAgents.fire(); }
fireSkillsChanged() { this._onDidChangeSkills.fire(); }
override dispose() {
this._onDidChangeCustomAgents.dispose();
this._onDidChangeInstructions.dispose();
this._onDidChangeSkills.dispose();
}
}
class MockWorkspaceService extends mock<IWorkspaceService>() {
private _folders: URI[] = [];
private readonly _onDidChange = new Emitter<void>();
@@ -105,7 +87,7 @@ class TestLogService extends mock<ILogService>() {
describe('ClaudeCustomizationProvider', () => {
let disposables: DisposableStore;
let mockRuntimeDataService: MockRuntimeDataService;
let mockPromptFileService: MockChatPromptFileService;
let mockPromptsService: MockPromptsService;
let mockWorkspaceService: MockWorkspaceService;
let mockFileSystemService: MockFileSystemService;
let provider: ClaudeCustomizationProvider;
@@ -117,11 +99,11 @@ describe('ClaudeCustomizationProvider', () => {
(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;
disposables = new DisposableStore();
mockRuntimeDataService = disposables.add(new MockRuntimeDataService());
mockPromptFileService = disposables.add(new MockChatPromptFileService());
mockPromptsService = disposables.add(new MockPromptsService());
mockWorkspaceService = new MockWorkspaceService();
mockFileSystemService = new MockFileSystemService();
provider = disposables.add(new ClaudeCustomizationProvider(
mockPromptFileService,
mockPromptsService,
mockRuntimeDataService,
mockWorkspaceService,
mockFileSystemService,
@@ -199,8 +181,8 @@ describe('ClaudeCustomizationProvider', () => {
it('shows file-based agents from .claude/ paths before session starts', async () => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
mockPromptFileService.setCustomAgents([
{ uri: URI.file('/workspace/.claude/agents/my-agent.agent.md') },
mockPromptsService.setCustomAgents([
mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'),
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -215,8 +197,8 @@ describe('ClaudeCustomizationProvider', () => {
mockRuntimeDataService.setAgents([
{ name: 'my-agent', description: 'SDK version' },
]);
mockPromptFileService.setCustomAgents([
{ uri: URI.file('/workspace/.claude/agents/my-agent.agent.md') },
mockPromptsService.setCustomAgents([
mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'),
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -228,9 +210,9 @@ describe('ClaudeCustomizationProvider', () => {
it('filters out file agents not under .claude/', async () => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
mockPromptFileService.setCustomAgents([
{ uri: URI.file('/workspace/.github/my-agent.agent.md') },
{ uri: URI.file('/workspace/root.agent.md') },
mockPromptsService.setCustomAgents([
mockAgent(URI.file('/workspace/.github/my-agent.agent.md'), 'my-agent'),
mockAgent(URI.file('/workspace/root.agent.md'), 'root-agent'),
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -303,7 +285,7 @@ describe('ClaudeCustomizationProvider', () => {
it('returns skills under .claude/skills/', async () => {
const uri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
mockPromptsService.setSkills([mockSkill(uri, 'my-skill')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
@@ -313,9 +295,9 @@ describe('ClaudeCustomizationProvider', () => {
});
it('filters out skills not under .claude/', async () => {
mockPromptFileService.setSkills([
{ uri: URI.file('/workspace/.github/skills/copilot-skill/SKILL.md') },
{ uri: URI.file('/workspace/.copilot/skills/other/SKILL.md') },
mockPromptsService.setSkills([
mockSkill(URI.file('/workspace/.github/skills/copilot-skill/SKILL.md'), 'copilot-skill'),
mockSkill(URI.file('/workspace/.copilot/skills/other/SKILL.md'), 'other-skill'),
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -325,7 +307,7 @@ describe('ClaudeCustomizationProvider', () => {
it('includes skills from user home .claude/ directory', async () => {
const uri = URI.file('/home/user/.claude/skills/global-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
mockPromptsService.setSkills([mockSkill(uri, 'global-skill')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
@@ -338,7 +320,7 @@ describe('ClaudeCustomizationProvider', () => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
mockRuntimeDataService.setAgents([{ name: 'Explore', description: 'Agent' }]);
mockFileSystemService.setFile(URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'), '# Instructions');
mockPromptFileService.setSkills([{ uri: URI.file('/workspace/.claude/skills/s/SKILL.md') }]);
mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/s/SKILL.md'), 's')]);
mockFileSystemService.setFile(
URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'),
JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } })
@@ -465,7 +447,7 @@ describe('ClaudeCustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireCustomAgentsChanged();
mockPromptsService.fireCustomAgentsChanged();
expect(fired).toBe(true);
});
@@ -473,7 +455,7 @@ describe('ClaudeCustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireSkillsChanged();
mockPromptsService.fireSkillsChanged();
expect(fired).toBe(true);
});
@@ -17,7 +17,6 @@ import { IGitService, RepoContext } from '../../../../platform/git/common/gitSer
import { IOctoKitService } from '../../../../platform/github/common/githubService';
import { ILogService } from '../../../../platform/log/common/logService';
import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index';
import { PromptsServiceImpl } from '../../../../platform/promptFiles/common/promptsServiceImpl';
import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
@@ -54,10 +53,10 @@ import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler';
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers';
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../copilotcli/node/userInputHelpers';
import { CustomSessionTitleService } from '../../copilotcli/vscode-node/customSessionTitleServiceImpl';
import { MockChatPromptFileService } from '../../copilotcli/vscode-node/test/testHelpers';
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant } from '../copilotCLIChatSessionsContribution';
import { CopilotCloudSessionsProvider } from '../copilotCloudSessionsProvider';
import { CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';
import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';
// Mock terminal integration to avoid importing PowerShell asset (.ps1) which Vite cannot parse during tests
vi.mock('../copilotCLITerminalIntegration', () => {
@@ -399,7 +398,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
}
} as unknown as IInstantiationService;
customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext, accessor.get(IInstantiationService), logService, new MockChatSessionMetadataStore());
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockChatPromptFileService())));
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService())));
manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager;
contentProvider = new class extends mock<CopilotCLIChatSessionContentProvider>() {
@@ -437,7 +436,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
workspaceFolderService,
telemetry,
logger,
new PromptsServiceImpl(new NullWorkspaceService(), fileSystem),
disposables.add(new MockPromptsService()),
delegationService,
folderRepositoryManager,
configurationService,
@@ -795,7 +794,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
workspaceFolderService,
telemetry,
logService,
new PromptsServiceImpl(new NullWorkspaceService(), new MockFileSystemService()),
disposables.add(new MockPromptsService()),
new class extends mock<IChatDelegationSummaryService>() {
override async summarize(_context: vscode.ChatContext, _token: vscode.CancellationToken): Promise<string | undefined> {
return undefined;
@@ -1908,7 +1907,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
workspaceFolderService,
telemetry,
logService,
new PromptsServiceImpl(new NullWorkspaceService(), new MockFileSystemService()),
disposables.add(new MockPromptsService()),
nullDelegationService,
folderRepositoryManager,
configurationService,
@@ -2041,7 +2040,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
workspaceFolderService,
telemetry,
logService,
new PromptsServiceImpl(new NullWorkspaceService(), new MockFileSystemService()),
disposables.add(new MockPromptsService()),
new (mock<IChatDelegationSummaryService>())(),
folderRepositoryManager,
configurationService,
@@ -7,16 +7,14 @@ import type { SweCustomAgent } from '@github/copilot/sdk';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as vscode from 'vscode';
import { ILogService } from '../../../../platform/log/common/logService';
import { IPromptsService, ParsedPromptFile, PromptFileParser } from '../../../../platform/promptFiles/common/promptsService';
import { MockCustomInstructionsService } from '../../../../platform/test/common/testCustomInstructionsService';
import { mock } from '../../../../util/common/test/simpleMock';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { Emitter } from '../../../../util/vs/base/common/event';
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../util/vs/base/common/uri';
import { IChatPromptFileService } from '../../common/chatPromptFileService';
import { CLIAgentInfo, ICopilotCLIAgents } from '../../copilotcli/node/copilotCli';
import { CopilotCLICustomizationProvider } from '../copilotCLICustomizationProvider';
import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';
class FakeChatSessionCustomizationType {
static readonly Agent = new FakeChatSessionCustomizationType('agent');
@@ -55,49 +53,24 @@ function makeFileAgentInfo(name: string, fileUri: URI, description = ''): CLIAge
};
}
class MockChatPromptFileService extends mock<IChatPromptFileService>() {
private readonly _onDidChangeCustomAgents = new Emitter<void>();
override readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event;
private readonly _onDidChangeInstructions = new Emitter<void>();
override readonly onDidChangeInstructions = this._onDidChangeInstructions.event;
private readonly _onDidChangeSkills = new Emitter<void>();
override readonly onDidChangeSkills = this._onDidChangeSkills.event;
private readonly _onDidChangeHooks = new Emitter<void>();
override readonly onDidChangeHooks = this._onDidChangeHooks.event;
private readonly _onDidChangePlugins = new Emitter<void>();
override readonly onDidChangePlugins = this._onDidChangePlugins.event;
/** Creates a ChatInstruction stub with the required name and source fields. */
function makeInstruction(uri: URI, name: string, pattern: string | undefined, description?: string): vscode.ChatInstruction {
return { uri, name, pattern, source: 'local', description };
}
private _customAgents: vscode.ChatResource[] = [];
private _instructions: vscode.ChatResource[] = [];
private _skills: vscode.ChatResource[] = [];
private _hooks: vscode.ChatResource[] = [];
private _plugins: vscode.ChatResource[] = [];
/** Creates a ChatSkill stub, deriving the name from the parent directory for SKILL.md files. */
function makeSkill(uri: URI, name: string): vscode.ChatSkill {
return { uri, name: name, source: 'local' };
}
override get customAgents(): readonly vscode.ChatResource[] { return this._customAgents; }
override get instructions(): readonly vscode.ChatResource[] { return this._instructions; }
override get skills(): readonly vscode.ChatResource[] { return this._skills; }
override get hooks(): readonly vscode.ChatResource[] { return this._hooks; }
override get plugins(): readonly vscode.ChatResource[] { return this._plugins; }
/** Creates a ChatHook stub. */
function makeHook(uri: URI): vscode.ChatHook {
return { uri };
}
setCustomAgents(agents: vscode.ChatResource[]) { this._customAgents = agents; }
setInstructions(instructions: vscode.ChatResource[]) { this._instructions = instructions; }
setSkills(skills: vscode.ChatResource[]) { this._skills = skills; }
setHooks(hooks: vscode.ChatResource[]) { this._hooks = hooks; }
setPlugins(plugins: vscode.ChatResource[]) { this._plugins = plugins; }
fireCustomAgentsChanged() { this._onDidChangeCustomAgents.fire(); }
fireInstructionsChanged() { this._onDidChangeInstructions.fire(); }
fireSkillsChanged() { this._onDidChangeSkills.fire(); }
fireHooksChanged() { this._onDidChangeHooks.fire(); }
firePluginsChanged() { this._onDidChangePlugins.fire(); }
override dispose() {
this._onDidChangeCustomAgents.dispose();
this._onDidChangeInstructions.dispose();
this._onDidChangeSkills.dispose();
this._onDidChangeHooks.dispose();
this._onDidChangePlugins.dispose();
}
/** Creates a ChatPlugin stub. */
function makePlugin(uri: URI): vscode.ChatPlugin {
return { uri };
}
class MockCopilotCLIAgents extends mock<ICopilotCLIAgents>() {
@@ -123,25 +96,11 @@ class TestCustomInstructionsService extends MockCustomInstructionsService {
override getAgentInstructions(): Promise<URI[]> { return Promise.resolve(this._agentInstructions); }
}
class TestPromptsService extends mock<IPromptsService>() {
private readonly parser = new PromptFileParser();
private _fileContents = new Map<string, string>();
/** Register content so parseFile returns a parsed result for the given URI. */
setFileContent(uri: URI, content: string) { this._fileContents.set(uri.toString(), content); }
override async parseFile(uri: URI, _token: CancellationToken): Promise<ParsedPromptFile> {
const content = this._fileContents.get(uri.toString()) ?? '';
return this.parser.parse(uri, content);
}
}
describe('CopilotCLICustomizationProvider', () => {
let disposables: DisposableStore;
let mockPromptFileService: MockChatPromptFileService;
let mockPromptsService: MockPromptsService;
let mockCopilotCLIAgents: MockCopilotCLIAgents;
let mockCustomInstructionsService: TestCustomInstructionsService;
let mockPromptsService: TestPromptsService;
let provider: CopilotCLICustomizationProvider;
let originalChatSessionCustomizationType: unknown;
@@ -150,12 +109,10 @@ describe('CopilotCLICustomizationProvider', () => {
originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;
(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;
disposables = new DisposableStore();
mockPromptFileService = disposables.add(new MockChatPromptFileService());
mockPromptsService = disposables.add(new MockPromptsService());
mockCopilotCLIAgents = disposables.add(new MockCopilotCLIAgents());
mockCustomInstructionsService = new TestCustomInstructionsService();
mockPromptsService = new TestPromptsService();
provider = disposables.add(new CopilotCLICustomizationProvider(
mockPromptFileService,
mockCopilotCLIAgents,
mockCustomInstructionsService,
mockPromptsService,
@@ -256,7 +213,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('returns instructions with on-demand groupKey when no applyTo pattern', async () => {
const uri = URI.file('/workspace/.github/copilot-instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
@@ -267,7 +224,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('returns skills', async () => {
const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
mockPromptsService.setSkills([makeSkill(uri, 'lint-check')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
@@ -278,7 +235,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('derives skill name from parent directory for SKILL.md files', async () => {
const uri = URI.file('/workspace/.copilot/skills/my-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
mockPromptsService.setSkills([makeSkill(uri, 'my-skill')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
@@ -287,10 +244,10 @@ describe('CopilotCLICustomizationProvider', () => {
it('returns all matching types combined', async () => {
mockCopilotCLIAgents.setAgents([makeAgentInfo('explore', 'Explore')]);
mockPromptFileService.setInstructions([{ uri: URI.file('/workspace/.github/b.instructions.md') }]);
mockPromptFileService.setSkills([{ uri: URI.file('/workspace/.github/skills/c/SKILL.md') }]);
mockPromptFileService.setHooks([{ uri: URI.file('/workspace/.copilot/hooks/pre-commit.json') }]);
mockPromptFileService.setPlugins([{ uri: URI.file('/workspace/.copilot/plugins/my-plugin') }]);
mockPromptsService.setInstructions([makeInstruction(URI.file('/workspace/.github/b.instructions.md'), 'b instructions', undefined)]);
mockPromptsService.setSkills([makeSkill(URI.file('/workspace/.github/skills/c/SKILL.md'), 'c')]);
mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/pre-commit.json'))]);
mockPromptsService.setPlugins([makePlugin(URI.file('/workspace/.copilot/plugins/my-plugin'))]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(5);
@@ -298,7 +255,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('returns hooks with correct type and name', async () => {
const uri = URI.file('/workspace/.copilot/hooks/diagnostics.json');
mockPromptFileService.setHooks([{ uri }]);
mockPromptsService.setHooks([makeHook(uri)]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
@@ -308,16 +265,16 @@ describe('CopilotCLICustomizationProvider', () => {
});
it('strips .json extension from hook file name', async () => {
mockPromptFileService.setHooks([{ uri: URI.file('/workspace/.copilot/hooks/security-checks.json') }]);
mockPromptsService.setHooks([makeHook(URI.file('/workspace/.copilot/hooks/security-checks.json'))]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items[0].name).toBe('security-checks');
});
it('returns multiple hooks', async () => {
mockPromptFileService.setHooks([
{ uri: URI.file('/workspace/.copilot/hooks/hooks.json') },
{ uri: URI.file('/workspace/.copilot/hooks/diagnostics.json') },
mockPromptsService.setHooks([
makeHook(URI.file('/workspace/.copilot/hooks/hooks.json')),
makeHook(URI.file('/workspace/.copilot/hooks/diagnostics.json')),
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -327,7 +284,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('returns plugins with correct type and name derived from URI', async () => {
const uri = URI.file('/workspace/.copilot/plugins/lint-rules');
mockPromptFileService.setPlugins([{ uri }]);
mockPromptsService.setPlugins([makePlugin(uri)]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
@@ -340,7 +297,7 @@ describe('CopilotCLICustomizationProvider', () => {
describe('instruction groupKeys and badges', () => {
it('uses agent-instructions groupKey for copilot-instructions.md files', async () => {
const uri = URI.file('/workspace/.github/copilot-instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setInstructions([makeInstruction(uri, 'copilot-instructions', undefined)]);
mockCustomInstructionsService.setAgentInstructionUris([uri]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -356,7 +313,7 @@ describe('CopilotCLICustomizationProvider', () => {
const copilotUri = URI.file('/workspace/.github/copilot-instructions.md');
// Agent instructions are NOT in chatPromptFileService.instructions —
// they come only from customInstructionsService.getAgentInstructions().
mockPromptFileService.setInstructions([]);
mockPromptsService.setInstructions([]);
mockCustomInstructionsService.setAgentInstructionUris([agentsUri, claudeUri, copilotUri]);
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -373,7 +330,6 @@ describe('CopilotCLICustomizationProvider', () => {
const existingUris = new Set([agentsUri.toString(), claudeUri.toString()]);
const testProvider = disposables.add(new CopilotCLICustomizationProvider(
mockPromptFileService,
mockCopilotCLIAgents,
mockCustomInstructionsService,
mockPromptsService,
@@ -386,7 +342,7 @@ describe('CopilotCLICustomizationProvider', () => {
} as any,
));
mockPromptFileService.setInstructions([]);
mockPromptsService.setInstructions([]);
mockCustomInstructionsService.setAgentInstructionUris([]);
const items = await testProvider.provideChatSessionCustomizations(undefined!);
@@ -398,13 +354,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('uses context-instructions groupKey with badge for instructions with applyTo pattern', async () => {
const uri = URI.file('/workspace/.github/style.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setFileContent(uri, [
'---',
'applyTo: \'src/**/*.ts\'',
'---',
'Use TypeScript best practices.',
].join('\n'));
mockPromptsService.setInstructions([makeInstruction(uri, 'style instructions', 'src/**/*.ts')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
@@ -416,13 +366,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('uses "always added" badge when applyTo is **', async () => {
const uri = URI.file('/workspace/.github/global.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setFileContent(uri, [
'---',
'applyTo: \'**\'',
'---',
'Global rules.',
].join('\n'));
mockPromptsService.setInstructions([makeInstruction(uri, 'global instructions', '**')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
@@ -434,13 +378,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('uses on-demand-instructions groupKey for instructions without applyTo', async () => {
const uri = URI.file('/workspace/.github/refactor.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setFileContent(uri, [
'---',
'description: \'Refactoring guidelines\'',
'---',
'Prefer small functions.',
].join('\n'));
mockPromptsService.setInstructions([makeInstruction(uri, 'refactor instructions', undefined, 'Refactoring guidelines')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
@@ -452,14 +390,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('includes description from parsed header', async () => {
const uri = URI.file('/workspace/.github/testing.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setFileContent(uri, [
'---',
'applyTo: \'**/*.spec.ts\'',
'description: \'Testing standards\'',
'---',
'Write unit tests with vitest.',
].join('\n'));
mockPromptsService.setInstructions([makeInstruction(uri, 'testing instructions', '**/*.spec.ts', 'Testing standards')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const instrItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
@@ -472,7 +403,7 @@ describe('CopilotCLICustomizationProvider', () => {
const agentUri = URI.file('/workspace/.github/copilot-instructions.md');
const contextUri = URI.file('/workspace/.github/style.instructions.md');
const onDemandUri = URI.file('/workspace/.github/refactor.instructions.md');
mockPromptFileService.setInstructions([{ uri: agentUri }, { uri: contextUri }, { uri: onDemandUri }]);
mockPromptsService.setInstructions([makeInstruction(agentUri, 'copilot instructions', undefined), makeInstruction(contextUri, 'style instructions', 'src/**'), makeInstruction(onDemandUri, 'refactor instructions', undefined)]);
mockCustomInstructionsService.setAgentInstructionUris([agentUri]);
mockPromptsService.setFileContent(contextUri, '---\napplyTo: \'src/**\'\n---\nStyle rules.');
mockPromptsService.setFileContent(onDemandUri, '---\ndescription: Refactoring\n---\nRefactor tips.');
@@ -497,7 +428,7 @@ describe('CopilotCLICustomizationProvider', () => {
it('falls back to on-demand-instructions when file has no YAML header', async () => {
const uri = URI.file('/workspace/.github/plain.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
mockPromptsService.setInstructions([makeInstruction(uri, 'plain instructions', undefined)]);
mockPromptsService.setFileContent(uri, 'Just plain text, no frontmatter.');
const items = await provider.provideChatSessionCustomizations(undefined!);
@@ -513,7 +444,7 @@ describe('CopilotCLICustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireCustomAgentsChanged();
mockPromptsService.fireCustomAgentsChanged();
expect(fired).toBe(true);
});
@@ -521,7 +452,7 @@ describe('CopilotCLICustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireInstructionsChanged();
mockPromptsService.fireInstructionsChanged();
expect(fired).toBe(true);
});
@@ -529,7 +460,7 @@ describe('CopilotCLICustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireSkillsChanged();
mockPromptsService.fireSkillsChanged();
expect(fired).toBe(true);
});
@@ -537,7 +468,7 @@ describe('CopilotCLICustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireHooksChanged();
mockPromptsService.fireHooksChanged();
expect(fired).toBe(true);
});
@@ -545,7 +476,7 @@ describe('CopilotCLICustomizationProvider', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.firePluginsChanged();
mockPromptsService.firePluginsChanged();
expect(fired).toBe(true);
});
@@ -64,7 +64,7 @@ import { IUrlOpener, NullUrlOpener } from '../../../platform/open/common/opener'
import { RealUrlOpener } from '../../../platform/open/vscode/opener';
import { IProjectTemplatesIndex, ProjectTemplatesIndex } from '../../../platform/projectTemplatesIndex/common/projectTemplatesIndex';
import { IPromptsService } from '../../../platform/promptFiles/common/promptsService';
import { PromptsServiceImpl } from '../../../platform/promptFiles/common/promptsServiceImpl';
import { PromptsServiceImpl } from '../../../platform/promptFiles/vscode/promptsServiceImpl';
import { IPromptPathRepresentationService, PromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService';
import { IReleaseNotesService } from '../../../platform/releaseNotes/common/releaseNotesService';
import { ReleaseNotesService } from '../../../platform/releaseNotes/vscode/releaseNotesServiceImpl';
@@ -62,7 +62,7 @@ import { INotebookService } from '../../../platform/notebook/common/notebookServ
import { INotificationService, NullNotificationService } from '../../../platform/notification/common/notificationService';
import { IOTelSqliteStore, OTelSqliteStore } from '../../../platform/otel/node/sqlite/otelSqliteStore';
import { IPromptsService } from '../../../platform/promptFiles/common/promptsService';
import { PromptsServiceImpl } from '../../../platform/promptFiles/common/promptsServiceImpl';
import { PromptsServiceImpl } from '../../../platform/promptFiles/vscode/promptsServiceImpl';
import { IPromptPathRepresentationService, PromptPathRepresentationService } from '../../../platform/prompts/common/promptPathRepresentationService';
import { IProxyModelsService, NullProxyModelsService } from '../../../platform/proxyModels/common/proxyModelsService';
import { IRemoteRepositoriesService, RemoteRepositoriesService } from '../../../platform/remoteRepositories/vscode/remoteRepositories';
@@ -3,7 +3,9 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode';
import { createServiceIdentifier } from '../../../util/common/services';
import { Event } from '../../../util/vs/base/common/event';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { URI } from '../../../util/vs/base/common/uri';
import { ParsedPromptFile } from '../../../util/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser';
@@ -29,4 +31,66 @@ export interface IPromptsService {
*/
parseFile(uri: URI, token: CancellationToken): Promise<ParsedPromptFile>;
/**
* An event that fires when the list of {@link customAgents custom agents} changes.
*/
readonly onDidChangeCustomAgents: Event<void>;
/**
* The list of currently available custom agents. These are `.agent.md` files
* from all sources (workspace, user, and extension-provided).
*/
getCustomAgents(token: CancellationToken): Promise<readonly ChatCustomAgent[]>;
/**
* Returns the slash command prompt files. These are prompts and skills
* from all sources (workspace, user, and extension-provided).
*/
getSlashCommands(token: CancellationToken): Promise<readonly ParsedPromptFile[]>;
/**
* An event that fires when the list of {@link instructions instructions} changes.
*/
readonly onDidChangeInstructions: Event<void>;
/**
* The list of currently available instructions. These are `.instructions.md` files
* from all sources (workspace, user, and extension-provided).
*/
getInstructions(token: CancellationToken): Promise<readonly ChatInstruction[]>;
/**
* An event that fires when the list of {@link skills skills} changes.
*/
readonly onDidChangeSkills: Event<void>;
/**
* The list of currently available skills. These are `SKILL.md` files
* from all sources (workspace, user, and extension-provided).
*/
getSkills(token: CancellationToken): Promise<readonly ChatSkill[]>;
/**
* An event that fires when the list of {@link hooks hooks} changes.
*/
readonly onDidChangeHooks: Event<void>;
/**
* The list of currently available hook configuration files.
* These are JSON files that define lifecycle hooks from all sources
* (workspace, user, and extension-provided).
*/
getHooks(token: CancellationToken): Promise<readonly ChatHook[]>;
/**
* An event that fires when the list of {@link plugins plugins} changes.
*/
readonly onDidChangePlugins: Event<void>;
/**
* The list of currently installed agent plugins.
*/
getPlugins(token: CancellationToken): Promise<readonly ChatPlugin[]>;
}
@@ -1,37 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { raceCancellationError } from '../../../util/vs/base/common/async';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { PromptFileParser } from '../../../util/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser';
import { IFileSystemService } from '../../filesystem/common/fileSystemService';
import { IWorkspaceService } from '../../workspace/common/workspaceService';
import { IPromptsService, ParsedPromptFile } from './promptsService';
export class PromptsServiceImpl implements IPromptsService {
declare _serviceBrand: undefined;
constructor(
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IFileSystemService private readonly fileService: IFileSystemService,
) { }
public async parseFile(uri: URI, token: CancellationToken): Promise<ParsedPromptFile> {
// a temporary workaround to avoid creating a text document to read the file content, which triggers the validation of the file in core (fixed in 1.114)
const getTextContent = async (uri: URI) => {
const existingDoc = this.workspaceService.textDocuments.find(doc => extUriBiasedIgnorePathCase.isEqual(doc.uri, uri));
if (!existingDoc) {
// if the document is not already open in the workspace, check if the file exists on disk before trying to open it, to avoid triggering unwanted "file not found" errors from the text document service
const bytes = await this.fileService.readFile(uri);
return new TextDecoder().decode(bytes);
} else {
return existingDoc.getText();
}
};
const text = await raceCancellationError(getTextContent(uri), token);
return new PromptFileParser().parse(uri, text);
}
}
@@ -0,0 +1,123 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { Emitter, Event } from '../../../../util/vs/base/common/event';
import { Disposable } from '../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../util/vs/base/common/uri';
import { PromptFileParser } from '../../../../util/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser';
import { IPromptsService, ParsedPromptFile } from '../../common/promptsService';
import { ResourceMap } from '../../../../util/vs/base/common/map';
export class MockPromptsService extends Disposable implements IPromptsService {
declare readonly _serviceBrand: undefined;
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event;
private readonly _onDidChangeInstructions = this._register(new Emitter<void>());
readonly onDidChangeInstructions: Event<void> = this._onDidChangeInstructions.event;
private readonly _onDidChangeSkills = this._register(new Emitter<void>());
readonly onDidChangeSkills: Event<void> = this._onDidChangeSkills.event;
private readonly _onDidChangeHooks = this._register(new Emitter<void>());
readonly onDidChangeHooks: Event<void> = this._onDidChangeHooks.event;
private readonly _onDidChangePlugins = this._register(new Emitter<void>());
readonly onDidChangePlugins: Event<void> = this._onDidChangePlugins.event;
private _customAgents: readonly ChatCustomAgent[] = [];
private _slashCommands: readonly ParsedPromptFile[] = [];
private _instructions: readonly ChatInstruction[] = [];
private _skills: readonly ChatSkill[] = [];
private _hooks: readonly ChatHook[] = [];
private _plugins: readonly ChatPlugin[] = [];
private _fileContents = new ResourceMap<string>();
setCustomAgents(agents: readonly ChatCustomAgent[]): void {
this._customAgents = agents;
this._onDidChangeCustomAgents.fire();
}
fireCustomAgentsChanged(): void {
this._onDidChangeCustomAgents.fire();
}
setSlashCommands(commands: readonly ParsedPromptFile[]): void {
this._slashCommands = commands;
}
setInstructions(instructions: readonly ChatInstruction[]): void {
this._instructions = instructions;
this._onDidChangeInstructions.fire();
}
fireInstructionsChanged(): void {
this._onDidChangeInstructions.fire();
}
setSkills(skills: readonly ChatSkill[]): void {
this._skills = skills;
this._onDidChangeSkills.fire();
}
fireSkillsChanged(): void {
this._onDidChangeSkills.fire();
}
setHooks(hooks: readonly ChatHook[]): void {
this._hooks = hooks;
this._onDidChangeHooks.fire();
}
firePluginsChanged(): void {
this._onDidChangePlugins.fire();
}
setPlugins(plugins: readonly ChatPlugin[]): void {
this._plugins = plugins;
this._onDidChangePlugins.fire();
}
getCustomAgents(_token: CancellationToken): Promise<readonly ChatCustomAgent[]> {
return Promise.resolve(this._customAgents);
}
getSlashCommands(_token: CancellationToken): Promise<readonly ParsedPromptFile[]> {
return Promise.resolve(this._slashCommands);
}
getInstructions(_token: CancellationToken): Promise<readonly ChatInstruction[]> {
return Promise.resolve(this._instructions);
}
getSkills(_token: CancellationToken): Promise<readonly ChatSkill[]> {
return Promise.resolve(this._skills);
}
getHooks(_token: CancellationToken): Promise<readonly ChatHook[]> {
return Promise.resolve(this._hooks);
}
fireHooksChanged(): void {
this._onDidChangeHooks.fire();
}
getPlugins(_token: CancellationToken): Promise<readonly ChatPlugin[]> {
return Promise.resolve(this._plugins);
}
/** Register content so parseFile returns a parsed result for the given URI. */
setFileContent(uri: URI, content: string) {
this._fileContents.set(uri, content);
}
parseFile(uri: URI, _token: CancellationToken): Promise<ParsedPromptFile> {
const content = this._fileContents.get(uri) ?? '';
return Promise.resolve(new PromptFileParser().parse(uri, content));
}
}
@@ -0,0 +1,91 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { ChatCustomAgent, ChatHook, ChatInstruction, ChatPlugin, ChatSkill } from 'vscode';
import * as vscode from 'vscode';
import { raceCancellationError } from '../../../util/vs/base/common/async';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { Emitter, Event } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { extUriBiasedIgnorePathCase } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { PromptFileParser } from '../../../util/vs/workbench/contrib/chat/common/promptSyntax/promptFileParser';
import { IFileSystemService } from '../../filesystem/common/fileSystemService';
import { IWorkspaceService } from '../../workspace/common/workspaceService';
import { IPromptsService, ParsedPromptFile } from '../common/promptsService';
export class PromptsServiceImpl extends Disposable implements IPromptsService {
declare _serviceBrand: undefined;
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
readonly onDidChangeCustomAgents: Event<void> = this._onDidChangeCustomAgents.event;
private readonly _onDidChangeInstructions = this._register(new Emitter<void>());
readonly onDidChangeInstructions: Event<void> = this._onDidChangeInstructions.event;
private readonly _onDidChangeSkills = this._register(new Emitter<void>());
readonly onDidChangeSkills: Event<void> = this._onDidChangeSkills.event;
private readonly _onDidChangeHooks = this._register(new Emitter<void>());
readonly onDidChangeHooks: Event<void> = this._onDidChangeHooks.event;
private readonly _onDidChangePlugins = this._register(new Emitter<void>());
readonly onDidChangePlugins: Event<void> = this._onDidChangePlugins.event;
constructor(
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IFileSystemService private readonly fileService: IFileSystemService,
) {
super();
this._register(vscode.chat.onDidChangeCustomAgents(() => this._onDidChangeCustomAgents.fire()));
this._register(vscode.chat.onDidChangeInstructions(() => this._onDidChangeInstructions.fire()));
this._register(vscode.chat.onDidChangeSkills(() => this._onDidChangeSkills.fire()));
this._register(vscode.chat.onDidChangeHooks(() => this._onDidChangeHooks.fire()));
this._register(vscode.chat.onDidChangePlugins(() => this._onDidChangePlugins.fire()));
}
getCustomAgents(token: CancellationToken): Promise<readonly ChatCustomAgent[]> {
return Promise.resolve(vscode.chat.getCustomAgents(token));
}
getSlashCommands(token: CancellationToken): Promise<readonly ParsedPromptFile[]> {
return Promise.resolve(vscode.chat.getSlashCommands(token));
}
getInstructions(token: CancellationToken): Promise<readonly ChatInstruction[]> {
return Promise.resolve(vscode.chat.getInstructions(token));
}
getSkills(token: CancellationToken): Promise<readonly ChatSkill[]> {
return Promise.resolve(vscode.chat.getSkills(token));
}
getHooks(token: CancellationToken): Promise<readonly ChatHook[]> {
return Promise.resolve(vscode.chat.getHooks(token));
}
getPlugins(token: CancellationToken): Promise<readonly ChatPlugin[]> {
return Promise.resolve(vscode.chat.getPlugins(token));
}
public async parseFile(uri: URI, token: CancellationToken): Promise<ParsedPromptFile> {
// a temporary workaround to avoid creating a text document to read the file content, which triggers the validation of the file in core (fixed in 1.114)
const getTextContent = async (uri: URI) => {
const existingDoc = this.workspaceService.textDocuments.find(doc => extUriBiasedIgnorePathCase.isEqual(doc.uri, uri));
if (!existingDoc) {
// if the document is not already open in the workspace, check if the file exists on disk before trying to open it, to avoid triggering unwanted "file not found" errors from the text document service
const bytes = await this.fileService.readFile(uri);
return new TextDecoder().decode(bytes);
} else {
return existingDoc.getText();
}
};
const text = await raceCancellationError(getTextContent(uri), token);
return new PromptFileParser().parse(uri, text);
}
}
+2 -36
View File
@@ -9,11 +9,10 @@ import * as fs from 'fs/promises';
import * as http from 'http';
import { platform, tmpdir } from 'os';
import * as path from 'path';
import type { ChatParticipantToolToken, ChatPromptReference, ChatResource } from 'vscode';
import type { ChatParticipantToolToken, ChatPromptReference } from 'vscode';
import { OpenAIAdapterFactoryForSTests } from '../../src/extension/agents/node/adapters/openaiAdapterForSTests';
import { ILanguageModelServer, ILanguageModelServerConfig, LanguageModelServer } from '../../src/extension/agents/node/langModelServer';
import { IAgentSessionsWorkspace } from '../../src/extension/chatSessions/common/agentSessionsWorkspace';
import { IChatPromptFileService } from '../../src/extension/chatSessions/common/chatPromptFileService';
import { IChatSessionMetadataStore } from '../../src/extension/chatSessions/common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../../src/extension/chatSessions/common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../../src/extension/chatSessions/common/chatSessionWorktreeService';
@@ -46,9 +45,8 @@ import { createServiceIdentifier } from '../../src/util/common/services';
import { ChatReferenceDiagnostic } from '../../src/util/common/test/shims/chatTypes';
import { disposableTimeout, IntervalTimer } from '../../src/util/vs/base/common/async';
import { CancellationToken } from '../../src/util/vs/base/common/cancellation';
import { Event, Emitter } from '../../src/util/vs/base/common/event';
import { Lazy } from '../../src/util/vs/base/common/lazy';
import { Disposable, DisposableStore, IReference } from '../../src/util/vs/base/common/lifecycle';
import { DisposableStore, IReference } from '../../src/util/vs/base/common/lifecycle';
import { URI } from '../../src/util/vs/base/common/uri';
import { SyncDescriptor } from '../../src/util/vs/platform/instantiation/common/descriptors';
import { IInstantiationService } from '../../src/util/vs/platform/instantiation/common/instantiation';
@@ -71,37 +69,6 @@ class TestCopilotCLIToolsService extends TestToolsService {
return super.invokeTool(name, options, token);
}
}
export class MockChatPromptFileService extends Disposable implements IChatPromptFileService {
declare _serviceBrand: undefined;
customAgents: ChatResource[] = [];
instructions: ChatResource[] = [];
skills: ChatResource[] = [];
readonly hooks: readonly ChatResource[] = [];
readonly plugins: readonly ChatResource[] = [];
private readonly _onDidChangeCustomAgents = this._register(new Emitter<void>());
private readonly _onDidChangeInstructions = this._register(new Emitter<void>());
private readonly _onDidChangeSkills = this._register(new Emitter<void>());
readonly onDidChangeHooks = Event.None;
readonly onDidChangePlugins = Event.None;
get onDidChangeCustomAgents() {
return this._onDidChangeCustomAgents.event;
}
get onDidChangeInstructions() {
return this._onDidChangeInstructions.event;
}
get onDidChangeSkills() {
return this._onDidChangeSkills.event;
}
get customAgentPromptFiles() {
return [];
}
constructor() {
super();
}
}
const keys = ['COPILOT_ENABLE_ALT_PROVIDERS', 'COPILOT_AGENT_MODEL', 'GH_TOKEN', 'COPILOT_API_URL', 'GITHUB_COPILOT_API_TOKEN'];
const originalValues: Record<string, string | undefined> = {};
@@ -302,7 +269,6 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl
testingServiceCollection.define(IToolsService, new SyncDescriptor(TestCopilotCLIToolsService, [new Set()]));
testingServiceCollection.define(IUserQuestionHandler, new SyncDescriptor(UserQuestionHandler));
testingServiceCollection.define(IChatDelegationSummaryService, delegatingSummarizerProvider);
testingServiceCollection.define(IChatPromptFileService, new SyncDescriptor(MockChatPromptFileService));
testingServiceCollection.define(IChatSessionMetadataStore, new SyncDescriptor(MockChatSessionMetadataStore));
testingServiceCollection.define(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace);
testingServiceCollection.define(IChatSessionWorkspaceFolderService, {