Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/askUserQuestionHandler.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 { AskUserQuestionInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools';6import { beforeEach, describe, expect, it } from 'vitest';7import type * as vscode from 'vscode';8import { IChatEndpoint } from '../../../../../platform/networking/common/networking';9import { Emitter } from '../../../../../util/vs/base/common/event';10import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';11import { constObservable, IObservable } from '../../../../../util/vs/base/common/observableInternal';12import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';13import { LanguageModelTextPart } from '../../../../../vscodeTypes';14import { createExtensionUnitTestingServices } from '../../../../test/node/services';15import { IAnswerResult } from '../../../../tools/common/askQuestionsTypes';16import { ToolName } from '../../../../tools/common/toolNames';17import { ICopilotTool } from '../../../../tools/common/toolsRegistry';18import { IOnWillInvokeToolEvent, IToolsService, IToolValidationResult } from '../../../../tools/common/toolsService';19import { ClaudeToolPermissionContext } from '../../common/claudeToolPermission';20import { ClaudeToolNames } from '../../common/claudeTools';21import { AskUserQuestionHandler } from '../../common/toolPermissionHandlers/askUserQuestionHandler';2223class MockToolsService implements IToolsService {24readonly _serviceBrand: undefined;2526private readonly _onWillInvokeTool = new Emitter<IOnWillInvokeToolEvent>();27readonly onWillInvokeTool = this._onWillInvokeTool.event;28readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation> = [];29readonly copilotTools = new Map<ToolName, ICopilotTool<unknown>>();30modelSpecificTools: IObservable<{ definition: vscode.LanguageModelToolDefinition; tool: ICopilotTool<unknown> }[]> = constObservable([]);3132private _result: vscode.LanguageModelToolResult2 = { content: [] };33private _shouldThrow = false;34private _invokeToolCalls: Array<{ name: string; input: unknown }> = [];3536setResult(answerResult: IAnswerResult): void {37this._result = {38content: [new LanguageModelTextPart(JSON.stringify(answerResult))]39};40}4142setEmptyResult(): void {43this._result = { content: [] };44}4546setShouldThrow(): void {47this._shouldThrow = true;48}4950get invokeToolCalls(): ReadonlyArray<{ name: string; input: unknown }> {51return this._invokeToolCalls;52}5354async invokeTool(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>): Promise<vscode.LanguageModelToolResult2> {55this._invokeToolCalls.push({ name, input: options.input });56if (this._shouldThrow) {57throw new Error('Tool invocation failed');58}59return this._result;60}6162invokeToolWithEndpoint(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>, _endpoint: IChatEndpoint | undefined): Thenable<vscode.LanguageModelToolResult2> {63return this.invokeTool(name, options);64}6566getCopilotTool(): ICopilotTool<unknown> | undefined { return undefined; }67getTool(): vscode.LanguageModelToolInformation | undefined { return undefined; }68getToolByToolReferenceName(): vscode.LanguageModelToolInformation | undefined { return undefined; }69validateToolInput(): IToolValidationResult { return { inputObj: {} }; }70validateToolName(): string | undefined { return undefined; }71getEnabledTools(): vscode.LanguageModelToolInformation[] { return []; }72}7374function createMockContext(): ClaudeToolPermissionContext {75return {76toolInvocationToken: {} as vscode.ChatParticipantToolToken77};78}7980function createInput(questions: AskUserQuestionInput['questions']): AskUserQuestionInput {81return { questions } as AskUserQuestionInput;82}8384describe('AskUserQuestionHandler', () => {85let store: DisposableStore;86let mockToolsService: MockToolsService;87let handler: AskUserQuestionHandler;8889beforeEach(() => {90store = new DisposableStore();91const serviceCollection = store.add(createExtensionUnitTestingServices());9293mockToolsService = new MockToolsService();94serviceCollection.set(IToolsService, mockToolsService);9596const accessor = serviceCollection.createTestingAccessor();97const instantiationService = accessor.get(IInstantiationService);98handler = instantiationService.createInstance(AskUserQuestionHandler);99});100101it('invokes CoreAskQuestions tool with input', async () => {102const input = createInput([{103question: 'Which framework?',104header: 'Framework',105options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],106multiSelect: false,107}]);108109mockToolsService.setResult({110answers: {111Framework: { selected: ['React'], freeText: null, skipped: false }112}113});114115await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());116117expect(mockToolsService.invokeToolCalls.length).toBe(1);118expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreAskQuestions);119expect(mockToolsService.invokeToolCalls[0].input).toBe(input);120});121122it('transforms answers from header-keyed to question-text-keyed', async () => {123const input = createInput([{124question: 'Which framework do you prefer?',125header: 'Framework',126options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],127multiSelect: false,128}]);129130mockToolsService.setResult({131answers: {132Framework: { selected: ['React'], freeText: null, skipped: false }133}134});135136const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());137138expect(result.behavior).toBe('allow');139if (result.behavior === 'allow') {140const answers = result.updatedInput.answers as Record<string, string>;141expect(answers['Which framework do you prefer?']).toBe('React');142expect(answers['Framework']).toBeUndefined();143}144});145146it('combines selected options and free text', async () => {147const input = createInput([{148question: 'What features do you want?',149header: 'Features',150options: [{ label: 'Auth', description: '' }, { label: 'DB', description: '' }],151multiSelect: true,152}]);153154mockToolsService.setResult({155answers: {156Features: { selected: ['Auth', 'DB'], freeText: 'also caching', skipped: false }157}158});159160const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());161162expect(result.behavior).toBe('allow');163if (result.behavior === 'allow') {164const answers = result.updatedInput.answers as Record<string, string>;165expect(answers['What features do you want?']).toBe('Auth, DB, also caching');166}167});168169it('excludes skipped questions from answers', async () => {170const input = createInput([171{172question: 'Which framework?',173header: 'Framework',174options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],175multiSelect: false,176},177{178question: 'Which database?',179header: 'Database',180options: [{ label: 'Postgres', description: '' }, { label: 'MySQL', description: '' }],181multiSelect: false,182},183]);184185mockToolsService.setResult({186answers: {187Framework: { selected: ['React'], freeText: null, skipped: false },188Database: { selected: [], freeText: null, skipped: true }189}190});191192const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());193194expect(result.behavior).toBe('allow');195if (result.behavior === 'allow') {196const answers = result.updatedInput.answers as Record<string, string>;197expect(answers['Which framework?']).toBe('React');198expect(answers['Which database?']).toBeUndefined();199}200});201202it('denies when all questions are skipped', async () => {203const input = createInput([{204question: 'Which framework?',205header: 'Framework',206options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],207multiSelect: false,208}]);209210mockToolsService.setResult({211answers: {212Framework: { selected: [], freeText: null, skipped: true }213}214});215216const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());217218expect(result.behavior).toBe('deny');219if (result.behavior === 'deny') {220expect(result.message).toBe('The user cancelled the question');221}222});223224it('denies when tool returns empty content', async () => {225const input = createInput([{226question: 'Which framework?',227header: 'Framework',228options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],229multiSelect: false,230}]);231232mockToolsService.setEmptyResult();233234const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());235236expect(result.behavior).toBe('deny');237if (result.behavior === 'deny') {238expect(result.message).toBe('The user cancelled the question');239}240});241242it('denies when tool throws', async () => {243const input = createInput([{244question: 'Which framework?',245header: 'Framework',246options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],247multiSelect: false,248}]);249250mockToolsService.setShouldThrow();251252const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());253254expect(result.behavior).toBe('deny');255if (result.behavior === 'deny') {256expect(result.message).toBe('The user cancelled the question');257}258});259260it('preserves original input in updatedInput alongside answers', async () => {261const input = createInput([{262question: 'Which framework?',263header: 'Framework',264options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],265multiSelect: false,266}]);267268mockToolsService.setResult({269answers: {270Framework: { selected: ['React'], freeText: null, skipped: false }271}272});273274const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());275276expect(result.behavior).toBe('allow');277if (result.behavior === 'allow') {278expect(result.updatedInput.questions).toBe(input.questions);279}280});281});282283284