Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeToolPermissionService.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 } from 'vitest';6import type * as vscode from 'vscode';7import { IChatEndpoint } from '../../../../../platform/networking/common/networking';8import { Emitter } from '../../../../../util/vs/base/common/event';9import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';10import { constObservable, IObservable } from '../../../../../util/vs/base/common/observableInternal';11import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';12import { LanguageModelTextPart } from '../../../../../vscodeTypes';13import { createExtensionUnitTestingServices } from '../../../../test/node/services';14import { ToolName } from '../../../../tools/common/toolNames';15import { ICopilotTool } from '../../../../tools/common/toolsRegistry';16import { IOnWillInvokeToolEvent, IToolsService, IToolValidationResult } from '../../../../tools/common/toolsService';17import { ClaudeToolPermissionContext, ClaudeToolPermissionResult, IClaudeToolConfirmationParams, IClaudeToolPermissionHandler } from '../../common/claudeToolPermission';18import { registerToolPermissionHandler } from '../../common/claudeToolPermissionRegistry';19import { ClaudeToolPermissionService } from '../../common/claudeToolPermissionService';20import { ClaudeToolNames } from '../../common/claudeTools';2122// Import existing handlers to ensure they're registered23import '../../common/toolPermissionHandlers/index';2425/**26* Mock tools service that can be configured for different test scenarios27*/28class MockToolsService implements IToolsService {29readonly _serviceBrand: undefined;3031private readonly _onWillInvokeTool = new Emitter<IOnWillInvokeToolEvent>();32readonly onWillInvokeTool = this._onWillInvokeTool.event;3334readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation> = [];35readonly copilotTools = new Map<ToolName, ICopilotTool<unknown>>();3637private _confirmationResult: 'yes' | 'no' = 'yes';38private _optionsConfirmationResult: string | undefined;39private _invokeToolCalls: Array<{ name: string; input: unknown }> = [];4041setConfirmationResult(result: 'yes' | 'no'): void {42this._confirmationResult = result;43}4445setOptionsConfirmationResult(result: string | undefined): void {46this._optionsConfirmationResult = result;47}4849get invokeToolCalls(): ReadonlyArray<{ name: string; input: unknown }> {50return this._invokeToolCalls;51}5253clearCalls(): void {54this._invokeToolCalls = [];55}5657invokeToolWithEndpoint(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>, endpoint: IChatEndpoint | undefined, token: vscode.CancellationToken): Thenable<vscode.LanguageModelToolResult2> {58return this.invokeTool(name, options);59}6061modelSpecificTools: IObservable<{ definition: vscode.LanguageModelToolDefinition; tool: ICopilotTool<unknown> }[]> = constObservable([]);6263async invokeTool(64name: string,65options: vscode.LanguageModelToolInvocationOptions<unknown>66): Promise<vscode.LanguageModelToolResult2> {67this._invokeToolCalls.push({ name, input: options.input });6869if (name === ToolName.CoreConfirmationTool || name === ToolName.CoreTerminalConfirmationTool) {70return {71content: [new LanguageModelTextPart(this._confirmationResult)]72};73}7475if (name === ToolName.CoreConfirmationToolWithOptions) {76return {77content: this._optionsConfirmationResult !== undefined78? [new LanguageModelTextPart(this._optionsConfirmationResult)]79: []80};81}8283return { content: [] };84}8586getCopilotTool(): ICopilotTool<unknown> | undefined {87return undefined;88}8990getTool(): vscode.LanguageModelToolInformation | undefined {91return undefined;92}9394getToolByToolReferenceName(): vscode.LanguageModelToolInformation | undefined {95return undefined;96}9798validateToolInput(): IToolValidationResult {99return { inputObj: {} };100}101102validateToolName(): string | undefined {103return undefined;104}105106getEnabledTools(): vscode.LanguageModelToolInformation[] {107return [];108}109}110111/**112* Creates a mock tool permission context113*/114function createMockContext(): ClaudeToolPermissionContext {115return {116toolInvocationToken: {} as vscode.ChatParticipantToolToken117};118}119120describe('ClaudeToolPermissionService', () => {121let store: DisposableStore;122let instantiationService: IInstantiationService;123let mockToolsService: MockToolsService;124let service: ClaudeToolPermissionService;125126beforeEach(() => {127store = new DisposableStore();128const serviceCollection = store.add(createExtensionUnitTestingServices());129130mockToolsService = new MockToolsService();131serviceCollection.set(IToolsService, mockToolsService);132133const accessor = serviceCollection.createTestingAccessor();134instantiationService = accessor.get(IInstantiationService);135service = instantiationService.createInstance(ClaudeToolPermissionService);136});137138describe('canUseTool', () => {139describe('with default confirmation flow', () => {140it('allows tool when user confirms', async () => {141mockToolsService.setConfirmationResult('yes');142const input = { pattern: '**/*.ts' };143const context = createMockContext();144145const result = await service.canUseTool(ClaudeToolNames.Glob, input, context);146147expect(result.behavior).toBe('allow');148if (result.behavior === 'allow') {149expect(result.updatedInput).toEqual(input);150}151});152153it('denies tool when user declines', async () => {154mockToolsService.setConfirmationResult('no');155const input = { pattern: '**/*.ts' };156const context = createMockContext();157158const result = await service.canUseTool(ClaudeToolNames.Glob, input, context);159160expect(result.behavior).toBe('deny');161if (result.behavior === 'deny') {162expect(result.message).toBe('The user declined to run the tool');163}164});165166it('invokes CoreConfirmationTool with tool parameters', async () => {167const input = { pattern: 'test-pattern' };168const context = createMockContext();169170await service.canUseTool(ClaudeToolNames.Glob, input, context);171172expect(mockToolsService.invokeToolCalls.length).toBe(1);173expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreConfirmationTool);174175const confirmParams = mockToolsService.invokeToolCalls[0].input as IClaudeToolConfirmationParams;176expect(confirmParams.title).toContain('Glob');177expect(confirmParams.message).toContain('test-pattern');178});179180it('uses default confirmation when no handler registered', async () => {181const input = { some: 'data' };182const context = createMockContext();183184// Use a tool that likely has no custom handler185await service.canUseTool('UnknownTool', input, context);186187expect(mockToolsService.invokeToolCalls.length).toBe(1);188const confirmParams = mockToolsService.invokeToolCalls[0].input as IClaudeToolConfirmationParams;189expect(confirmParams.title).toContain('UnknownTool');190});191});192193describe('with registered handler', () => {194it('uses handler handle method for Bash tool with terminal confirmation', async () => {195const input = { command: 'npm test' };196const context = createMockContext();197198await service.canUseTool(ClaudeToolNames.Bash, input, context);199200expect(mockToolsService.invokeToolCalls.length).toBe(1);201// Bash handler uses CoreTerminalConfirmationTool directly via its handle method202expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreTerminalConfirmationTool);203const terminalInput = mockToolsService.invokeToolCalls[0].input as { message: string; command: string; isBackground: boolean };204expect(terminalInput.command).toBe('npm test');205expect(terminalInput.isBackground).toBe(false);206});207208it('bypasses confirmation when canAutoApprove returns true', async () => {209// Register a handler that auto-approves210class AutoApproveHandler implements IClaudeToolPermissionHandler<ClaudeToolNames.NotebookEdit> {211readonly toolNames = [ClaudeToolNames.NotebookEdit] as const;212213async canAutoApprove(): Promise<boolean> {214return true;215}216}217registerToolPermissionHandler([ClaudeToolNames.NotebookEdit], AutoApproveHandler);218219// Create a new service to pick up the handler220const newService = instantiationService.createInstance(ClaudeToolPermissionService);221const input = { notebook_path: '/test.ipynb' };222const context = createMockContext();223224const result = await newService.canUseTool(ClaudeToolNames.NotebookEdit, input, context);225226expect(result.behavior).toBe('allow');227expect(mockToolsService.invokeToolCalls.length).toBe(0);228});229230it('uses full handle implementation when available', async () => {231const customResult: ClaudeToolPermissionResult = {232behavior: 'allow',233updatedInput: { modified: true }234};235236// Register a handler with full handle implementation237class FullHandler implements IClaudeToolPermissionHandler<ClaudeToolNames.KillBash> {238readonly toolNames = [ClaudeToolNames.KillBash] as const;239240async handle(): Promise<ClaudeToolPermissionResult> {241return customResult;242}243}244registerToolPermissionHandler([ClaudeToolNames.KillBash], FullHandler);245246// Create a new service to pick up the handler247const newService = instantiationService.createInstance(ClaudeToolPermissionService);248const input = { pid: 123 };249const context = createMockContext();250251const result = await newService.canUseTool(ClaudeToolNames.KillBash, input, context);252253expect(result).toEqual(customResult);254expect(mockToolsService.invokeToolCalls.length).toBe(0);255});256});257258describe('handler caching', () => {259it('caches handler instances for repeated calls', async () => {260const context = createMockContext();261262// Call twice with the same tool263await service.canUseTool(ClaudeToolNames.Bash, { command: 'ls' }, context);264mockToolsService.clearCalls();265await service.canUseTool(ClaudeToolNames.Bash, { command: 'pwd' }, context);266267// Both calls should succeed268expect(mockToolsService.invokeToolCalls.length).toBe(1);269// Bash handler uses CoreTerminalConfirmationTool directly via its handle method270expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreTerminalConfirmationTool);271const terminalInput = mockToolsService.invokeToolCalls[0].input as { message: string; command: string; isBackground: boolean };272expect(terminalInput.command).toBe('pwd');273});274});275276describe('ExitPlanMode handler', () => {277const exitPlanModeInput = { plan: 'Step 1: Do something\nStep 2: Do another thing' };278279it('allows when user clicks Approve', async () => {280mockToolsService.setOptionsConfirmationResult('Approve');281const context = createMockContext();282283const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);284285expect(result.behavior).toBe('allow');286if (result.behavior === 'allow') {287expect(result.updatedInput).toEqual(exitPlanModeInput);288}289});290291it('invokes CoreConfirmationToolWithOptions with Approve and Deny buttons', async () => {292mockToolsService.setOptionsConfirmationResult('Approve');293const context = createMockContext();294295await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);296297expect(mockToolsService.invokeToolCalls.length).toBe(1);298expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreConfirmationToolWithOptions);299const input = mockToolsService.invokeToolCalls[0].input as { title: string; message: string; buttons: string[] };300expect(input.buttons).toEqual(['Approve', 'Deny']);301expect(input.message).toContain('Step 1: Do something');302});303304it('denies when user clicks Deny', async () => {305mockToolsService.setOptionsConfirmationResult('Deny');306const context = createMockContext();307308const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);309310expect(result.behavior).toBe('deny');311if (result.behavior === 'deny') {312expect(result.message).toContain('declined');313}314});315316it('denies when dialog returns empty content', async () => {317mockToolsService.setOptionsConfirmationResult(undefined);318const context = createMockContext();319320const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, context);321322expect(result.behavior).toBe('deny');323if (result.behavior === 'deny') {324expect(result.message).toContain('declined');325}326});327328it('denies with distinct message when tool invocation throws', async () => {329const failingService = new class extends MockToolsService {330override async invokeTool(name: string): Promise<vscode.LanguageModelToolResult2> {331if (name === ToolName.CoreConfirmationToolWithOptions) {332throw new Error('Tool unavailable');333}334return { content: [] };335}336}();337338const serviceCollection = store.add(createExtensionUnitTestingServices());339serviceCollection.set(IToolsService, failingService);340const accessor = serviceCollection.createTestingAccessor();341const newService = accessor.get(IInstantiationService).createInstance(ClaudeToolPermissionService);342343const result = await newService.canUseTool(ClaudeToolNames.ExitPlanMode, exitPlanModeInput, createMockContext());344345expect(result.behavior).toBe('deny');346if (result.behavior === 'deny') {347expect(result.message).toBe('Failed to show plan confirmation');348}349});350351it('handles missing plan gracefully', async () => {352mockToolsService.setOptionsConfirmationResult('Approve');353const context = createMockContext();354355const result = await service.canUseTool(ClaudeToolNames.ExitPlanMode, {}, context);356357expect(result.behavior).toBe('allow');358const input = mockToolsService.invokeToolCalls[0].input as { message: string };359expect(input.message).toContain('');360});361});362363describe('error handling', () => {364it('denies when confirmation tool throws', async () => {365// Create a mock that throws366const failingService = new class extends MockToolsService {367override async invokeTool(): Promise<vscode.LanguageModelToolResult2> {368throw new Error('Confirmation failed');369}370}();371372const serviceCollection = store.add(createExtensionUnitTestingServices());373serviceCollection.set(IToolsService, failingService);374const accessor = serviceCollection.createTestingAccessor();375const newInstantiationService = accessor.get(IInstantiationService);376const newService = newInstantiationService.createInstance(ClaudeToolPermissionService);377378const result = await newService.canUseTool(ClaudeToolNames.Glob, {}, createMockContext());379380expect(result.behavior).toBe('deny');381});382383it('denies when confirmation returns empty content', async () => {384const emptyService = new class extends MockToolsService {385override async invokeTool(): Promise<vscode.LanguageModelToolResult2> {386return { content: [] };387}388}();389390const serviceCollection = store.add(createExtensionUnitTestingServices());391serviceCollection.set(IToolsService, emptyService);392const accessor = serviceCollection.createTestingAccessor();393const newInstantiationService = accessor.get(IInstantiationService);394const newService = newInstantiationService.createInstance(ClaudeToolPermissionService);395396const result = await newService.canUseTool(ClaudeToolNames.Glob, {}, createMockContext());397398expect(result.behavior).toBe('deny');399});400});401});402});403404405