Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { AgentInfo } from '@anthropic-ai/claude-agent-sdk';6import { afterEach, beforeEach, describe, expect, it } from 'vitest';7import * as vscode from 'vscode';8import { INativeEnvService } from '../../../../platform/env/common/envService';9import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';10import { ILogService } from '../../../../platform/log/common/logService';11import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';12import { mock } from '../../../../util/common/test/simpleMock';13import { Emitter, Event } from '../../../../util/vs/base/common/event';14import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';15import { URI } from '../../../../util/vs/base/common/uri';16import { IClaudeRuntimeDataService } from '../../claude/common/claudeRuntimeDataService';17import { ClaudeCustomizationProvider } from '../claudeCustomizationProvider';18import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';1920function mockAgent(uri: URI, name: string): vscode.ChatCustomAgent {21return { uri, name, source: 'local', userInvocable: true, disableModelInvocation: false, enabled: true } satisfies vscode.ChatCustomAgent;22}2324function mockSkill(uri: URI, name: string): vscode.ChatSkill {25return { uri, name, source: 'local' } satisfies vscode.ChatSkill;26}2728class FakeChatSessionCustomizationType {29static readonly Agent = new FakeChatSessionCustomizationType('agent');30static readonly Skill = new FakeChatSessionCustomizationType('skill');31static readonly Instructions = new FakeChatSessionCustomizationType('instructions');32static readonly Prompt = new FakeChatSessionCustomizationType('prompt');33static readonly Hook = new FakeChatSessionCustomizationType('hook');34constructor(readonly id: string) { }35}3637class MockRuntimeDataService extends mock<IClaudeRuntimeDataService>() {38private readonly _onDidChange = new Emitter<void>();39override readonly onDidChange = this._onDidChange.event;40private _agents: AgentInfo[] = [];4142setAgents(agents: AgentInfo[]) { this._agents = agents; }43override getAgents(): readonly AgentInfo[] { return this._agents; }44fireChanged() { this._onDidChange.fire(); }45dispose() { this._onDidChange.dispose(); }46}4748class MockWorkspaceService extends mock<IWorkspaceService>() {49private _folders: URI[] = [];50private readonly _onDidChange = new Emitter<void>();51override readonly onDidChangeWorkspaceFolders: Event<any> = this._onDidChange.event;52setFolders(folders: URI[]) { this._folders = folders; }53override getWorkspaceFolders(): URI[] { return this._folders; }54fireWorkspaceFoldersChanged() { this._onDidChange.fire(); }55}5657class MockFileSystemService extends mock<IFileSystemService>() {58private readonly _files = new Map<string, Uint8Array>();59setFile(uri: URI, content: string) {60this._files.set(uri.toString(), new TextEncoder().encode(content));61}62override async stat(uri: URI): Promise<{ type: number; ctime: number; mtime: number; size: number }> {63if (!this._files.has(uri.toString())) {64throw new Error(`File not found: ${uri.toString()}`);65}66return { type: 1 /* File */, ctime: 0, mtime: 0, size: this._files.get(uri.toString())!.length };67}68override async readFile(uri: URI): Promise<Uint8Array> {69const content = this._files.get(uri.toString());70if (!content) {71throw new Error(`File not found: ${uri.toString()}`);72}73return content;74}75}7677class MockEnvService extends mock<INativeEnvService>() {78override userHome = URI.file('/home/user');79}8081class TestLogService extends mock<ILogService>() {82override trace() { }83override debug() { }84}8586describe('ClaudeCustomizationProvider', () => {87let disposables: DisposableStore;88let mockRuntimeDataService: MockRuntimeDataService;89let mockPromptsService: MockPromptsService;90let mockWorkspaceService: MockWorkspaceService;91let mockFileSystemService: MockFileSystemService;92let provider: ClaudeCustomizationProvider;9394let originalChatSessionCustomizationType: unknown;9596beforeEach(() => {97originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;98(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;99disposables = new DisposableStore();100mockRuntimeDataService = disposables.add(new MockRuntimeDataService());101mockPromptsService = disposables.add(new MockPromptsService());102mockWorkspaceService = new MockWorkspaceService();103mockFileSystemService = new MockFileSystemService();104provider = disposables.add(new ClaudeCustomizationProvider(105mockPromptsService,106mockRuntimeDataService,107mockWorkspaceService,108mockFileSystemService,109new MockEnvService(),110new TestLogService(),111));112});113114afterEach(() => {115disposables.dispose();116(vscode as Record<string, unknown>).ChatSessionCustomizationType = originalChatSessionCustomizationType;117});118119describe('metadata', () => {120it('has correct label and icon', () => {121expect(ClaudeCustomizationProvider.metadata.label).toBe('Claude');122expect(ClaudeCustomizationProvider.metadata.iconId).toBe('claude');123});124125it('supports Agent, Skill, Instructions, and Hook types', () => {126const supported = ClaudeCustomizationProvider.metadata.supportedTypes;127expect(supported).toBeDefined();128expect(supported).toHaveLength(4);129expect(supported).toContain(FakeChatSessionCustomizationType.Agent);130expect(supported).toContain(FakeChatSessionCustomizationType.Skill);131expect(supported).toContain(FakeChatSessionCustomizationType.Instructions);132expect(supported).toContain(FakeChatSessionCustomizationType.Hook);133});134135it('only returns items whose type is in supportedTypes', async () => {136mockRuntimeDataService.setAgents([137{ name: 'Explore', description: 'Fast exploration agent' },138]);139const items = await provider.provideChatSessionCustomizations(undefined!);140const supported = new Set(ClaudeCustomizationProvider.metadata.supportedTypes!.map(t => t.id));141for (const item of items) {142expect(supported.has(item.type.id), `item "${item.name}" has type "${item.type.id}" which is not in supportedTypes`).toBe(true);143}144});145146it('does not set groupKey for items with synthetic URIs (vscode infers grouping)', async () => {147mockRuntimeDataService.setAgents([148{ name: 'Explore', description: 'Explore agent' },149]);150const items = await provider.provideChatSessionCustomizations(undefined!);151const builtinItems = items.filter(i => i.uri.scheme !== 'file');152for (const item of builtinItems) {153expect(item.groupKey, `item "${item.name}" with scheme "${item.uri.scheme}" should not have groupKey (vscode infers)`).toBeUndefined();154}155});156});157158describe('agents from SDK', () => {159it('returns empty when no session has initialized and no file agents', async () => {160const items = await provider.provideChatSessionCustomizations(undefined!);161expect(items).toEqual([]);162});163164it('returns agents from the runtime data service', async () => {165mockRuntimeDataService.setAgents([166{ name: 'Explore', description: 'Fast exploration agent' },167{ name: 'Review', description: 'Code review agent', model: 'claude-3.5-sonnet' },168]);169170const items = await provider.provideChatSessionCustomizations(undefined!);171const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);172expect(agentItems).toHaveLength(2);173expect(agentItems[0].name).toBe('Explore');174expect(agentItems[0].description).toBe('Fast exploration agent');175expect(agentItems[0].groupKey).toBeUndefined();176expect(agentItems[0].uri.scheme).toBe('claude-code');177expect(agentItems[0].uri.path).toBe('/agents/Explore');178expect(agentItems[1].name).toBe('Review');179});180181it('shows file-based agents from .claude/ paths before session starts', async () => {182mockWorkspaceService.setFolders([URI.file('/workspace')]);183mockPromptsService.setCustomAgents([184mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'),185]);186187const items = await provider.provideChatSessionCustomizations(undefined!);188const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);189expect(agentItems).toHaveLength(1);190expect(agentItems[0].name).toBe('my-agent');191expect(agentItems[0].uri.scheme).toBe('file');192});193194it('deduplicates file agents when SDK provides the same agent', async () => {195mockWorkspaceService.setFolders([URI.file('/workspace')]);196mockRuntimeDataService.setAgents([197{ name: 'my-agent', description: 'SDK version' },198]);199mockPromptsService.setCustomAgents([200mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'),201]);202203const items = await provider.provideChatSessionCustomizations(undefined!);204const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);205expect(agentItems).toHaveLength(1);206expect(agentItems[0].description).toBe('SDK version');207expect(agentItems[0].groupKey).toBeUndefined();208});209210it('filters out file agents not under .claude/', async () => {211mockWorkspaceService.setFolders([URI.file('/workspace')]);212mockPromptsService.setCustomAgents([213mockAgent(URI.file('/workspace/.github/my-agent.agent.md'), 'my-agent'),214mockAgent(URI.file('/workspace/root.agent.md'), 'root-agent'),215]);216217const items = await provider.provideChatSessionCustomizations(undefined!);218const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);219expect(agentItems).toHaveLength(0);220});221});222223describe('instructions from CLAUDE.md paths', () => {224beforeEach(() => {225mockWorkspaceService.setFolders([URI.file('/workspace')]);226});227228it('discovers CLAUDE.md in workspace root', async () => {229const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md');230mockFileSystemService.setFile(uri, '# Instructions');231232const items = await provider.provideChatSessionCustomizations(undefined!);233const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);234expect(instructionItems).toHaveLength(1);235expect(instructionItems[0].name).toBe('CLAUDE');236expect(instructionItems[0].uri).toEqual(uri);237});238239it('discovers CLAUDE.local.md in workspace root', async () => {240const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.local.md');241mockFileSystemService.setFile(uri, '# Local');242243const items = await provider.provideChatSessionCustomizations(undefined!);244const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);245expect(instructionItems).toHaveLength(1);246expect(instructionItems[0].name).toBe('CLAUDE.local');247});248249it('discovers .claude/CLAUDE.md in workspace', async () => {250const uri = URI.joinPath(URI.file('/workspace'), '.claude', 'CLAUDE.md');251mockFileSystemService.setFile(uri, '# Claude dir');252253const items = await provider.provideChatSessionCustomizations(undefined!);254const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);255expect(instructionItems).toHaveLength(1);256expect(instructionItems[0].name).toBe('CLAUDE');257});258259it('discovers ~/.claude/CLAUDE.md in user home', async () => {260const uri = URI.joinPath(URI.file('/home/user'), '.claude', 'CLAUDE.md');261mockFileSystemService.setFile(uri, '# Home');262263const items = await provider.provideChatSessionCustomizations(undefined!);264const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);265expect(instructionItems).toHaveLength(1);266expect(instructionItems[0].uri).toEqual(uri);267});268269it('only reports instruction files that exist', async () => {270// Only set one of the five possible paths271const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md');272mockFileSystemService.setFile(uri, '# Only this one');273274const items = await provider.provideChatSessionCustomizations(undefined!);275const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);276expect(instructionItems).toHaveLength(1);277});278});279280describe('skills from .claude/ paths', () => {281beforeEach(() => {282mockWorkspaceService.setFolders([URI.file('/workspace')]);283});284285it('returns skills under .claude/skills/', async () => {286const uri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md');287mockPromptsService.setSkills([mockSkill(uri, 'my-skill')]);288289const items = await provider.provideChatSessionCustomizations(undefined!);290const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);291expect(skillItems).toHaveLength(1);292expect(skillItems[0].uri).toBe(uri);293expect(skillItems[0].name).toBe('my-skill');294});295296it('filters out skills not under .claude/', async () => {297mockPromptsService.setSkills([298mockSkill(URI.file('/workspace/.github/skills/copilot-skill/SKILL.md'), 'copilot-skill'),299mockSkill(URI.file('/workspace/.copilot/skills/other/SKILL.md'), 'other-skill'),300]);301302const items = await provider.provideChatSessionCustomizations(undefined!);303const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);304expect(skillItems).toHaveLength(0);305});306307it('includes skills from user home .claude/ directory', async () => {308const uri = URI.file('/home/user/.claude/skills/global-skill/SKILL.md');309mockPromptsService.setSkills([mockSkill(uri, 'global-skill')]);310311const items = await provider.provideChatSessionCustomizations(undefined!);312const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);313expect(skillItems).toHaveLength(1);314});315});316317describe('combined items', () => {318it('returns agents, instructions, skills, and hooks together', async () => {319mockWorkspaceService.setFolders([URI.file('/workspace')]);320mockRuntimeDataService.setAgents([{ name: 'Explore', description: 'Agent' }]);321mockFileSystemService.setFile(URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'), '# Instructions');322mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/s/SKILL.md'), 's')]);323mockFileSystemService.setFile(324URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'),325JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } })326);327328const items = await provider.provideChatSessionCustomizations(undefined!);329expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Agent)).toHaveLength(1);330expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions)).toHaveLength(1);331expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Skill)).toHaveLength(1);332expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Hook)).toHaveLength(1);333});334});335336describe('hook discovery', () => {337it('discovers hooks from workspace .claude/settings.json', async () => {338const workspaceFolder = URI.file('/workspace');339mockWorkspaceService.setFolders([workspaceFolder]);340const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json');341mockFileSystemService.setFile(settingsUri, JSON.stringify({342hooks: {343PreToolUse: [344{ matcher: 'Bash', hooks: [{ type: 'command', command: './scripts/pre-bash.sh' }] }345]346}347}));348349const items = await provider.provideChatSessionCustomizations(undefined!);350const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);351expect(hookItems).toHaveLength(1);352expect(hookItems[0].name).toBe('PreToolUse (Bash)');353expect(hookItems[0].description).toBe('./scripts/pre-bash.sh');354expect(hookItems[0].uri).toEqual(settingsUri);355});356357it('uses wildcard label for * matcher', async () => {358const workspaceFolder = URI.file('/workspace');359mockWorkspaceService.setFolders([workspaceFolder]);360mockFileSystemService.setFile(361URI.joinPath(workspaceFolder, '.claude', 'settings.json'),362JSON.stringify({363hooks: {364SessionStart: [365{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }366]367}368})369);370371const items = await provider.provideChatSessionCustomizations(undefined!);372const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);373expect(hookItems).toHaveLength(1);374expect(hookItems[0].name).toBe('SessionStart');375});376377it('discovers hooks from user home .claude/settings.json', async () => {378const userSettingsUri = URI.joinPath(URI.file('/home/user'), '.claude', 'settings.json');379mockFileSystemService.setFile(userSettingsUri, JSON.stringify({380hooks: {381PostToolUse: [382{ matcher: 'Edit', hooks: [{ type: 'command', command: './lint.sh' }] }383]384}385}));386387const items = await provider.provideChatSessionCustomizations(undefined!);388const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);389expect(hookItems).toHaveLength(1);390expect(hookItems[0].name).toBe('PostToolUse (Edit)');391});392393it('discovers multiple hooks across event types', async () => {394const workspaceFolder = URI.file('/workspace');395mockWorkspaceService.setFolders([workspaceFolder]);396mockFileSystemService.setFile(397URI.joinPath(workspaceFolder, '.claude', 'settings.json'),398JSON.stringify({399hooks: {400PreToolUse: [401{ matcher: 'Bash', hooks: [{ type: 'command', command: './a.sh' }] },402{ matcher: 'Edit', hooks: [{ type: 'command', command: './b.sh' }, { type: 'command', command: './c.sh' }] },403],404SessionStart: [405{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }406]407}408})409);410411const items = await provider.provideChatSessionCustomizations(undefined!);412const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);413expect(hookItems).toHaveLength(4);414});415416it('gracefully handles missing settings files', async () => {417mockWorkspaceService.setFolders([URI.file('/workspace')]);418419const items = await provider.provideChatSessionCustomizations(undefined!);420expect(items).toEqual([]);421});422423it('gracefully handles invalid JSON in settings', async () => {424const workspaceFolder = URI.file('/workspace');425mockWorkspaceService.setFolders([workspaceFolder]);426mockFileSystemService.setFile(427URI.joinPath(workspaceFolder, '.claude', 'settings.json'),428'not valid json {'429);430431const items = await provider.provideChatSessionCustomizations(undefined!);432expect(items).toEqual([]);433});434});435436describe('onDidChange', () => {437it('fires when runtime data changes', () => {438let fired = false;439disposables.add(provider.onDidChange(() => { fired = true; }));440441mockRuntimeDataService.fireChanged();442expect(fired).toBe(true);443});444445it('fires when custom agents change', () => {446let fired = false;447disposables.add(provider.onDidChange(() => { fired = true; }));448449mockPromptsService.fireCustomAgentsChanged();450expect(fired).toBe(true);451});452453it('fires when skills change', () => {454let fired = false;455disposables.add(provider.onDidChange(() => { fired = true; }));456457mockPromptsService.fireSkillsChanged();458expect(fired).toBe(true);459});460461it('fires when workspace folders change', () => {462let fired = false;463disposables.add(provider.onDidChange(() => { fired = true; }));464465mockWorkspaceService.fireWorkspaceFoldersChanged();466expect(fired).toBe(true);467});468});469});470471472