Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/vscode-node/test/claudeSlashCommandService.spec.ts
13406 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 { beforeEach, describe, expect, it, vi } from 'vitest';
7
import type * as vscode from 'vscode';
8
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../util/common/test/testUtils';
9
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
10
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
11
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
12
import { MockChatResponseStream } from '../../../../test/node/testHelpers';
13
import { ClaudeSlashCommandService, IClaudeSlashCommandRequest } from '../claudeSlashCommandService';
14
import { IClaudeSlashCommandHandler, IClaudeSlashCommandHandlerCtor } from '../slashCommands/claudeSlashCommandRegistry';
15
16
// Wire test handler ctors through the registry so the service populates its cache naturally
17
const mockGetRegistry = vi.fn<() => readonly IClaudeSlashCommandHandlerCtor[]>().mockReturnValue([]);
18
vi.mock('../slashCommands/claudeSlashCommandRegistry', async importOriginal => {
19
const actual = await importOriginal<typeof import('../slashCommands/claudeSlashCommandRegistry')>();
20
return { ...actual, getClaudeSlashCommandRegistry: () => mockGetRegistry() };
21
});
22
23
class TestHooksHandler implements IClaudeSlashCommandHandler {
24
static handleSpy = vi.fn<IClaudeSlashCommandHandler['handle']>().mockResolvedValue({});
25
readonly commandName = 'hooks';
26
readonly description = 'Test hooks handler';
27
28
handle(args: string, stream: vscode.ChatResponseStream | undefined, token: CancellationToken): Promise<vscode.ChatResult | void> {
29
return TestHooksHandler.handleSpy(args, stream, token);
30
}
31
}
32
33
class TestMemoryHandler implements IClaudeSlashCommandHandler {
34
static handleSpy = vi.fn<IClaudeSlashCommandHandler['handle']>().mockResolvedValue({});
35
readonly commandName = 'memory';
36
readonly description = 'Test memory handler';
37
38
handle(args: string, stream: vscode.ChatResponseStream | undefined, token: CancellationToken): Promise<vscode.ChatResult | void> {
39
return TestMemoryHandler.handleSpy(args, stream, token);
40
}
41
}
42
43
function makeRequest(prompt: string, command?: string): IClaudeSlashCommandRequest {
44
return { prompt, command };
45
}
46
47
describe('ClaudeSlashCommandService', () => {
48
const store = ensureNoDisposablesAreLeakedInTestSuite();
49
let service: ClaudeSlashCommandService;
50
let stream: MockChatResponseStream;
51
52
beforeEach(() => {
53
TestHooksHandler.handleSpy.mockReset().mockResolvedValue({});
54
TestMemoryHandler.handleSpy.mockReset().mockResolvedValue({});
55
mockGetRegistry.mockReturnValue([TestHooksHandler, TestMemoryHandler]);
56
57
const serviceCollection = store.add(createExtensionUnitTestingServices(store));
58
const accessor = serviceCollection.createTestingAccessor();
59
const instantiationService = accessor.get(IInstantiationService);
60
61
service = store.add(instantiationService.createInstance(ClaudeSlashCommandService));
62
stream = new MockChatResponseStream();
63
});
64
65
// #region request.command (VS Code UI slash command)
66
67
describe('request.command handling', () => {
68
it('dispatches to handler when request.command matches', async () => {
69
const result = await service.tryHandleCommand(
70
makeRequest('some prompt', 'hooks'),
71
stream,
72
CancellationToken.None,
73
);
74
75
expect(result.handled).toBe(true);
76
expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('some prompt', stream, CancellationToken.None);
77
});
78
79
it('passes the full prompt as args when dispatched via request.command', async () => {
80
await service.tryHandleCommand(
81
makeRequest('event PreToolUse', 'hooks'),
82
stream,
83
CancellationToken.None,
84
);
85
86
expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('event PreToolUse', stream, CancellationToken.None);
87
});
88
89
it('is case-insensitive for request.command', async () => {
90
const result = await service.tryHandleCommand(
91
makeRequest('test', 'HOOKS'),
92
stream,
93
CancellationToken.None,
94
);
95
96
expect(result.handled).toBe(true);
97
expect(TestHooksHandler.handleSpy).toHaveBeenCalled();
98
});
99
100
it('returns handled:false for unknown request.command and no prompt match', async () => {
101
const result = await service.tryHandleCommand(
102
makeRequest('hello', 'unknown'),
103
stream,
104
CancellationToken.None,
105
);
106
107
expect(result.handled).toBe(false);
108
});
109
110
it('falls through to prompt parsing when request.command is unknown', async () => {
111
const result = await service.tryHandleCommand(
112
makeRequest('/memory list', 'unknown'),
113
stream,
114
CancellationToken.None,
115
);
116
117
expect(result.handled).toBe(true);
118
expect(TestMemoryHandler.handleSpy).toHaveBeenCalledWith('list', stream, CancellationToken.None);
119
});
120
121
it('takes precedence over prompt-based parsing', async () => {
122
await service.tryHandleCommand(
123
makeRequest('/memory list', 'hooks'),
124
stream,
125
CancellationToken.None,
126
);
127
128
// request.command = 'hooks' wins, prompt is passed as-is
129
expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('/memory list', stream, CancellationToken.None);
130
expect(TestMemoryHandler.handleSpy).not.toHaveBeenCalled();
131
});
132
});
133
134
// #endregion
135
136
// #region Prompt-based slash command parsing
137
138
describe('prompt-based slash command parsing', () => {
139
it('dispatches /command from prompt text', async () => {
140
const result = await service.tryHandleCommand(
141
makeRequest('/hooks event'),
142
stream,
143
CancellationToken.None,
144
);
145
146
expect(result.handled).toBe(true);
147
expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('event', stream, CancellationToken.None);
148
});
149
150
it('passes empty string args when no arguments in prompt', async () => {
151
await service.tryHandleCommand(
152
makeRequest('/hooks'),
153
stream,
154
CancellationToken.None,
155
);
156
157
expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('', stream, CancellationToken.None);
158
});
159
160
it('is case-insensitive for command name in prompt', async () => {
161
const result = await service.tryHandleCommand(
162
makeRequest('/HOOKS'),
163
stream,
164
CancellationToken.None,
165
);
166
167
expect(result.handled).toBe(true);
168
expect(TestHooksHandler.handleSpy).toHaveBeenCalled();
169
});
170
171
it('trims whitespace before parsing', async () => {
172
const result = await service.tryHandleCommand(
173
makeRequest(' /hooks '),
174
stream,
175
CancellationToken.None,
176
);
177
178
expect(result.handled).toBe(true);
179
});
180
181
it('returns handled:false for non-slash prompt', async () => {
182
const result = await service.tryHandleCommand(
183
makeRequest('hello world'),
184
stream,
185
CancellationToken.None,
186
);
187
188
expect(result.handled).toBe(false);
189
});
190
191
it('returns handled:false for unknown command in prompt', async () => {
192
const result = await service.tryHandleCommand(
193
makeRequest('/nonexistent'),
194
stream,
195
CancellationToken.None,
196
);
197
198
expect(result.handled).toBe(false);
199
});
200
201
it('returns handled:false for prompt with slash mid-text', async () => {
202
const result = await service.tryHandleCommand(
203
makeRequest('please run /hooks'),
204
stream,
205
CancellationToken.None,
206
);
207
208
expect(result.handled).toBe(false);
209
});
210
});
211
212
// #endregion
213
214
// #region Handler result propagation
215
216
describe('result propagation', () => {
217
it('returns handler result in the response', async () => {
218
const expectedResult: vscode.ChatResult = { metadata: { key: 'value' } };
219
TestHooksHandler.handleSpy.mockResolvedValue(expectedResult);
220
221
const result = await service.tryHandleCommand(
222
makeRequest('/hooks'),
223
stream,
224
CancellationToken.None,
225
);
226
227
expect(result.result).toEqual(expectedResult);
228
});
229
230
it('returns empty object when handler returns void', async () => {
231
TestHooksHandler.handleSpy.mockResolvedValue(undefined);
232
233
const result = await service.tryHandleCommand(
234
makeRequest('/hooks'),
235
stream,
236
CancellationToken.None,
237
);
238
239
expect(result.handled).toBe(true);
240
expect(result.result).toEqual({});
241
});
242
});
243
244
// #endregion
245
246
// #region request.command undefined / not set
247
248
describe('when request.command is undefined', () => {
249
it('falls through to prompt-based parsing', async () => {
250
const result = await service.tryHandleCommand(
251
makeRequest('/memory foo', undefined),
252
stream,
253
CancellationToken.None,
254
);
255
256
expect(result.handled).toBe(true);
257
expect(TestMemoryHandler.handleSpy).toHaveBeenCalledWith('foo', stream, CancellationToken.None);
258
});
259
});
260
261
// #endregion
262
});
263
264