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