sessions: implement chatSessionCustomizationProvider for Claude and Copilot CLI (#4772)

* Add customization providers for Claude and Copilot CLI chat sessions

* Add unit tests for chat session customization providers

Add tests for ClaudeCustomizationProvider and CopilotCLICustomizationProvider
covering metadata, item discovery, and change event forwarding. Refactor
metadata from static readonly to static getter for testability (avoids
class initializer accessing vscode.ChatSessionCustomizationType before
shim setup).

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

* Fix customization provider accuracy: add hook discovery, mark unsupported types

- Claude provider now discovers hooks from .claude/settings.json and
  .claude/settings.local.json (workspace + user home), reporting them
  as ChatSessionCustomizationType.Hook items with event/matcher names
- Copilot CLI provider marks both Hook and Prompt as unsupported since
  it doesn't support hooks and prompt files aren't enumerable via the API
- Claude provider now takes IWorkspaceService, IFileSystemService, and
  INativeEnvService to read settings files for hook discovery
- Added 6 new hook discovery tests covering workspace/user settings,
  multiple matchers, invalid JSON, and missing files

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

* Mark Agent as unsupported for Claude customization provider

Claude doesn't use .agent.md files — it has its own agent system via
CLAUDE.md memory files and the Claude Agent SDK. Remove Agent items
from Claude's provideChatSessionCustomizations output and add Agent
to its unsupportedTypes metadata.

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

* Remove workspaceSubpaths from API, add internal path filtering

Remove the workspaceSubpaths property from ChatSessionCustomizationProviderMetadata
since the extension should filter internally. Each provider now only returns items
under its relevant paths:

- Claude: instructions/skills under .claude/ (workspace folders + user home)
- Copilot CLI: instructions/skills under .github/ or .copilot/ (agents are always
  included since they're Copilot-specific)

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

* Add TODO comments for work in progress on chatSessionCustomizationProvider API

* Sync chatSessionCustomizationProvider d.ts with VS Code origin/main

Add groupKey, badge, and badgeTooltip fields to ChatSessionCustomizationItem
to match upstream changes from #305810 and #305813.

The providers don't set these fields explicitly — the VS Code UI
auto-enriches instruction items by parsing frontmatter when groupKey
is not provided.

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

* sessions: Claude customization provider uses SDK runtime data

- Add IClaudeRuntimeDataService to cache agents from Query.supportedAgents()
- ClaudeCodeSession calls runtimeDataService.update() after query creation
- Rewrite ClaudeCustomizationProvider:
  - Agents: reported from SDK via IClaudeRuntimeDataService (built-in groupKey)
  - Instructions: hard-coded CLAUDE.md paths with existence checks
  - Skills: filtered from IChatPromptFileService under .claude/
  - Hooks: unchanged (from .claude/settings.json)
- unsupportedTypes changed from [Agent, Prompt] to [Prompt] only
- 28 tests (22 provider + 6 service)

* sessions: Copilot CLI customization provider uses runtime agent data

- Inject ICopilotCLIAgents to enrich agents with displayName/description
- Expand path filter: add .agents/ to CLI_SUBPATHS
- Add home directory support: ~/.copilot/, ~/.agents/
- Listen to ICopilotCLIAgents.onDidChangeAgents for change events
- 21 tests covering new paths, agent enrichment, and events

* fix: register IClaudeRuntimeDataService in test services

The existing claudeCodeAgent tests were failing because ClaudeCodeSession
now depends on IClaudeRuntimeDataService, but it was not registered in
createExtensionUnitTestingServices().

* sessions: implement correct chatSessionCustomizationProvider for Claude and Copilot CLI

Claude provider:
- New IClaudeRuntimeDataService to cache SDK Query agents
- Hybrid agent approach: file-based .claude/ agents pre-session, SDK agents post-session
- Instructions from hard-coded CLAUDE.md paths (stat-checked)
- Skills from .claude/skills/ via IChatPromptFileService
- Hooks from .claude/settings.json (unchanged)
- Per-category debug logging with names

Copilot CLI provider:
- ICopilotCLIAgents as primary agent source (SDK + prompt files)
- Path filter expanded to .github/, .copilot/, .agents/
- Home directory support (~/.copilot/, ~/.agents/)
- Agent enrichment with displayName/description from SDK

Menu contributions:
- chat/customizations/create for Claude agents, hooks, instructions sections

Code review fixes:
- Use stat() instead of readFile() for existence checks
- Fire-and-forget runtimeDataService.update() to avoid blocking session startup
- Restore vscode.ChatSessionCustomizationType in test afterEach
- Type-safe makeSweAgent helper (no as any)

* claude: mark plugins as unsupported type

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Josh Spicer
2026-03-31 23:58:02 +00:00
committed by GitHub
co-authored by Copilot
parent 1871c35dac
commit 0201d5fdd3
12 changed files with 1611 additions and 3 deletions
+19 -1
View File
@@ -146,7 +146,8 @@
"workspaceTrust",
"environmentPower",
"terminalTitle",
"toolInvocationApproveCombination"
"toolInvocationApproveCombination",
"chatSessionCustomizationProvider"
],
"contributes": {
"languageModelTools": [
@@ -5603,6 +5604,23 @@
"when": "chatSessionType == copilotcli && isSessionsWindow && sessions.hasGitRepository && sessions.changesVersionMode == branchChanges",
"group": "navigation@1"
}
],
"chat/customizations/create": [
{
"command": "copilot.claude.agents",
"when": "aiCustomizationManagementHarness == claude-code && aiCustomizationManagementSection == agents",
"group": "navigation@1"
},
{
"command": "copilot.claude.hooks",
"when": "aiCustomizationManagementHarness == claude-code && aiCustomizationManagementSection == hooks",
"group": "navigation@1"
},
{
"command": "copilot.claude.memory",
"when": "aiCustomizationManagementHarness == claude-code && aiCustomizationManagementSection == instructions",
"group": "navigation@1"
}
]
},
"icons": {
@@ -0,0 +1,31 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { AgentInfo, Query } from '@anthropic-ai/claude-agent-sdk';
import { Event } from '../../../../util/vs/base/common/event';
import { createDecorator } from '../../../../util/vs/platform/instantiation/common/instantiation';
export const IClaudeRuntimeDataService = createDecorator<IClaudeRuntimeDataService>('claudeRuntimeDataService');
export interface IClaudeRuntimeDataService {
readonly _serviceBrand: undefined;
/**
* Fires when cached runtime data is updated (e.g. after a new session initializes).
*/
readonly onDidChange: Event<void>;
/**
* Returns the cached list of agents from the most recent Claude session.
* Returns an empty array if no session has been initialized yet.
*/
getAgents(): readonly AgentInfo[];
/**
* Updates the cached runtime data by querying the given SDK Query instance.
* Called by ClaudeCodeSession after a new Query is created.
*/
update(query: Query): Promise<void>;
}
@@ -25,6 +25,7 @@ import { ExternalEditTracker } from '../../common/externalEditTracker';
import { buildHooksFromRegistry } from '../common/claudeHookRegistry';
import { buildMcpServersFromRegistry } from '../common/claudeMcpServerRegistry';
import { dispatchMessage, KnownClaudeError } from '../common/claudeMessageDispatch';
import { IClaudeRuntimeDataService } from '../common/claudeRuntimeDataService';
import { ClaudeSessionUri } from '../common/claudeSessionUri';
import { IClaudeToolPermissionService } from '../common/claudeToolPermissionService';
import { claudeEditTools, getAffectedUrisForEditTool } from '../common/claudeTools';
@@ -211,6 +212,7 @@ export class ClaudeCodeSession extends Disposable {
@IClaudeCodeSdkService private readonly claudeCodeService: IClaudeCodeSdkService,
@IClaudeToolPermissionService private readonly toolPermissionService: IClaudeToolPermissionService,
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
@IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService,
@IMcpService private readonly mcpService: IMcpService,
@IOTelService private readonly _otelService: IOTelService,
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
@@ -478,6 +480,10 @@ export class ClaudeCodeSession extends Disposable {
options
});
// Cache runtime data (agents, etc.) for the customization provider.
// Fire-and-forget to avoid blocking session startup — error handling is inside the service.
void this.runtimeDataService.update(this._queryGenerator);
// Take a snapshot of settings files so we can detect changes
await this._settingsChangeTracker.takeSnapshot();
@@ -0,0 +1,40 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { AgentInfo, Query } from '@anthropic-ai/claude-agent-sdk';
import { ILogService } from '../../../../platform/log/common/logService';
import { Emitter } from '../../../../util/vs/base/common/event';
import { Disposable } from '../../../../util/vs/base/common/lifecycle';
import { IClaudeRuntimeDataService } from '../common/claudeRuntimeDataService';
export class ClaudeRuntimeDataService extends Disposable implements IClaudeRuntimeDataService {
declare readonly _serviceBrand: undefined;
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;
private _agents: readonly AgentInfo[] = [];
constructor(
@ILogService private readonly logService: ILogService,
) {
super();
}
getAgents(): readonly AgentInfo[] {
return this._agents;
}
async update(query: Query): Promise<void> {
try {
this._agents = await query.supportedAgents();
this.logService.trace(`[ClaudeRuntimeDataService] Cached ${this._agents.length} agents`);
} catch (err) {
this.logService.error('[ClaudeRuntimeDataService] Failed to query agents from SDK', err);
// Keep previous cache (or empty) on error
}
this._onDidChange.fire();
}
}
@@ -0,0 +1,93 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { AgentInfo, Query } from '@anthropic-ai/claude-agent-sdk';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ILogService } from '../../../../../platform/log/common/logService';
import { mock } from '../../../../../util/common/test/simpleMock';
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
import { ClaudeRuntimeDataService } from '../claudeRuntimeDataService';
class TestLogService extends mock<ILogService>() {
override trace() { }
override error() { }
}
function createMockQuery(agents: AgentInfo[]): Pick<Query, 'supportedAgents'> {
return {
supportedAgents: vi.fn().mockResolvedValue(agents),
};
}
describe('ClaudeRuntimeDataService', () => {
let disposables: DisposableStore;
let service: ClaudeRuntimeDataService;
beforeEach(() => {
disposables = new DisposableStore();
service = disposables.add(new ClaudeRuntimeDataService(new TestLogService()));
});
afterEach(() => {
disposables.dispose();
});
it('returns empty agents before first update', () => {
expect(service.getAgents()).toEqual([]);
});
it('caches agents after update', async () => {
const agents: AgentInfo[] = [
{ name: 'Explore', description: 'Fast exploration' },
{ name: 'Review', description: 'Code review', model: 'claude-3.5-sonnet' },
];
await service.update(createMockQuery(agents) as Query);
expect(service.getAgents()).toEqual(agents);
});
it('fires onDidChange after update', async () => {
let fired = false;
disposables.add(service.onDidChange(() => { fired = true; }));
await service.update(createMockQuery([]) as Query);
expect(fired).toBe(true);
});
it('fires onDidChange even when supportedAgents fails', async () => {
let fired = false;
disposables.add(service.onDidChange(() => { fired = true; }));
const query = {
supportedAgents: vi.fn().mockRejectedValue(new Error('SDK error')),
};
await service.update(query as unknown as Query);
expect(fired).toBe(true);
// Previous cache should be preserved (empty in this case)
expect(service.getAgents()).toEqual([]);
});
it('preserves previous cache on error', async () => {
const agents: AgentInfo[] = [{ name: 'Explore', description: 'Agent' }];
await service.update(createMockQuery(agents) as Query);
const failingQuery = {
supportedAgents: vi.fn().mockRejectedValue(new Error('fail')),
};
await service.update(failingQuery as unknown as Query);
expect(service.getAgents()).toEqual(agents);
});
it('overwrites cache on subsequent updates', async () => {
await service.update(createMockQuery([{ name: 'A', description: 'First' }]) as Query);
expect(service.getAgents()).toHaveLength(1);
await service.update(createMockQuery([{ name: 'B', description: 'Second' }, { name: 'C', description: 'Third' }]) as Query);
expect(service.getAgents()).toHaveLength(2);
expect(service.getAgents()[0].name).toBe('B');
});
});
@@ -32,6 +32,8 @@ import { ClaudeCodeSessionService, IClaudeCodeSessionService } from '../claude/n
import { ClaudeSlashCommandService, IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
import { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService';
import { ClaudeRuntimeDataService } from '../claude/node/claudeRuntimeDataService';
import { IChatPromptFileService } from '../common/chatPromptFileService';
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
@@ -60,8 +62,10 @@ import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderS
import { ChatSessionWorktreeCheckpointService } from './chatSessionWorktreeCheckpointServiceImpl';
import { ChatSessionWorktreeService } from './chatSessionWorktreeServiceImpl';
import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProvider';
import { ClaudeCustomizationProvider } from './claudeCustomizationProvider';
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessions';
import { CopilotCLIChatSessionContentProvider as CopilotCLIChatSessionContentProviderV1, CopilotCLIChatSessionItemProvider as CopilotCLIChatSessionItemProviderV1, CopilotCLIChatSessionParticipant as CopilotCLIChatSessionParticipantV1, registerCLIChatCommands as registerCLIChatCommandsV1 } from './copilotCLIChatSessionsContribution';
import { CopilotCLICustomizationProvider } from './copilotCLICustomizationProvider';
import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from './folderRepositoryManagerImpl';
@@ -128,6 +132,8 @@ 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));
const claudeModels = claudeAgentInstaService.invokeFunction(accessor => accessor.get(IClaudeCodeModels));
@@ -136,6 +142,8 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const chatParticipant = vscode.chat.createChatParticipant(ClaudeSessionUri.scheme, chatSessionContentProvider.createHandler());
chatParticipant.iconPath = new vscode.ThemeIcon('claude');
this._register(vscode.chat.registerChatSessionContentProvider(ClaudeSessionUri.scheme, chatSessionContentProvider, chatParticipant));
const claudeCustomizationProvider = this._register(claudeAgentInstaService.createInstance(ClaudeCustomizationProvider));
this._register(vscode.chat.registerChatSessionCustomizationProvider(ClaudeSessionUri.scheme, ClaudeCustomizationProvider.metadata, claudeCustomizationProvider));
// #endregion
@@ -198,6 +206,8 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler());
this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant));
const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider));
this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider));
this._register(registerCLIChatCommands(copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, nativeEnvService, fileSystemService, sessionTracker, terminalIntegration, logService));
// #endregion
@@ -263,6 +273,8 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const copilotcliParticipant = vscode.chat.createChatParticipant(this.copilotcliSessionType, copilotcliChatSessionParticipant.createHandler());
this._register(vscode.chat.registerChatSessionContentProvider(this.copilotcliSessionType, copilotcliChatSessionContentProvider, copilotcliParticipant));
const copilotcliCustomizationProvider = this._register(copilotcliAgentInstaService.createInstance(CopilotCLICustomizationProvider));
this._register(vscode.chat.registerChatSessionCustomizationProvider(this.copilotcliSessionType, CopilotCLICustomizationProvider.metadata, copilotcliCustomizationProvider));
this._register(registerCLIChatCommandsV1(copilotcliSessionItemProvider, copilotCLISessionService, copilotCLIWorktreeManagerService, gitService, gitExtensionService, toolsService, copilotCLIWorkspaceFolderSessions, copilotcliChatSessionContentProvider, folderRepositoryManager, nativeEnvService, fileSystemService, logService));
// #endregion
@@ -0,0 +1,288 @@
/*---------------------------------------------------------------------------------------------
* 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 { 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';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
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 { IClaudeRuntimeDataService } from '../claude/common/claudeRuntimeDataService';
import { ClaudeSessionUri } from '../claude/common/claudeSessionUri';
import { IChatPromptFileService } from '../common/chatPromptFileService';
// TODO: Consider reporting Claude slash commands (from Query.supportedCommands()) when appropriate
// TODO: Report MCP servers when ChatSessionCustomizationType.Mcp is available (use Query.mcpServerStatus())
/**
* Hard-coded CLAUDE.md instruction file names that Claude recognizes.
* Per workspace folder: CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md, .claude/CLAUDE.local.md
* User home: ~/.claude/CLAUDE.md
*/
const WORKSPACE_INSTRUCTION_PATHS = [
'CLAUDE.md',
'CLAUDE.local.md',
['.claude', 'CLAUDE.md'] as const,
['.claude', 'CLAUDE.local.md'] as const,
] as const;
const HOME_INSTRUCTION_PATHS = [
['.claude', 'CLAUDE.md'] as const,
] as const;
/**
* Hook event IDs that Claude supports, matching the HookEvent types from
* the Claude Agent SDK. Used to discover hooks from .claude/settings.json.
*/
const HOOK_EVENT_IDS = [
'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'PermissionRequest',
'UserPromptSubmit', 'Stop', 'SubagentStart', 'SubagentStop',
'PreCompact', 'SessionStart', 'SessionEnd', 'Notification',
] as const;
interface HookConfig {
readonly type: string;
readonly command: string;
}
interface MatcherConfig {
readonly matcher: string;
readonly hooks: HookConfig[];
}
interface HooksSettings {
readonly hooks?: Partial<Record<string, MatcherConfig[]>>;
}
export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider {
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;
static get metadata(): vscode.ChatSessionCustomizationProviderMetadata {
return {
label: 'Claude',
iconId: 'claude',
unsupportedTypes: [
vscode.ChatSessionCustomizationType.Prompt,
new vscode.ChatSessionCustomizationType('plugins'),
],
};
}
constructor(
@IChatPromptFileService private readonly chatPromptFileService: IChatPromptFileService,
@IClaudeRuntimeDataService private readonly runtimeDataService: IClaudeRuntimeDataService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IFileSystemService private readonly fileSystemService: IFileSystemService,
@INativeEnvService private readonly envService: INativeEnvService,
@ILogService private readonly logService: ILogService,
) {
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.workspaceService.onDidChangeWorkspaceFolders(() => this._onDidChange.fire()));
}
async provideChatSessionCustomizations(_token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
const items: vscode.ChatSessionCustomizationItem[] = [];
// Agents: hybrid approach — file-based .claude/ agents merged with SDK-provided agents.
// File-based agents are available immediately; SDK agents appear once a session starts.
const sdkAgents = this.runtimeDataService.getAgents();
const sdkAgentNames = new Set(sdkAgents.map(a => a.name.toLowerCase()));
// SDK agents (built-in subagents like "Explore") — preferred when available
for (const agent of sdkAgents) {
items.push({
uri: URI.from({ scheme: ClaudeSessionUri.scheme, path: `/agents/${agent.name}` }),
type: vscode.ChatSessionCustomizationType.Agent,
name: agent.name,
description: agent.description,
groupKey: 'Built-in',
});
}
// File-based agents from .claude/ paths — shown pre-session, deduplicated with SDK
for (const agent of this.chatPromptFileService.customAgents) {
if (this.isClaudePath(agent.uri)) {
const name = deriveNameFromUri(agent.uri, AGENT_FILE_EXTENSION);
if (!sdkAgentNames.has(name.toLowerCase())) {
items.push({
uri: agent.uri,
type: vscode.ChatSessionCustomizationType.Agent,
name,
});
}
}
}
const agentItems = items.filter(i => i.type === vscode.ChatSessionCustomizationType.Agent);
this.logService.debug(`[ClaudeCustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}${sdkAgents.length ? ' [sdk]' : ' [files-only, no session]'}`);
// Instructions from hard-coded CLAUDE.md paths (checked for existence)
const instructionItems = await this.discoverInstructions();
items.push(...instructionItems);
this.logService.debug(`[ClaudeCustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`);
// Skills from .claude/skills/ directories (user-defined SKILL.md files)
const skillItems: vscode.ChatSessionCustomizationItem[] = [];
for (const skill of this.chatPromptFileService.skills) {
if (this.isClaudePath(skill.uri)) {
const item: vscode.ChatSessionCustomizationItem = {
uri: skill.uri,
type: vscode.ChatSessionCustomizationType.Skill,
name: deriveNameFromUri(skill.uri, SKILL_FILENAME),
};
skillItems.push(item);
}
}
items.push(...skillItems);
this.logService.debug(`[ClaudeCustomizationProvider] skills (${skillItems.length}): ${skillItems.map(s => s.name).join(', ') || '(none)'}`);
// Hooks from .claude/settings.json files
const hookItems = await this.discoverHooks();
items.push(...hookItems);
this.logService.debug(`[ClaudeCustomizationProvider] hooks (${hookItems.length}): ${hookItems.map(h => h.name).join(', ') || '(none)'}`);
this.logService.debug(`[ClaudeCustomizationProvider] total: ${items.length} items`);
return items;
}
private async discoverInstructions(): Promise<vscode.ChatSessionCustomizationItem[]> {
const items: vscode.ChatSessionCustomizationItem[] = [];
const candidates: URI[] = [];
for (const folder of this.workspaceService.getWorkspaceFolders()) {
for (const entry of WORKSPACE_INSTRUCTION_PATHS) {
if (typeof entry === 'string') {
candidates.push(URI.joinPath(folder, entry));
} else {
candidates.push(URI.joinPath(folder, ...entry));
}
}
}
for (const entry of HOME_INSTRUCTION_PATHS) {
candidates.push(URI.joinPath(this.envService.userHome, ...entry));
}
for (const uri of candidates) {
if (await this.fileExists(uri)) {
const name = basename(uri).replace(/\.md$/i, '');
items.push({
uri,
type: vscode.ChatSessionCustomizationType.Instructions,
name,
});
}
}
return items;
}
private async fileExists(uri: URI): Promise<boolean> {
try {
await this.fileSystemService.stat(uri);
return true;
} catch {
return false;
}
}
private async discoverHooks(): Promise<vscode.ChatSessionCustomizationItem[]> {
const items: vscode.ChatSessionCustomizationItem[] = [];
const settingsPaths = this.getSettingsFilePaths();
for (const settingsUri of settingsPaths) {
try {
const content = await this.fileSystemService.readFile(settingsUri);
const settings: HooksSettings = JSON.parse(new TextDecoder().decode(content));
if (!settings.hooks) {
continue;
}
for (const eventId of HOOK_EVENT_IDS) {
const matchers = settings.hooks[eventId];
if (!matchers || matchers.length === 0) {
continue;
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
const matcherLabel = matcher.matcher === '*' ? '' : ` (${matcher.matcher})`;
items.push({
uri: settingsUri,
type: vscode.ChatSessionCustomizationType.Hook,
name: `${eventId}${matcherLabel}`,
description: hook.command,
});
}
}
}
} catch {
// Settings file doesn't exist or is invalid — skip
}
}
return items;
}
private getSettingsFilePaths(): URI[] {
const paths: URI[] = [];
for (const folder of this.workspaceService.getWorkspaceFolders()) {
paths.push(URI.joinPath(folder, '.claude', 'settings.json'));
paths.push(URI.joinPath(folder, '.claude', 'settings.local.json'));
}
paths.push(URI.joinPath(this.envService.userHome, '.claude', 'settings.json'));
return paths;
}
private isClaudePath(uri: URI): boolean {
const folders = this.workspaceService.getWorkspaceFolders();
for (const folder of folders) {
const folderPath = folder.path.endsWith('/') ? folder.path : folder.path + '/';
if (uri.path.startsWith(folderPath)) {
const relative = uri.path.slice(folderPath.length);
if (relative.startsWith('.claude/')) {
return true;
}
}
}
// Also check user home .claude/ directory
const homePath = this.envService.userHome.path;
const homePrefix = homePath.endsWith('/') ? homePath : homePath + '/';
if (uri.path.startsWith(homePrefix)) {
const relative = uri.path.slice(homePrefix.length);
if (relative.startsWith('.claude/')) {
return true;
}
}
return false;
}
}
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;
}
@@ -0,0 +1,153 @@
/*---------------------------------------------------------------------------------------------
* 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 { AGENT_FILE_EXTENSION, INSTRUCTION_FILE_EXTENSION, SKILL_FILENAME } from '../../../platform/customInstructions/common/promptTypes';
import { INativeEnvService } from '../../../platform/env/common/envService';
import { ILogService } from '../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
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';
/**
* Workspace-relative path prefixes that are relevant to Copilot CLI.
* Matches the copilot-agent-runtime discovery paths for skills, instructions, and agents.
*/
const CLI_SUBPATHS = ['.github/', '.copilot/', '.agents/'];
/**
* Home-directory relative path prefixes for Copilot CLI customizations.
* Matches the copilot-agent-runtime personal skill/instruction directories.
*/
const CLI_HOME_SUBPATHS = ['.copilot/', '.agents/'];
export class CopilotCLICustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider {
private readonly _onDidChange = this._register(new Emitter<void>());
readonly onDidChange = this._onDidChange.event;
static get metadata(): vscode.ChatSessionCustomizationProviderMetadata {
return {
label: 'Copilot CLI',
iconId: 'worktree',
unsupportedTypes: [vscode.ChatSessionCustomizationType.Hook, vscode.ChatSessionCustomizationType.Prompt],
};
}
constructor(
@IChatPromptFileService private readonly chatPromptFileService: IChatPromptFileService,
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@INativeEnvService private readonly envService: INativeEnvService,
@ILogService private readonly logService: ILogService,
) {
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.copilotCLIAgents.onDidChangeAgents(() => this._onDidChange.fire()));
}
async provideChatSessionCustomizations(_token: vscode.CancellationToken): Promise<vscode.ChatSessionCustomizationItem[]> {
const items: vscode.ChatSessionCustomizationItem[] = [];
// Build a file URI lookup from prompt file agents for cross-referencing
const fileAgentLookup = new Map<string, URI>();
for (const agent of this.chatPromptFileService.customAgents) {
const name = deriveNameFromUri(agent.uri, AGENT_FILE_EXTENSION);
fileAgentLookup.set(name.toLowerCase(), agent.uri);
}
// Agents: use ICopilotCLIAgents as the primary source (includes SDK + prompt file agents).
// Cross-reference with chatPromptFileService.customAgents for file URIs when available.
const cliAgents = await this.copilotCLIAgents.getAgents();
const agentItems: vscode.ChatSessionCustomizationItem[] = [];
for (const agent of cliAgents) {
const fileUri = fileAgentLookup.get(agent.name.toLowerCase());
agentItems.push({
uri: fileUri ?? URI.from({ scheme: 'copilotcli', path: `/agents/${agent.name}` }),
type: vscode.ChatSessionCustomizationType.Agent,
name: agent.displayName || agent.name,
description: agent.description,
groupKey: fileUri ? undefined : 'Built-in',
});
}
items.push(...agentItems);
this.logService.debug(`[CopilotCLICustomizationProvider] agents (${agentItems.length}): ${agentItems.map(a => a.name).join(', ') || '(none)'}`);
const instructionItems: vscode.ChatSessionCustomizationItem[] = [];
for (const instruction of this.chatPromptFileService.instructions) {
if (this.isCLIPath(instruction.uri)) {
instructionItems.push({
uri: instruction.uri,
type: vscode.ChatSessionCustomizationType.Instructions,
name: deriveNameFromUri(instruction.uri, INSTRUCTION_FILE_EXTENSION),
});
}
}
items.push(...instructionItems);
this.logService.debug(`[CopilotCLICustomizationProvider] instructions (${instructionItems.length}): ${instructionItems.map(i => i.name).join(', ') || '(none)'}`);
const skillItems: vscode.ChatSessionCustomizationItem[] = [];
for (const skill of this.chatPromptFileService.skills) {
if (this.isCLIPath(skill.uri)) {
skillItems.push({
uri: skill.uri,
type: vscode.ChatSessionCustomizationType.Skill,
name: deriveNameFromUri(skill.uri, SKILL_FILENAME),
});
}
}
items.push(...skillItems);
this.logService.debug(`[CopilotCLICustomizationProvider] skills (${skillItems.length}): ${skillItems.map(s => s.name).join(', ') || '(none)'}`);
this.logService.debug(`[CopilotCLICustomizationProvider] total: ${items.length} items`);
return items;
}
private isCLIPath(uri: URI): boolean {
// Check workspace folder paths
const folders = this.workspaceService.getWorkspaceFolders();
for (const folder of folders) {
const folderPath = folder.path.endsWith('/') ? folder.path : folder.path + '/';
if (uri.path.startsWith(folderPath)) {
const relative = uri.path.slice(folderPath.length);
if (CLI_SUBPATHS.some(prefix => relative.startsWith(prefix))) {
return true;
}
}
}
// Check home directory paths (e.g., ~/.copilot/skills/, ~/.agents/skills/)
const homePath = this.envService.userHome.path;
const homePrefix = homePath.endsWith('/') ? homePath : homePath + '/';
if (uri.path.startsWith(homePrefix)) {
const relative = uri.path.slice(homePrefix.length);
if (CLI_HOME_SUBPATHS.some(prefix => relative.startsWith(prefix))) {
return true;
}
}
return false;
}
}
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;
}
@@ -0,0 +1,464 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { AgentInfo } from '@anthropic-ai/claude-agent-sdk';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as vscode from 'vscode';
import { INativeEnvService } from '../../../../platform/env/common/envService';
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { mock } from '../../../../util/common/test/simpleMock';
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';
class FakeChatSessionCustomizationType {
static readonly Agent = new FakeChatSessionCustomizationType('agent');
static readonly Skill = new FakeChatSessionCustomizationType('skill');
static readonly Instructions = new FakeChatSessionCustomizationType('instructions');
static readonly Prompt = new FakeChatSessionCustomizationType('prompt');
static readonly Hook = new FakeChatSessionCustomizationType('hook');
constructor(readonly id: string) { }
}
class MockRuntimeDataService extends mock<IClaudeRuntimeDataService>() {
private readonly _onDidChange = new Emitter<void>();
override readonly onDidChange = this._onDidChange.event;
private _agents: AgentInfo[] = [];
setAgents(agents: AgentInfo[]) { this._agents = agents; }
override getAgents(): readonly AgentInfo[] { return this._agents; }
fireChanged() { this._onDidChange.fire(); }
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>();
override readonly onDidChangeWorkspaceFolders: Event<any> = this._onDidChange.event;
setFolders(folders: URI[]) { this._folders = folders; }
override getWorkspaceFolders(): URI[] { return this._folders; }
fireWorkspaceFoldersChanged() { this._onDidChange.fire(); }
}
class MockFileSystemService extends mock<IFileSystemService>() {
private readonly _files = new Map<string, Uint8Array>();
setFile(uri: URI, content: string) {
this._files.set(uri.toString(), new TextEncoder().encode(content));
}
override async stat(uri: URI): Promise<{ type: number; ctime: number; mtime: number; size: number }> {
if (!this._files.has(uri.toString())) {
throw new Error(`File not found: ${uri.toString()}`);
}
return { type: 1 /* File */, ctime: 0, mtime: 0, size: this._files.get(uri.toString())!.length };
}
override async readFile(uri: URI): Promise<Uint8Array> {
const content = this._files.get(uri.toString());
if (!content) {
throw new Error(`File not found: ${uri.toString()}`);
}
return content;
}
}
class MockEnvService extends mock<INativeEnvService>() {
override userHome = URI.file('/home/user');
}
class TestLogService extends mock<ILogService>() {
override trace() { }
override debug() { }
}
describe('ClaudeCustomizationProvider', () => {
let disposables: DisposableStore;
let mockRuntimeDataService: MockRuntimeDataService;
let mockPromptFileService: MockChatPromptFileService;
let mockWorkspaceService: MockWorkspaceService;
let mockFileSystemService: MockFileSystemService;
let provider: ClaudeCustomizationProvider;
let originalChatSessionCustomizationType: unknown;
beforeEach(() => {
originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;
(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;
disposables = new DisposableStore();
mockRuntimeDataService = disposables.add(new MockRuntimeDataService());
mockPromptFileService = disposables.add(new MockChatPromptFileService());
mockWorkspaceService = new MockWorkspaceService();
mockFileSystemService = new MockFileSystemService();
provider = disposables.add(new ClaudeCustomizationProvider(
mockPromptFileService,
mockRuntimeDataService,
mockWorkspaceService,
mockFileSystemService,
new MockEnvService(),
new TestLogService(),
));
});
afterEach(() => {
disposables.dispose();
(vscode as Record<string, unknown>).ChatSessionCustomizationType = originalChatSessionCustomizationType;
});
describe('metadata', () => {
it('has correct label and icon', () => {
expect(ClaudeCustomizationProvider.metadata.label).toBe('Claude');
expect(ClaudeCustomizationProvider.metadata.iconId).toBe('claude');
});
it('marks Prompt and plugins as unsupported', () => {
const unsupported = ClaudeCustomizationProvider.metadata.unsupportedTypes;
expect(unsupported).toBeDefined();
expect(unsupported).toHaveLength(2);
expect(unsupported![0]).toBe(FakeChatSessionCustomizationType.Prompt);
expect(unsupported![1].id).toBe('plugins');
});
});
describe('agents from SDK', () => {
it('returns empty when no session has initialized and no file agents', async () => {
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toEqual([]);
});
it('returns agents from the runtime data service', async () => {
mockRuntimeDataService.setAgents([
{ name: 'Explore', description: 'Fast exploration agent' },
{ name: 'Review', description: 'Code review agent', model: 'claude-3.5-sonnet' },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(2);
expect(agentItems[0].name).toBe('Explore');
expect(agentItems[0].description).toBe('Fast exploration agent');
expect(agentItems[0].groupKey).toBe('Built-in');
expect(agentItems[0].uri.scheme).toBe('claude-code');
expect(agentItems[0].uri.path).toBe('/agents/Explore');
expect(agentItems[1].name).toBe('Review');
});
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') },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(1);
expect(agentItems[0].name).toBe('my-agent');
expect(agentItems[0].uri.scheme).toBe('file');
});
it('deduplicates file agents when SDK provides the same agent', async () => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
mockRuntimeDataService.setAgents([
{ name: 'my-agent', description: 'SDK version' },
]);
mockPromptFileService.setCustomAgents([
{ uri: URI.file('/workspace/.claude/agents/my-agent.agent.md') },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(1);
expect(agentItems[0].description).toBe('SDK version');
expect(agentItems[0].groupKey).toBe('Built-in');
});
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') },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(0);
});
});
describe('instructions from CLAUDE.md paths', () => {
beforeEach(() => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
});
it('discovers CLAUDE.md in workspace root', async () => {
const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md');
mockFileSystemService.setFile(uri, '# Instructions');
const items = await provider.provideChatSessionCustomizations(undefined!);
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
expect(instructionItems).toHaveLength(1);
expect(instructionItems[0].name).toBe('CLAUDE');
expect(instructionItems[0].uri).toEqual(uri);
});
it('discovers CLAUDE.local.md in workspace root', async () => {
const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.local.md');
mockFileSystemService.setFile(uri, '# Local');
const items = await provider.provideChatSessionCustomizations(undefined!);
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
expect(instructionItems).toHaveLength(1);
expect(instructionItems[0].name).toBe('CLAUDE.local');
});
it('discovers .claude/CLAUDE.md in workspace', async () => {
const uri = URI.joinPath(URI.file('/workspace'), '.claude', 'CLAUDE.md');
mockFileSystemService.setFile(uri, '# Claude dir');
const items = await provider.provideChatSessionCustomizations(undefined!);
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
expect(instructionItems).toHaveLength(1);
expect(instructionItems[0].name).toBe('CLAUDE');
});
it('discovers ~/.claude/CLAUDE.md in user home', async () => {
const uri = URI.joinPath(URI.file('/home/user'), '.claude', 'CLAUDE.md');
mockFileSystemService.setFile(uri, '# Home');
const items = await provider.provideChatSessionCustomizations(undefined!);
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
expect(instructionItems).toHaveLength(1);
expect(instructionItems[0].uri).toEqual(uri);
});
it('only reports instruction files that exist', async () => {
// Only set one of the five possible paths
const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md');
mockFileSystemService.setFile(uri, '# Only this one');
const items = await provider.provideChatSessionCustomizations(undefined!);
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
expect(instructionItems).toHaveLength(1);
});
});
describe('skills from .claude/ paths', () => {
beforeEach(() => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
});
it('returns skills under .claude/skills/', async () => {
const uri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
expect(skillItems).toHaveLength(1);
expect(skillItems[0].uri).toBe(uri);
expect(skillItems[0].name).toBe('my-skill');
});
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') },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
expect(skillItems).toHaveLength(0);
});
it('includes skills from user home .claude/ directory', async () => {
const uri = URI.file('/home/user/.claude/skills/global-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
expect(skillItems).toHaveLength(1);
});
});
describe('combined items', () => {
it('returns agents, instructions, skills, and hooks together', async () => {
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') }]);
mockFileSystemService.setFile(
URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'),
JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } })
);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Agent)).toHaveLength(1);
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions)).toHaveLength(1);
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Skill)).toHaveLength(1);
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Hook)).toHaveLength(1);
});
});
describe('hook discovery', () => {
it('discovers hooks from workspace .claude/settings.json', async () => {
const workspaceFolder = URI.file('/workspace');
mockWorkspaceService.setFolders([workspaceFolder]);
const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json');
mockFileSystemService.setFile(settingsUri, JSON.stringify({
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: './scripts/pre-bash.sh' }] }
]
}
}));
const items = await provider.provideChatSessionCustomizations(undefined!);
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
expect(hookItems).toHaveLength(1);
expect(hookItems[0].name).toBe('PreToolUse (Bash)');
expect(hookItems[0].description).toBe('./scripts/pre-bash.sh');
expect(hookItems[0].uri).toEqual(settingsUri);
});
it('uses wildcard label for * matcher', async () => {
const workspaceFolder = URI.file('/workspace');
mockWorkspaceService.setFolders([workspaceFolder]);
mockFileSystemService.setFile(
URI.joinPath(workspaceFolder, '.claude', 'settings.json'),
JSON.stringify({
hooks: {
SessionStart: [
{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }
]
}
})
);
const items = await provider.provideChatSessionCustomizations(undefined!);
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
expect(hookItems).toHaveLength(1);
expect(hookItems[0].name).toBe('SessionStart');
});
it('discovers hooks from user home .claude/settings.json', async () => {
const userSettingsUri = URI.joinPath(URI.file('/home/user'), '.claude', 'settings.json');
mockFileSystemService.setFile(userSettingsUri, JSON.stringify({
hooks: {
PostToolUse: [
{ matcher: 'Edit', hooks: [{ type: 'command', command: './lint.sh' }] }
]
}
}));
const items = await provider.provideChatSessionCustomizations(undefined!);
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
expect(hookItems).toHaveLength(1);
expect(hookItems[0].name).toBe('PostToolUse (Edit)');
});
it('discovers multiple hooks across event types', async () => {
const workspaceFolder = URI.file('/workspace');
mockWorkspaceService.setFolders([workspaceFolder]);
mockFileSystemService.setFile(
URI.joinPath(workspaceFolder, '.claude', 'settings.json'),
JSON.stringify({
hooks: {
PreToolUse: [
{ matcher: 'Bash', hooks: [{ type: 'command', command: './a.sh' }] },
{ matcher: 'Edit', hooks: [{ type: 'command', command: './b.sh' }, { type: 'command', command: './c.sh' }] },
],
SessionStart: [
{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }
]
}
})
);
const items = await provider.provideChatSessionCustomizations(undefined!);
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
expect(hookItems).toHaveLength(4);
});
it('gracefully handles missing settings files', async () => {
mockWorkspaceService.setFolders([URI.file('/workspace')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toEqual([]);
});
it('gracefully handles invalid JSON in settings', async () => {
const workspaceFolder = URI.file('/workspace');
mockWorkspaceService.setFolders([workspaceFolder]);
mockFileSystemService.setFile(
URI.joinPath(workspaceFolder, '.claude', 'settings.json'),
'not valid json {'
);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toEqual([]);
});
});
describe('onDidChange', () => {
it('fires when runtime data changes', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockRuntimeDataService.fireChanged();
expect(fired).toBe(true);
});
it('fires when custom agents change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireCustomAgentsChanged();
expect(fired).toBe(true);
});
it('fires when skills change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireSkillsChanged();
expect(fired).toBe(true);
});
it('fires when workspace folders change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockWorkspaceService.fireWorkspaceFoldersChanged();
expect(fired).toBe(true);
});
});
});
@@ -0,0 +1,332 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { SweCustomAgent } from '@github/copilot/sdk';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import * as vscode from 'vscode';
import { INativeEnvService } from '../../../../platform/env/common/envService';
import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { mock } from '../../../../util/common/test/simpleMock';
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 { ICopilotCLIAgents } from '../../copilotcli/node/copilotCli';
import { CopilotCLICustomizationProvider } from '../copilotCLICustomizationProvider';
class FakeChatSessionCustomizationType {
static readonly Agent = new FakeChatSessionCustomizationType('agent');
static readonly Skill = new FakeChatSessionCustomizationType('skill');
static readonly Instructions = new FakeChatSessionCustomizationType('instructions');
static readonly Prompt = new FakeChatSessionCustomizationType('prompt');
static readonly Hook = new FakeChatSessionCustomizationType('hook');
constructor(readonly id: string) { }
}
function makeSweAgent(name: string, description = '', displayName?: string): Readonly<SweCustomAgent> {
return {
name,
displayName: displayName ?? name,
description,
tools: null,
prompt: () => Promise.resolve(''),
disableModelInvocation: false,
};
}
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 _instructions: vscode.ChatResource[] = [];
private _skills: vscode.ChatResource[] = [];
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; }
setCustomAgents(agents: vscode.ChatResource[]) { this._customAgents = agents; }
setInstructions(instructions: vscode.ChatResource[]) { this._instructions = instructions; }
setSkills(skills: vscode.ChatResource[]) { this._skills = skills; }
fireCustomAgentsChanged() { this._onDidChangeCustomAgents.fire(); }
fireInstructionsChanged() { this._onDidChangeInstructions.fire(); }
fireSkillsChanged() { this._onDidChangeSkills.fire(); }
override dispose() {
this._onDidChangeCustomAgents.dispose();
this._onDidChangeInstructions.dispose();
this._onDidChangeSkills.dispose();
}
}
class MockCopilotCLIAgents extends mock<ICopilotCLIAgents>() {
private readonly _onDidChangeAgents = new Emitter<void>();
override readonly onDidChangeAgents = this._onDidChangeAgents.event;
private _agents: Readonly<SweCustomAgent>[] = [];
setAgents(agents: Readonly<SweCustomAgent>[]) { this._agents = agents; }
override async getAgents(): Promise<Readonly<SweCustomAgent>[]> { return this._agents; }
fireAgentsChanged() { this._onDidChangeAgents.fire(); }
dispose() { this._onDidChangeAgents.dispose(); }
}
class MockWorkspaceService extends mock<IWorkspaceService>() {
private _folders: URI[] = [];
setFolders(folders: URI[]) { this._folders = folders; }
override getWorkspaceFolders(): URI[] { return this._folders; }
}
class MockEnvService extends mock<INativeEnvService>() {
override userHome = URI.file('/home/user');
}
class TestLogService extends mock<ILogService>() {
override trace() { }
override debug() { }
}
const WORKSPACE = URI.file('/workspace');
describe('CopilotCLICustomizationProvider', () => {
let disposables: DisposableStore;
let mockPromptFileService: MockChatPromptFileService;
let mockCopilotCLIAgents: MockCopilotCLIAgents;
let mockWorkspaceService: MockWorkspaceService;
let provider: CopilotCLICustomizationProvider;
let originalChatSessionCustomizationType: unknown;
beforeEach(() => {
originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;
(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;
disposables = new DisposableStore();
mockPromptFileService = disposables.add(new MockChatPromptFileService());
mockCopilotCLIAgents = disposables.add(new MockCopilotCLIAgents());
mockWorkspaceService = new MockWorkspaceService();
mockWorkspaceService.setFolders([WORKSPACE]);
provider = disposables.add(new CopilotCLICustomizationProvider(
mockPromptFileService,
mockCopilotCLIAgents,
mockWorkspaceService,
new MockEnvService(),
new TestLogService(),
));
});
afterEach(() => {
disposables.dispose();
(vscode as Record<string, unknown>).ChatSessionCustomizationType = originalChatSessionCustomizationType;
});
describe('metadata', () => {
it('has correct label and icon', () => {
expect(CopilotCLICustomizationProvider.metadata.label).toBe('Copilot CLI');
expect(CopilotCLICustomizationProvider.metadata.iconId).toBe('worktree');
});
it('marks Hook and Prompt types as unsupported', () => {
const unsupported = CopilotCLICustomizationProvider.metadata.unsupportedTypes;
expect(unsupported).toBeDefined();
expect(unsupported).toHaveLength(2);
expect(unsupported![0]).toBe(FakeChatSessionCustomizationType.Hook);
expect(unsupported![1]).toBe(FakeChatSessionCustomizationType.Prompt);
});
});
describe('provideChatSessionCustomizations', () => {
it('returns empty array when no files exist', async () => {
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toEqual([]);
});
it('returns agents from ICopilotCLIAgents as primary source', async () => {
mockCopilotCLIAgents.setAgents([
makeSweAgent('explore', 'Fast code exploration'),
makeSweAgent('task', 'Multi-step tasks'),
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(2);
expect(agentItems[0].name).toBe('explore');
expect(agentItems[0].description).toBe('Fast code exploration');
});
it('uses file URI when agent has matching .agent.md file', async () => {
const fileUri = URI.file('/workspace/.github/explore.agent.md');
mockPromptFileService.setCustomAgents([{ uri: fileUri }]);
mockCopilotCLIAgents.setAgents([makeSweAgent('explore', 'Explore agent')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(1);
expect(agentItems[0].uri).toEqual(fileUri);
expect(agentItems[0].groupKey).toBeUndefined();
});
it('uses virtual URI for SDK-only agents without .agent.md files', async () => {
mockCopilotCLIAgents.setAgents([makeSweAgent('task', 'Task agent')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
const agentItems = items.filter((i: vscode.ChatSessionCustomizationItem) => i.type === FakeChatSessionCustomizationType.Agent);
expect(agentItems).toHaveLength(1);
expect(agentItems[0].uri.scheme).toBe('copilotcli');
expect(agentItems[0].uri.path).toBe('/agents/task');
expect(agentItems[0].groupKey).toBe('Built-in');
});
it('uses displayName from SDK agents when available', async () => {
mockCopilotCLIAgents.setAgents([makeSweAgent('code-review', 'Reviews code', 'Code Review')]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items[0].name).toBe('Code Review');
});
it('returns instructions under .github/ paths', async () => {
const uri = URI.file('/workspace/.github/copilot-instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].uri).toBe(uri);
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions);
});
it('returns instructions under .copilot/ paths', async () => {
const uri = URI.file('/workspace/.copilot/setup.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].uri).toBe(uri);
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions);
});
it('returns instructions under .agents/ paths', async () => {
const uri = URI.file('/workspace/.agents/setup.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions);
});
it('filters out instructions not under CLI paths', async () => {
mockPromptFileService.setInstructions([
{ uri: URI.file('/workspace/.claude/some.instructions.md') },
{ uri: URI.file('/workspace/root.instructions.md') },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(0);
});
it('returns skills under .github/skills/', async () => {
const uri = URI.file('/workspace/.github/skills/lint-check/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].uri).toBe(uri);
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Skill);
expect(items[0].name).toBe('lint-check');
});
it('returns skills under .copilot/skills/', async () => {
const uri = URI.file('/workspace/.copilot/skills/my-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].name).toBe('my-skill');
});
it('returns skills under .agents/skills/', async () => {
const uri = URI.file('/workspace/.agents/skills/agent-skill/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].name).toBe('agent-skill');
});
it('filters out skills not under CLI paths', async () => {
mockPromptFileService.setSkills([
{ uri: URI.file('/workspace/.claude/skills/claude-skill/SKILL.md') },
]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(0);
});
it('includes instructions from home directory ~/.copilot/', async () => {
const uri = URI.file('/home/user/.copilot/custom.instructions.md');
mockPromptFileService.setInstructions([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Instructions);
});
it('includes skills from home directory ~/.agents/', async () => {
const uri = URI.file('/home/user/.agents/skills/personal/SKILL.md');
mockPromptFileService.setSkills([{ uri }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(1);
expect(items[0].type).toBe(FakeChatSessionCustomizationType.Skill);
});
it('returns all matching types combined', async () => {
mockCopilotCLIAgents.setAgents([makeSweAgent('explore', 'Explore')]);
mockPromptFileService.setInstructions([{ uri: URI.file('/workspace/.github/b.instructions.md') }]);
mockPromptFileService.setSkills([{ uri: URI.file('/workspace/.github/skills/c/SKILL.md') }]);
const items = await provider.provideChatSessionCustomizations(undefined!);
expect(items).toHaveLength(3);
});
});
describe('onDidChange', () => {
it('fires when custom agents change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireCustomAgentsChanged();
expect(fired).toBe(true);
});
it('fires when instructions change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireInstructionsChanged();
expect(fired).toBe(true);
});
it('fires when skills change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockPromptFileService.fireSkillsChanged();
expect(fired).toBe(true);
});
it('fires when ICopilotCLIAgents agents change', () => {
let fired = false;
disposables.add(provider.onDidChange(() => { fired = true; }));
mockCopilotCLIAgents.fireAgentsChanged();
expect(fired).toBe(true);
});
});
});
@@ -31,6 +31,7 @@ import { ILogService } from '../../../platform/log/common/logService';
import { IMcpService, NullMcpService } from '../../../platform/mcp/common/mcpService';
import { EditLogService, IEditLogService } from '../../../platform/multiFileEdit/common/editLogService';
import { IMultiFileEditInternalTelemetryService, MultiFileEditInternalTelemetryService } from '../../../platform/multiFileEdit/common/multiFileEditQualityTelemetry';
import { IToolDeferralService } from '../../../platform/networking/common/toolDeferralService';
import { IChatWebSocketManager, NullChatWebSocketManager } from '../../../platform/networking/node/chatWebSocketManager';
import { IAlternativeNotebookContentService } from '../../../platform/notebook/common/alternativeContent';
import { AlternativeNotebookContentEditGenerator, IAlternativeNotebookContentEditGenerator } from '../../../platform/notebook/common/alternativeContentEditGenerator';
@@ -52,9 +53,11 @@ import { DisposableStore } from '../../../util/vs/base/common/lifecycle';
import { SyncDescriptor } from '../../../util/vs/platform/instantiation/common/descriptors';
import { ILanguageModelServer } from '../../agents/node/langModelServer';
import { MockLanguageModelServer } from '../../agents/node/test/mockLanguageModelServer';
import { IClaudeRuntimeDataService } from '../../chatSessions/claude/common/claudeRuntimeDataService';
import { IClaudeToolPermissionService } from '../../chatSessions/claude/common/claudeToolPermissionService';
import { IClaudeCodeModels } from '../../chatSessions/claude/node/claudeCodeModels';
import { IClaudeCodeSdkService } from '../../chatSessions/claude/node/claudeCodeSdkService';
import { ClaudeRuntimeDataService } from '../../chatSessions/claude/node/claudeRuntimeDataService';
import { ClaudeSessionStateService, IClaudeSessionStateService } from '../../chatSessions/claude/node/claudeSessionStateService';
import { MockClaudeCodeModels } from '../../chatSessions/claude/node/test/mockClaudeCodeModels';
import { MockClaudeCodeSdkService } from '../../chatSessions/claude/node/test/mockClaudeCodeSdkService';
@@ -73,12 +76,11 @@ import { FixCookbookService, IFixCookbookService } from '../../prompts/node/inli
import { AgentMemoryService, IAgentMemoryService } from '../../tools/common/agentMemoryService';
import { EditToolLearningService, IEditToolLearningService } from '../../tools/common/editToolLearningService';
import { IMemoryCleanupService, MemoryCleanupService } from '../../tools/common/memoryCleanupService';
import { ToolDeferralService } from '../../tools/common/toolDeferralService';
import { IToolsService } from '../../tools/common/toolsService';
import { IToolEmbeddingsComputer } from '../../tools/common/virtualTools/toolEmbeddingsComputer';
import { ToolGroupingService } from '../../tools/common/virtualTools/toolGroupingService';
import '../../tools/node/allTools';
import { IToolDeferralService } from '../../../platform/networking/common/toolDeferralService';
import { ToolDeferralService } from '../../tools/common/toolDeferralService';
import { TestToolsService } from '../../tools/node/test/testToolsService';
import { TestToolEmbeddingsComputer } from '../../tools/test/node/virtualTools/testVirtualTools';
import { ISimilarFilesContextService } from '../../xtab/common/similarFilesContextService';
@@ -124,6 +126,7 @@ export function createExtensionUnitTestingServices(disposables: Pick<DisposableS
testingServiceCollection.define(IClaudeToolPermissionService, new SyncDescriptor(MockClaudeToolPermissionService));
testingServiceCollection.define(IClaudeCodeModels, new SyncDescriptor(MockClaudeCodeModels));
testingServiceCollection.define(IClaudeSessionStateService, new SyncDescriptor(ClaudeSessionStateService));
testingServiceCollection.define(IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService));
testingServiceCollection.define(IMcpService, new SyncDescriptor(NullMcpService));
testingServiceCollection.define(IEditLogService, new SyncDescriptor(EditLogService));
testingServiceCollection.define(IProxyModelsService, new SyncDescriptor(NullProxyModelsService));
@@ -0,0 +1,168 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
declare module 'vscode' {
// #region Customization Provider Types
/**
* Identifies the kind of customization an item represents.
*
* Use the built-in static instances (e.g. {@link ChatSessionCustomizationType.Agent})
* for well-known customization types, or create a new instance with a custom
* string id for extension-defined types.
*/
export class ChatSessionCustomizationType {
/** Agent customization (`.agent.md` files). */
static readonly Agent: ChatSessionCustomizationType;
/** Skill customization (`SKILL.md` files). */
static readonly Skill: ChatSessionCustomizationType;
/** Instruction customization (`.instructions.md` files). */
static readonly Instructions: ChatSessionCustomizationType;
/** Prompt customization (`.prompt.md` files). */
static readonly Prompt: ChatSessionCustomizationType;
/** Hook customization (event-driven automation). */
static readonly Hook: ChatSessionCustomizationType;
/**
* The string identifier for this customization type.
*/
readonly id: string;
/**
* Create a new customization type.
*
* @param id A unique string identifier for this type (e.g. `'agent'`, `'skill'`).
*/
constructor(id: string);
}
/**
* Metadata describing a customization provider and its capabilities.
* This drives UI presentation (label, icon) and filtering (unsupported types,
* workspace sub-paths).
*/
export interface ChatSessionCustomizationProviderMetadata {
/**
* Display label for this provider (e.g. "Copilot CLI", "Claude Code").
*/
readonly label: string;
/**
* Optional codicon ID for this provider's icon in the UI.
*/
readonly iconId?: string;
/**
* Customization types that this provider does **not** support.
* The corresponding sections will be hidden in the management UI
* when this provider is active.
*/
readonly unsupportedTypes?: readonly ChatSessionCustomizationType[];
}
/**
* Represents a single customization item reported by a provider.
*/
export interface ChatSessionCustomizationItem {
/**
* URI to the customization file (e.g. an `.agent.md`, `SKILL.md`, or `.instructions.md` file).
*/
readonly uri: Uri;
/**
* The type of customization this item represents.
*/
readonly type: ChatSessionCustomizationType;
/**
* Display name for this customization.
*/
readonly name: string;
/**
* Optional description of this customization.
*/
readonly description?: string;
/**
* Optional group key for display grouping. Items sharing the same
* `groupKey` are placed under a shared collapsible header in the
* management UI.
*
* When omitted, items are grouped automatically by their storage
* source (e.g. Workspace, User) based on the item's URI.
*/
readonly groupKey?: string;
/**
* Optional inline badge text shown next to the item name
* (e.g. a glob pattern like `src/vs/sessions/**`).
*/
readonly badge?: string;
/**
* Optional tooltip text shown when hovering over the badge.
*/
readonly badgeTooltip?: string;
}
/**
* A provider that reports which chat customizations are available.
*
* Chat customizations are configuration artifacts agents, skills,
* instructions, prompts, and hooks that augment LLM behavior during
* a chat session. Extensions that manage their own customization files
* (e.g. from an SDK's config directory) register a provider so the
* management UI can discover and display them.
*
* ### Lifecycle
*
* 1. Register via {@link chat.registerChatSessionCustomizationProvider}.
* 2. The UI calls {@link provideChatSessionCustomizations} once and caches
* the result.
* 3. When the underlying files change, fire {@link onDidChange} to
* trigger a fresh call to {@link provideChatSessionCustomizations}.
*/
export interface ChatSessionCustomizationProvider {
/**
* An optional event that fires when the provider's customizations change.
* The UI caches the result of {@link provideChatSessionCustomizations} and will
* only re-query the provider when this event fires.
*/
readonly onDidChange?: Event<void>;
/**
* Provide the customization items this provider supports.
*
* The result is cached by the UI until {@link onDidChange} fires.
*
* @param token A cancellation token.
* @returns The list of customization items, or `undefined` if unavailable.
*/
provideChatSessionCustomizations(token: CancellationToken): ProviderResult<ChatSessionCustomizationItem[]>;
}
// #endregion
// #region Registration
export namespace chat {
/**
* Register a customization provider that reports what customizations
* a harness or runtime supports. The provider's metadata drives UI
* presentation and filtering, while {@link ChatSessionCustomizationProvider.provideChatSessionCustomizations}
* supplies the actual items.
*
* @param chatSessionType The session type this provider is for (e.g. `'cli'`, `'claude'`).
* @param metadata Metadata describing the provider's capabilities and UI presentation.
* @param provider The customization provider implementation.
* @returns A disposable that unregisters the provider when disposed.
*/
export function registerChatSessionCustomizationProvider(chatSessionType: string, metadata: ChatSessionCustomizationProviderMetadata, provider: ChatSessionCustomizationProvider): Disposable;
}
// #endregion
}