Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/vscode-node/test/claudeSlashCommandService.spec.ts
13406 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 { beforeEach, describe, expect, it, vi } from 'vitest';6import type * as vscode from 'vscode';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../util/common/test/testUtils';8import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';9import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';10import { createExtensionUnitTestingServices } from '../../../../test/node/services';11import { MockChatResponseStream } from '../../../../test/node/testHelpers';12import { ClaudeSlashCommandService, IClaudeSlashCommandRequest } from '../claudeSlashCommandService';13import { IClaudeSlashCommandHandler, IClaudeSlashCommandHandlerCtor } from '../slashCommands/claudeSlashCommandRegistry';1415// Wire test handler ctors through the registry so the service populates its cache naturally16const mockGetRegistry = vi.fn<() => readonly IClaudeSlashCommandHandlerCtor[]>().mockReturnValue([]);17vi.mock('../slashCommands/claudeSlashCommandRegistry', async importOriginal => {18const actual = await importOriginal<typeof import('../slashCommands/claudeSlashCommandRegistry')>();19return { ...actual, getClaudeSlashCommandRegistry: () => mockGetRegistry() };20});2122class TestHooksHandler implements IClaudeSlashCommandHandler {23static handleSpy = vi.fn<IClaudeSlashCommandHandler['handle']>().mockResolvedValue({});24readonly commandName = 'hooks';25readonly description = 'Test hooks handler';2627handle(args: string, stream: vscode.ChatResponseStream | undefined, token: CancellationToken): Promise<vscode.ChatResult | void> {28return TestHooksHandler.handleSpy(args, stream, token);29}30}3132class TestMemoryHandler implements IClaudeSlashCommandHandler {33static handleSpy = vi.fn<IClaudeSlashCommandHandler['handle']>().mockResolvedValue({});34readonly commandName = 'memory';35readonly description = 'Test memory handler';3637handle(args: string, stream: vscode.ChatResponseStream | undefined, token: CancellationToken): Promise<vscode.ChatResult | void> {38return TestMemoryHandler.handleSpy(args, stream, token);39}40}4142function makeRequest(prompt: string, command?: string): IClaudeSlashCommandRequest {43return { prompt, command };44}4546describe('ClaudeSlashCommandService', () => {47const store = ensureNoDisposablesAreLeakedInTestSuite();48let service: ClaudeSlashCommandService;49let stream: MockChatResponseStream;5051beforeEach(() => {52TestHooksHandler.handleSpy.mockReset().mockResolvedValue({});53TestMemoryHandler.handleSpy.mockReset().mockResolvedValue({});54mockGetRegistry.mockReturnValue([TestHooksHandler, TestMemoryHandler]);5556const serviceCollection = store.add(createExtensionUnitTestingServices(store));57const accessor = serviceCollection.createTestingAccessor();58const instantiationService = accessor.get(IInstantiationService);5960service = store.add(instantiationService.createInstance(ClaudeSlashCommandService));61stream = new MockChatResponseStream();62});6364// #region request.command (VS Code UI slash command)6566describe('request.command handling', () => {67it('dispatches to handler when request.command matches', async () => {68const result = await service.tryHandleCommand(69makeRequest('some prompt', 'hooks'),70stream,71CancellationToken.None,72);7374expect(result.handled).toBe(true);75expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('some prompt', stream, CancellationToken.None);76});7778it('passes the full prompt as args when dispatched via request.command', async () => {79await service.tryHandleCommand(80makeRequest('event PreToolUse', 'hooks'),81stream,82CancellationToken.None,83);8485expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('event PreToolUse', stream, CancellationToken.None);86});8788it('is case-insensitive for request.command', async () => {89const result = await service.tryHandleCommand(90makeRequest('test', 'HOOKS'),91stream,92CancellationToken.None,93);9495expect(result.handled).toBe(true);96expect(TestHooksHandler.handleSpy).toHaveBeenCalled();97});9899it('returns handled:false for unknown request.command and no prompt match', async () => {100const result = await service.tryHandleCommand(101makeRequest('hello', 'unknown'),102stream,103CancellationToken.None,104);105106expect(result.handled).toBe(false);107});108109it('falls through to prompt parsing when request.command is unknown', async () => {110const result = await service.tryHandleCommand(111makeRequest('/memory list', 'unknown'),112stream,113CancellationToken.None,114);115116expect(result.handled).toBe(true);117expect(TestMemoryHandler.handleSpy).toHaveBeenCalledWith('list', stream, CancellationToken.None);118});119120it('takes precedence over prompt-based parsing', async () => {121await service.tryHandleCommand(122makeRequest('/memory list', 'hooks'),123stream,124CancellationToken.None,125);126127// request.command = 'hooks' wins, prompt is passed as-is128expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('/memory list', stream, CancellationToken.None);129expect(TestMemoryHandler.handleSpy).not.toHaveBeenCalled();130});131});132133// #endregion134135// #region Prompt-based slash command parsing136137describe('prompt-based slash command parsing', () => {138it('dispatches /command from prompt text', async () => {139const result = await service.tryHandleCommand(140makeRequest('/hooks event'),141stream,142CancellationToken.None,143);144145expect(result.handled).toBe(true);146expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('event', stream, CancellationToken.None);147});148149it('passes empty string args when no arguments in prompt', async () => {150await service.tryHandleCommand(151makeRequest('/hooks'),152stream,153CancellationToken.None,154);155156expect(TestHooksHandler.handleSpy).toHaveBeenCalledWith('', stream, CancellationToken.None);157});158159it('is case-insensitive for command name in prompt', async () => {160const result = await service.tryHandleCommand(161makeRequest('/HOOKS'),162stream,163CancellationToken.None,164);165166expect(result.handled).toBe(true);167expect(TestHooksHandler.handleSpy).toHaveBeenCalled();168});169170it('trims whitespace before parsing', async () => {171const result = await service.tryHandleCommand(172makeRequest(' /hooks '),173stream,174CancellationToken.None,175);176177expect(result.handled).toBe(true);178});179180it('returns handled:false for non-slash prompt', async () => {181const result = await service.tryHandleCommand(182makeRequest('hello world'),183stream,184CancellationToken.None,185);186187expect(result.handled).toBe(false);188});189190it('returns handled:false for unknown command in prompt', async () => {191const result = await service.tryHandleCommand(192makeRequest('/nonexistent'),193stream,194CancellationToken.None,195);196197expect(result.handled).toBe(false);198});199200it('returns handled:false for prompt with slash mid-text', async () => {201const result = await service.tryHandleCommand(202makeRequest('please run /hooks'),203stream,204CancellationToken.None,205);206207expect(result.handled).toBe(false);208});209});210211// #endregion212213// #region Handler result propagation214215describe('result propagation', () => {216it('returns handler result in the response', async () => {217const expectedResult: vscode.ChatResult = { metadata: { key: 'value' } };218TestHooksHandler.handleSpy.mockResolvedValue(expectedResult);219220const result = await service.tryHandleCommand(221makeRequest('/hooks'),222stream,223CancellationToken.None,224);225226expect(result.result).toEqual(expectedResult);227});228229it('returns empty object when handler returns void', async () => {230TestHooksHandler.handleSpy.mockResolvedValue(undefined);231232const result = await service.tryHandleCommand(233makeRequest('/hooks'),234stream,235CancellationToken.None,236);237238expect(result.handled).toBe(true);239expect(result.result).toEqual({});240});241});242243// #endregion244245// #region request.command undefined / not set246247describe('when request.command is undefined', () => {248it('falls through to prompt-based parsing', async () => {249const result = await service.tryHandleCommand(250makeRequest('/memory foo', undefined),251stream,252CancellationToken.None,253);254255expect(result.handled).toBe(true);256expect(TestMemoryHandler.handleSpy).toHaveBeenCalledWith('foo', stream, CancellationToken.None);257});258});259260// #endregion261});262263264