Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeCustomizationProvider.spec.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { AgentInfo } from '@anthropic-ai/claude-agent-sdk';
7
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
import * as vscode from 'vscode';
9
import { INativeEnvService } from '../../../../platform/env/common/envService';
10
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
11
import { ILogService } from '../../../../platform/log/common/logService';
12
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
13
import { mock } from '../../../../util/common/test/simpleMock';
14
import { Emitter, Event } from '../../../../util/vs/base/common/event';
15
import { DisposableStore } from '../../../../util/vs/base/common/lifecycle';
16
import { URI } from '../../../../util/vs/base/common/uri';
17
import { IClaudeRuntimeDataService } from '../../claude/common/claudeRuntimeDataService';
18
import { ClaudeCustomizationProvider } from '../claudeCustomizationProvider';
19
import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';
20
21
function mockAgent(uri: URI, name: string): vscode.ChatCustomAgent {
22
return { uri, name, source: 'local', userInvocable: true, disableModelInvocation: false, enabled: true } satisfies vscode.ChatCustomAgent;
23
}
24
25
function mockSkill(uri: URI, name: string): vscode.ChatSkill {
26
return { uri, name, source: 'local' } satisfies vscode.ChatSkill;
27
}
28
29
class FakeChatSessionCustomizationType {
30
static readonly Agent = new FakeChatSessionCustomizationType('agent');
31
static readonly Skill = new FakeChatSessionCustomizationType('skill');
32
static readonly Instructions = new FakeChatSessionCustomizationType('instructions');
33
static readonly Prompt = new FakeChatSessionCustomizationType('prompt');
34
static readonly Hook = new FakeChatSessionCustomizationType('hook');
35
constructor(readonly id: string) { }
36
}
37
38
class MockRuntimeDataService extends mock<IClaudeRuntimeDataService>() {
39
private readonly _onDidChange = new Emitter<void>();
40
override readonly onDidChange = this._onDidChange.event;
41
private _agents: AgentInfo[] = [];
42
43
setAgents(agents: AgentInfo[]) { this._agents = agents; }
44
override getAgents(): readonly AgentInfo[] { return this._agents; }
45
fireChanged() { this._onDidChange.fire(); }
46
dispose() { this._onDidChange.dispose(); }
47
}
48
49
class MockWorkspaceService extends mock<IWorkspaceService>() {
50
private _folders: URI[] = [];
51
private readonly _onDidChange = new Emitter<void>();
52
override readonly onDidChangeWorkspaceFolders: Event<any> = this._onDidChange.event;
53
setFolders(folders: URI[]) { this._folders = folders; }
54
override getWorkspaceFolders(): URI[] { return this._folders; }
55
fireWorkspaceFoldersChanged() { this._onDidChange.fire(); }
56
}
57
58
class MockFileSystemService extends mock<IFileSystemService>() {
59
private readonly _files = new Map<string, Uint8Array>();
60
setFile(uri: URI, content: string) {
61
this._files.set(uri.toString(), new TextEncoder().encode(content));
62
}
63
override async stat(uri: URI): Promise<{ type: number; ctime: number; mtime: number; size: number }> {
64
if (!this._files.has(uri.toString())) {
65
throw new Error(`File not found: ${uri.toString()}`);
66
}
67
return { type: 1 /* File */, ctime: 0, mtime: 0, size: this._files.get(uri.toString())!.length };
68
}
69
override async readFile(uri: URI): Promise<Uint8Array> {
70
const content = this._files.get(uri.toString());
71
if (!content) {
72
throw new Error(`File not found: ${uri.toString()}`);
73
}
74
return content;
75
}
76
}
77
78
class MockEnvService extends mock<INativeEnvService>() {
79
override userHome = URI.file('/home/user');
80
}
81
82
class TestLogService extends mock<ILogService>() {
83
override trace() { }
84
override debug() { }
85
}
86
87
describe('ClaudeCustomizationProvider', () => {
88
let disposables: DisposableStore;
89
let mockRuntimeDataService: MockRuntimeDataService;
90
let mockPromptsService: MockPromptsService;
91
let mockWorkspaceService: MockWorkspaceService;
92
let mockFileSystemService: MockFileSystemService;
93
let provider: ClaudeCustomizationProvider;
94
95
let originalChatSessionCustomizationType: unknown;
96
97
beforeEach(() => {
98
originalChatSessionCustomizationType = (vscode as Record<string, unknown>).ChatSessionCustomizationType;
99
(vscode as Record<string, unknown>).ChatSessionCustomizationType = FakeChatSessionCustomizationType;
100
disposables = new DisposableStore();
101
mockRuntimeDataService = disposables.add(new MockRuntimeDataService());
102
mockPromptsService = disposables.add(new MockPromptsService());
103
mockWorkspaceService = new MockWorkspaceService();
104
mockFileSystemService = new MockFileSystemService();
105
provider = disposables.add(new ClaudeCustomizationProvider(
106
mockPromptsService,
107
mockRuntimeDataService,
108
mockWorkspaceService,
109
mockFileSystemService,
110
new MockEnvService(),
111
new TestLogService(),
112
));
113
});
114
115
afterEach(() => {
116
disposables.dispose();
117
(vscode as Record<string, unknown>).ChatSessionCustomizationType = originalChatSessionCustomizationType;
118
});
119
120
describe('metadata', () => {
121
it('has correct label and icon', () => {
122
expect(ClaudeCustomizationProvider.metadata.label).toBe('Claude');
123
expect(ClaudeCustomizationProvider.metadata.iconId).toBe('claude');
124
});
125
126
it('supports Agent, Skill, Instructions, and Hook types', () => {
127
const supported = ClaudeCustomizationProvider.metadata.supportedTypes;
128
expect(supported).toBeDefined();
129
expect(supported).toHaveLength(4);
130
expect(supported).toContain(FakeChatSessionCustomizationType.Agent);
131
expect(supported).toContain(FakeChatSessionCustomizationType.Skill);
132
expect(supported).toContain(FakeChatSessionCustomizationType.Instructions);
133
expect(supported).toContain(FakeChatSessionCustomizationType.Hook);
134
});
135
136
it('only returns items whose type is in supportedTypes', async () => {
137
mockRuntimeDataService.setAgents([
138
{ name: 'Explore', description: 'Fast exploration agent' },
139
]);
140
const items = await provider.provideChatSessionCustomizations(undefined!);
141
const supported = new Set(ClaudeCustomizationProvider.metadata.supportedTypes!.map(t => t.id));
142
for (const item of items) {
143
expect(supported.has(item.type.id), `item "${item.name}" has type "${item.type.id}" which is not in supportedTypes`).toBe(true);
144
}
145
});
146
147
it('does not set groupKey for items with synthetic URIs (vscode infers grouping)', async () => {
148
mockRuntimeDataService.setAgents([
149
{ name: 'Explore', description: 'Explore agent' },
150
]);
151
const items = await provider.provideChatSessionCustomizations(undefined!);
152
const builtinItems = items.filter(i => i.uri.scheme !== 'file');
153
for (const item of builtinItems) {
154
expect(item.groupKey, `item "${item.name}" with scheme "${item.uri.scheme}" should not have groupKey (vscode infers)`).toBeUndefined();
155
}
156
});
157
});
158
159
describe('agents from SDK', () => {
160
it('returns empty when no session has initialized and no file agents', async () => {
161
const items = await provider.provideChatSessionCustomizations(undefined!);
162
expect(items).toEqual([]);
163
});
164
165
it('returns agents from the runtime data service', async () => {
166
mockRuntimeDataService.setAgents([
167
{ name: 'Explore', description: 'Fast exploration agent' },
168
{ name: 'Review', description: 'Code review agent', model: 'claude-3.5-sonnet' },
169
]);
170
171
const items = await provider.provideChatSessionCustomizations(undefined!);
172
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
173
expect(agentItems).toHaveLength(2);
174
expect(agentItems[0].name).toBe('Explore');
175
expect(agentItems[0].description).toBe('Fast exploration agent');
176
expect(agentItems[0].groupKey).toBeUndefined();
177
expect(agentItems[0].uri.scheme).toBe('claude-code');
178
expect(agentItems[0].uri.path).toBe('/agents/Explore');
179
expect(agentItems[1].name).toBe('Review');
180
});
181
182
it('shows file-based agents from .claude/ paths before session starts', async () => {
183
mockWorkspaceService.setFolders([URI.file('/workspace')]);
184
mockPromptsService.setCustomAgents([
185
mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'),
186
]);
187
188
const items = await provider.provideChatSessionCustomizations(undefined!);
189
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
190
expect(agentItems).toHaveLength(1);
191
expect(agentItems[0].name).toBe('my-agent');
192
expect(agentItems[0].uri.scheme).toBe('file');
193
});
194
195
it('deduplicates file agents when SDK provides the same agent', async () => {
196
mockWorkspaceService.setFolders([URI.file('/workspace')]);
197
mockRuntimeDataService.setAgents([
198
{ name: 'my-agent', description: 'SDK version' },
199
]);
200
mockPromptsService.setCustomAgents([
201
mockAgent(URI.file('/workspace/.claude/agents/my-agent.agent.md'), 'my-agent'),
202
]);
203
204
const items = await provider.provideChatSessionCustomizations(undefined!);
205
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
206
expect(agentItems).toHaveLength(1);
207
expect(agentItems[0].description).toBe('SDK version');
208
expect(agentItems[0].groupKey).toBeUndefined();
209
});
210
211
it('filters out file agents not under .claude/', async () => {
212
mockWorkspaceService.setFolders([URI.file('/workspace')]);
213
mockPromptsService.setCustomAgents([
214
mockAgent(URI.file('/workspace/.github/my-agent.agent.md'), 'my-agent'),
215
mockAgent(URI.file('/workspace/root.agent.md'), 'root-agent'),
216
]);
217
218
const items = await provider.provideChatSessionCustomizations(undefined!);
219
const agentItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Agent);
220
expect(agentItems).toHaveLength(0);
221
});
222
});
223
224
describe('instructions from CLAUDE.md paths', () => {
225
beforeEach(() => {
226
mockWorkspaceService.setFolders([URI.file('/workspace')]);
227
});
228
229
it('discovers CLAUDE.md in workspace root', async () => {
230
const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md');
231
mockFileSystemService.setFile(uri, '# Instructions');
232
233
const items = await provider.provideChatSessionCustomizations(undefined!);
234
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
235
expect(instructionItems).toHaveLength(1);
236
expect(instructionItems[0].name).toBe('CLAUDE');
237
expect(instructionItems[0].uri).toEqual(uri);
238
});
239
240
it('discovers CLAUDE.local.md in workspace root', async () => {
241
const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.local.md');
242
mockFileSystemService.setFile(uri, '# Local');
243
244
const items = await provider.provideChatSessionCustomizations(undefined!);
245
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
246
expect(instructionItems).toHaveLength(1);
247
expect(instructionItems[0].name).toBe('CLAUDE.local');
248
});
249
250
it('discovers .claude/CLAUDE.md in workspace', async () => {
251
const uri = URI.joinPath(URI.file('/workspace'), '.claude', 'CLAUDE.md');
252
mockFileSystemService.setFile(uri, '# Claude dir');
253
254
const items = await provider.provideChatSessionCustomizations(undefined!);
255
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
256
expect(instructionItems).toHaveLength(1);
257
expect(instructionItems[0].name).toBe('CLAUDE');
258
});
259
260
it('discovers ~/.claude/CLAUDE.md in user home', async () => {
261
const uri = URI.joinPath(URI.file('/home/user'), '.claude', 'CLAUDE.md');
262
mockFileSystemService.setFile(uri, '# Home');
263
264
const items = await provider.provideChatSessionCustomizations(undefined!);
265
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
266
expect(instructionItems).toHaveLength(1);
267
expect(instructionItems[0].uri).toEqual(uri);
268
});
269
270
it('only reports instruction files that exist', async () => {
271
// Only set one of the five possible paths
272
const uri = URI.joinPath(URI.file('/workspace'), 'CLAUDE.md');
273
mockFileSystemService.setFile(uri, '# Only this one');
274
275
const items = await provider.provideChatSessionCustomizations(undefined!);
276
const instructionItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions);
277
expect(instructionItems).toHaveLength(1);
278
});
279
});
280
281
describe('skills from .claude/ paths', () => {
282
beforeEach(() => {
283
mockWorkspaceService.setFolders([URI.file('/workspace')]);
284
});
285
286
it('returns skills under .claude/skills/', async () => {
287
const uri = URI.file('/workspace/.claude/skills/my-skill/SKILL.md');
288
mockPromptsService.setSkills([mockSkill(uri, 'my-skill')]);
289
290
const items = await provider.provideChatSessionCustomizations(undefined!);
291
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
292
expect(skillItems).toHaveLength(1);
293
expect(skillItems[0].uri).toBe(uri);
294
expect(skillItems[0].name).toBe('my-skill');
295
});
296
297
it('filters out skills not under .claude/', async () => {
298
mockPromptsService.setSkills([
299
mockSkill(URI.file('/workspace/.github/skills/copilot-skill/SKILL.md'), 'copilot-skill'),
300
mockSkill(URI.file('/workspace/.copilot/skills/other/SKILL.md'), 'other-skill'),
301
]);
302
303
const items = await provider.provideChatSessionCustomizations(undefined!);
304
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
305
expect(skillItems).toHaveLength(0);
306
});
307
308
it('includes skills from user home .claude/ directory', async () => {
309
const uri = URI.file('/home/user/.claude/skills/global-skill/SKILL.md');
310
mockPromptsService.setSkills([mockSkill(uri, 'global-skill')]);
311
312
const items = await provider.provideChatSessionCustomizations(undefined!);
313
const skillItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Skill);
314
expect(skillItems).toHaveLength(1);
315
});
316
});
317
318
describe('combined items', () => {
319
it('returns agents, instructions, skills, and hooks together', async () => {
320
mockWorkspaceService.setFolders([URI.file('/workspace')]);
321
mockRuntimeDataService.setAgents([{ name: 'Explore', description: 'Agent' }]);
322
mockFileSystemService.setFile(URI.joinPath(URI.file('/workspace'), 'CLAUDE.md'), '# Instructions');
323
mockPromptsService.setSkills([mockSkill(URI.file('/workspace/.claude/skills/s/SKILL.md'), 's')]);
324
mockFileSystemService.setFile(
325
URI.joinPath(URI.file('/workspace'), '.claude', 'settings.json'),
326
JSON.stringify({ hooks: { SessionStart: [{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }] } })
327
);
328
329
const items = await provider.provideChatSessionCustomizations(undefined!);
330
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Agent)).toHaveLength(1);
331
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Instructions)).toHaveLength(1);
332
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Skill)).toHaveLength(1);
333
expect(items.filter(i => i.type === FakeChatSessionCustomizationType.Hook)).toHaveLength(1);
334
});
335
});
336
337
describe('hook discovery', () => {
338
it('discovers hooks from workspace .claude/settings.json', async () => {
339
const workspaceFolder = URI.file('/workspace');
340
mockWorkspaceService.setFolders([workspaceFolder]);
341
const settingsUri = URI.joinPath(workspaceFolder, '.claude', 'settings.json');
342
mockFileSystemService.setFile(settingsUri, JSON.stringify({
343
hooks: {
344
PreToolUse: [
345
{ matcher: 'Bash', hooks: [{ type: 'command', command: './scripts/pre-bash.sh' }] }
346
]
347
}
348
}));
349
350
const items = await provider.provideChatSessionCustomizations(undefined!);
351
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
352
expect(hookItems).toHaveLength(1);
353
expect(hookItems[0].name).toBe('PreToolUse (Bash)');
354
expect(hookItems[0].description).toBe('./scripts/pre-bash.sh');
355
expect(hookItems[0].uri).toEqual(settingsUri);
356
});
357
358
it('uses wildcard label for * matcher', async () => {
359
const workspaceFolder = URI.file('/workspace');
360
mockWorkspaceService.setFolders([workspaceFolder]);
361
mockFileSystemService.setFile(
362
URI.joinPath(workspaceFolder, '.claude', 'settings.json'),
363
JSON.stringify({
364
hooks: {
365
SessionStart: [
366
{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }
367
]
368
}
369
})
370
);
371
372
const items = await provider.provideChatSessionCustomizations(undefined!);
373
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
374
expect(hookItems).toHaveLength(1);
375
expect(hookItems[0].name).toBe('SessionStart');
376
});
377
378
it('discovers hooks from user home .claude/settings.json', async () => {
379
const userSettingsUri = URI.joinPath(URI.file('/home/user'), '.claude', 'settings.json');
380
mockFileSystemService.setFile(userSettingsUri, JSON.stringify({
381
hooks: {
382
PostToolUse: [
383
{ matcher: 'Edit', hooks: [{ type: 'command', command: './lint.sh' }] }
384
]
385
}
386
}));
387
388
const items = await provider.provideChatSessionCustomizations(undefined!);
389
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
390
expect(hookItems).toHaveLength(1);
391
expect(hookItems[0].name).toBe('PostToolUse (Edit)');
392
});
393
394
it('discovers multiple hooks across event types', async () => {
395
const workspaceFolder = URI.file('/workspace');
396
mockWorkspaceService.setFolders([workspaceFolder]);
397
mockFileSystemService.setFile(
398
URI.joinPath(workspaceFolder, '.claude', 'settings.json'),
399
JSON.stringify({
400
hooks: {
401
PreToolUse: [
402
{ matcher: 'Bash', hooks: [{ type: 'command', command: './a.sh' }] },
403
{ matcher: 'Edit', hooks: [{ type: 'command', command: './b.sh' }, { type: 'command', command: './c.sh' }] },
404
],
405
SessionStart: [
406
{ matcher: '*', hooks: [{ type: 'command', command: './init.sh' }] }
407
]
408
}
409
})
410
);
411
412
const items = await provider.provideChatSessionCustomizations(undefined!);
413
const hookItems = items.filter(i => i.type === FakeChatSessionCustomizationType.Hook);
414
expect(hookItems).toHaveLength(4);
415
});
416
417
it('gracefully handles missing settings files', async () => {
418
mockWorkspaceService.setFolders([URI.file('/workspace')]);
419
420
const items = await provider.provideChatSessionCustomizations(undefined!);
421
expect(items).toEqual([]);
422
});
423
424
it('gracefully handles invalid JSON in settings', async () => {
425
const workspaceFolder = URI.file('/workspace');
426
mockWorkspaceService.setFolders([workspaceFolder]);
427
mockFileSystemService.setFile(
428
URI.joinPath(workspaceFolder, '.claude', 'settings.json'),
429
'not valid json {'
430
);
431
432
const items = await provider.provideChatSessionCustomizations(undefined!);
433
expect(items).toEqual([]);
434
});
435
});
436
437
describe('onDidChange', () => {
438
it('fires when runtime data changes', () => {
439
let fired = false;
440
disposables.add(provider.onDidChange(() => { fired = true; }));
441
442
mockRuntimeDataService.fireChanged();
443
expect(fired).toBe(true);
444
});
445
446
it('fires when custom agents change', () => {
447
let fired = false;
448
disposables.add(provider.onDidChange(() => { fired = true; }));
449
450
mockPromptsService.fireCustomAgentsChanged();
451
expect(fired).toBe(true);
452
});
453
454
it('fires when skills change', () => {
455
let fired = false;
456
disposables.add(provider.onDidChange(() => { fired = true; }));
457
458
mockPromptsService.fireSkillsChanged();
459
expect(fired).toBe(true);
460
});
461
462
it('fires when workspace folders change', () => {
463
let fired = false;
464
disposables.add(provider.onDidChange(() => { fired = true; }));
465
466
mockWorkspaceService.fireWorkspaceFoldersChanged();
467
expect(fired).toBe(true);
468
});
469
});
470
});
471
472