Skip to content
This repository was archived by the owner on May 20, 2026. It is now read-only.

Commit deb097d

Browse files
Fix slash command handler not taking the command into consideration (#3839)
* Fix slash command handler not taking the command into consideration This broke at some point, not sure when... but now it's plumbed through. * feedback
1 parent 21ac232 commit deb097d

3 files changed

Lines changed: 286 additions & 7 deletions

File tree

src/extension/agents/claude/vscode-node/claudeSlashCommandService.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import { Disposable } from '../../../../util/vs/base/common/lifecycle';
1010
import { createDecorator, IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
1111
import { getClaudeSlashCommandRegistry, IClaudeSlashCommandHandler } from './slashCommands/claudeSlashCommandRegistry';
1212

13+
export interface IClaudeSlashCommandRequest {
14+
readonly prompt: string;
15+
readonly command: string | undefined;
16+
}
17+
1318
// Import all slash command handlers to trigger self-registration
1419
import './slashCommands/index';
1520

@@ -22,15 +27,18 @@ export interface IClaudeSlashCommandService {
2227
readonly _serviceBrand: undefined;
2328

2429
/**
25-
* Try to handle a slash command from the user's prompt.
30+
* Try to handle a slash command from the user's request.
31+
*
32+
* Checks `request.command` first (VS Code slash command), then falls back to
33+
* parsing a `/command` pattern from `request.prompt`.
2634
*
27-
* @param prompt - The user's full prompt (e.g., "/hooks event")
35+
* @param request - The user's request containing prompt and optional command
2836
* @param stream - Response stream for sending messages to the chat
2937
* @param token - Cancellation token
3038
* @returns Object indicating whether the command was handled and the result
3139
*/
3240
tryHandleCommand(
33-
prompt: string,
41+
request: IClaudeSlashCommandRequest,
3442
stream: vscode.ChatResponseStream,
3543
token: CancellationToken
3644
): Promise<IClaudeSlashCommandResult>;
@@ -59,12 +67,21 @@ export class ClaudeSlashCommandService extends Disposable implements IClaudeSlas
5967
}
6068

6169
async tryHandleCommand(
62-
prompt: string,
70+
request: IClaudeSlashCommandRequest,
6371
stream: vscode.ChatResponseStream,
6472
token: CancellationToken
6573
): Promise<IClaudeSlashCommandResult> {
66-
// Parse the prompt for a slash command pattern: /commandName [args]
67-
const match = prompt.trim().match(/^\/(\w+)(?:\s+(.*))?$/);
74+
// 1. Check request.command (VS Code slash command selected via UI)
75+
if (request.command) {
76+
const handler = this._getHandler(request.command.toLowerCase());
77+
if (handler) {
78+
const result = await handler.handle(request.prompt, stream, token);
79+
return { handled: true, result: result ?? {} };
80+
}
81+
}
82+
83+
// 2. Fall back to parsing /command from the prompt text
84+
const match = request.prompt.trim().match(/^\/(\w+)(?:\s+(.*))?$/);
6885
if (!match) {
6986
return { handled: false };
7087
}
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
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+
});

src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco
271271
}
272272

273273
// Try to handle as a slash command first
274-
const slashResult = await this.slashCommandService.tryHandleCommand(request.prompt, stream, token);
274+
const slashResult = await this.slashCommandService.tryHandleCommand(request, stream, token);
275275
if (slashResult.handled) {
276276
return slashResult.result ?? {};
277277
}

0 commit comments

Comments
 (0)