From 0201d5fdd387bc4d863aacebde40e85bb2bcc4c4 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Tue, 31 Mar 2026 16:58:02 -0700 Subject: [PATCH] sessions: implement chatSessionCustomizationProvider for Claude and Copilot CLI (#4772) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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> --- extensions/copilot/package.json | 20 +- .../claude/common/claudeRuntimeDataService.ts | 31 ++ .../claude/node/claudeCodeAgent.ts | 6 + .../claude/node/claudeRuntimeDataService.ts | 40 ++ .../test/claudeRuntimeDataService.spec.ts | 93 ++++ .../chatSessions/vscode-node/chatSessions.ts | 12 + .../claudeCustomizationProvider.ts | 288 +++++++++++ .../copilotCLICustomizationProvider.ts | 153 ++++++ .../test/claudeCustomizationProvider.spec.ts | 464 ++++++++++++++++++ .../copilotCLICustomizationProvider.spec.ts | 332 +++++++++++++ .../src/extension/test/node/services.ts | 7 +- ...osed.chatSessionCustomizationProvider.d.ts | 168 +++++++ 12 files changed, 1611 insertions(+), 3 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/claude/common/claudeRuntimeDataService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/claude/node/claudeRuntimeDataService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/claude/node/test/claudeRuntimeDataService.spec.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts create mode 100644 extensions/copilot/src/extension/vscode.proposed.chatSessionCustomizationProvider.d.ts diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 6f1823c2f85..dc683c6ffbf 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -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": { diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeRuntimeDataService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeRuntimeDataService.ts new file mode 100644 index 00000000000..f2d76494a76 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeRuntimeDataService.ts @@ -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('claudeRuntimeDataService'); + +export interface IClaudeRuntimeDataService { + readonly _serviceBrand: undefined; + + /** + * Fires when cached runtime data is updated (e.g. after a new session initializes). + */ + readonly onDidChange: Event; + + /** + * 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; +} diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index 58b0d2db29f..7b359f93bfb 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -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(); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeRuntimeDataService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeRuntimeDataService.ts new file mode 100644 index 00000000000..12f9668e37b --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeRuntimeDataService.ts @@ -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()); + 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 { + 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(); + } +} diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeRuntimeDataService.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeRuntimeDataService.spec.ts new file mode 100644 index 00000000000..a3f65f650b2 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeRuntimeDataService.spec.ts @@ -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() { + override trace() { } + override error() { } +} + +function createMockQuery(agents: AgentInfo[]): Pick { + 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'); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 2c32c08802f..c12953ba343 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -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 diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts new file mode 100644 index 00000000000..7212145ee70 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeCustomizationProvider.ts @@ -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>; +} + +export class ClaudeCustomizationProvider extends Disposable implements vscode.ChatSessionCustomizationProvider { + + private readonly _onDidChange = this._register(new Emitter()); + 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 { + 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 { + 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 { + try { + await this.fileSystemService.stat(uri); + return true; + } catch { + return false; + } + } + + private async discoverHooks(): Promise { + 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; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts new file mode 100644 index 00000000000..47261f53fc4 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLICustomizationProvider.ts @@ -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()); + 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 { + const items: vscode.ChatSessionCustomizationItem[] = []; + + // Build a file URI lookup from prompt file agents for cross-referencing + const fileAgentLookup = new Map(); + 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; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts new file mode 100644 index 00000000000..eec52dc0381 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts @@ -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() { + private readonly _onDidChange = new Emitter(); + 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() { + private readonly _onDidChangeCustomAgents = new Emitter(); + override readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; + private readonly _onDidChangeInstructions = new Emitter(); + override readonly onDidChangeInstructions = this._onDidChangeInstructions.event; + private readonly _onDidChangeSkills = new Emitter(); + 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() { + private _folders: URI[] = []; + private readonly _onDidChange = new Emitter(); + override readonly onDidChangeWorkspaceFolders: Event = this._onDidChange.event; + setFolders(folders: URI[]) { this._folders = folders; } + override getWorkspaceFolders(): URI[] { return this._folders; } + fireWorkspaceFoldersChanged() { this._onDidChange.fire(); } +} + +class MockFileSystemService extends mock() { + private readonly _files = new Map(); + 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 { + const content = this._files.get(uri.toString()); + if (!content) { + throw new Error(`File not found: ${uri.toString()}`); + } + return content; + } +} + +class MockEnvService extends mock() { + override userHome = URI.file('/home/user'); +} + +class TestLogService extends mock() { + 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).ChatSessionCustomizationType; + (vscode as Record).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).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); + }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts new file mode 100644 index 00000000000..c04df017975 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLICustomizationProvider.spec.ts @@ -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 { + return { + name, + displayName: displayName ?? name, + description, + tools: null, + prompt: () => Promise.resolve(''), + disableModelInvocation: false, + }; +} + +class MockChatPromptFileService extends mock() { + private readonly _onDidChangeCustomAgents = new Emitter(); + override readonly onDidChangeCustomAgents = this._onDidChangeCustomAgents.event; + private readonly _onDidChangeInstructions = new Emitter(); + override readonly onDidChangeInstructions = this._onDidChangeInstructions.event; + private readonly _onDidChangeSkills = new Emitter(); + 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() { + private readonly _onDidChangeAgents = new Emitter(); + override readonly onDidChangeAgents = this._onDidChangeAgents.event; + private _agents: Readonly[] = []; + + setAgents(agents: Readonly[]) { this._agents = agents; } + override async getAgents(): Promise[]> { return this._agents; } + fireAgentsChanged() { this._onDidChangeAgents.fire(); } + dispose() { this._onDidChangeAgents.dispose(); } +} + +class MockWorkspaceService extends mock() { + private _folders: URI[] = []; + setFolders(folders: URI[]) { this._folders = folders; } + override getWorkspaceFolders(): URI[] { return this._folders; } +} + +class MockEnvService extends mock() { + override userHome = URI.file('/home/user'); +} + +class TestLogService extends mock() { + 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).ChatSessionCustomizationType; + (vscode as Record).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).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); + }); + }); +}); diff --git a/extensions/copilot/src/extension/test/node/services.ts b/extensions/copilot/src/extension/test/node/services.ts index 9d598da8f5b..1a08e1ca740 100644 --- a/extensions/copilot/src/extension/test/node/services.ts +++ b/extensions/copilot/src/extension/test/node/services.ts @@ -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