Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/claude/node/test/askUserQuestionHandler.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 { AskUserQuestionInput } from '@anthropic-ai/claude-agent-sdk/sdk-tools';
7
import { beforeEach, describe, expect, it } from 'vitest';
8
import type * as vscode from 'vscode';
9
import { IChatEndpoint } from '../../../../../platform/networking/common/networking';
10
import { Emitter } from '../../../../../util/vs/base/common/event';
11
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
12
import { constObservable, IObservable } from '../../../../../util/vs/base/common/observableInternal';
13
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
14
import { LanguageModelTextPart } from '../../../../../vscodeTypes';
15
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
16
import { IAnswerResult } from '../../../../tools/common/askQuestionsTypes';
17
import { ToolName } from '../../../../tools/common/toolNames';
18
import { ICopilotTool } from '../../../../tools/common/toolsRegistry';
19
import { IOnWillInvokeToolEvent, IToolsService, IToolValidationResult } from '../../../../tools/common/toolsService';
20
import { ClaudeToolPermissionContext } from '../../common/claudeToolPermission';
21
import { ClaudeToolNames } from '../../common/claudeTools';
22
import { AskUserQuestionHandler } from '../../common/toolPermissionHandlers/askUserQuestionHandler';
23
24
class MockToolsService implements IToolsService {
25
readonly _serviceBrand: undefined;
26
27
private readonly _onWillInvokeTool = new Emitter<IOnWillInvokeToolEvent>();
28
readonly onWillInvokeTool = this._onWillInvokeTool.event;
29
readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation> = [];
30
readonly copilotTools = new Map<ToolName, ICopilotTool<unknown>>();
31
modelSpecificTools: IObservable<{ definition: vscode.LanguageModelToolDefinition; tool: ICopilotTool<unknown> }[]> = constObservable([]);
32
33
private _result: vscode.LanguageModelToolResult2 = { content: [] };
34
private _shouldThrow = false;
35
private _invokeToolCalls: Array<{ name: string; input: unknown }> = [];
36
37
setResult(answerResult: IAnswerResult): void {
38
this._result = {
39
content: [new LanguageModelTextPart(JSON.stringify(answerResult))]
40
};
41
}
42
43
setEmptyResult(): void {
44
this._result = { content: [] };
45
}
46
47
setShouldThrow(): void {
48
this._shouldThrow = true;
49
}
50
51
get invokeToolCalls(): ReadonlyArray<{ name: string; input: unknown }> {
52
return this._invokeToolCalls;
53
}
54
55
async invokeTool(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>): Promise<vscode.LanguageModelToolResult2> {
56
this._invokeToolCalls.push({ name, input: options.input });
57
if (this._shouldThrow) {
58
throw new Error('Tool invocation failed');
59
}
60
return this._result;
61
}
62
63
invokeToolWithEndpoint(name: string, options: vscode.LanguageModelToolInvocationOptions<unknown>, _endpoint: IChatEndpoint | undefined): Thenable<vscode.LanguageModelToolResult2> {
64
return this.invokeTool(name, options);
65
}
66
67
getCopilotTool(): ICopilotTool<unknown> | undefined { return undefined; }
68
getTool(): vscode.LanguageModelToolInformation | undefined { return undefined; }
69
getToolByToolReferenceName(): vscode.LanguageModelToolInformation | undefined { return undefined; }
70
validateToolInput(): IToolValidationResult { return { inputObj: {} }; }
71
validateToolName(): string | undefined { return undefined; }
72
getEnabledTools(): vscode.LanguageModelToolInformation[] { return []; }
73
}
74
75
function createMockContext(): ClaudeToolPermissionContext {
76
return {
77
toolInvocationToken: {} as vscode.ChatParticipantToolToken
78
};
79
}
80
81
function createInput(questions: AskUserQuestionInput['questions']): AskUserQuestionInput {
82
return { questions } as AskUserQuestionInput;
83
}
84
85
describe('AskUserQuestionHandler', () => {
86
let store: DisposableStore;
87
let mockToolsService: MockToolsService;
88
let handler: AskUserQuestionHandler;
89
90
beforeEach(() => {
91
store = new DisposableStore();
92
const serviceCollection = store.add(createExtensionUnitTestingServices());
93
94
mockToolsService = new MockToolsService();
95
serviceCollection.set(IToolsService, mockToolsService);
96
97
const accessor = serviceCollection.createTestingAccessor();
98
const instantiationService = accessor.get(IInstantiationService);
99
handler = instantiationService.createInstance(AskUserQuestionHandler);
100
});
101
102
it('invokes CoreAskQuestions tool with input', async () => {
103
const input = createInput([{
104
question: 'Which framework?',
105
header: 'Framework',
106
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
107
multiSelect: false,
108
}]);
109
110
mockToolsService.setResult({
111
answers: {
112
Framework: { selected: ['React'], freeText: null, skipped: false }
113
}
114
});
115
116
await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
117
118
expect(mockToolsService.invokeToolCalls.length).toBe(1);
119
expect(mockToolsService.invokeToolCalls[0].name).toBe(ToolName.CoreAskQuestions);
120
expect(mockToolsService.invokeToolCalls[0].input).toBe(input);
121
});
122
123
it('transforms answers from header-keyed to question-text-keyed', async () => {
124
const input = createInput([{
125
question: 'Which framework do you prefer?',
126
header: 'Framework',
127
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
128
multiSelect: false,
129
}]);
130
131
mockToolsService.setResult({
132
answers: {
133
Framework: { selected: ['React'], freeText: null, skipped: false }
134
}
135
});
136
137
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
138
139
expect(result.behavior).toBe('allow');
140
if (result.behavior === 'allow') {
141
const answers = result.updatedInput.answers as Record<string, string>;
142
expect(answers['Which framework do you prefer?']).toBe('React');
143
expect(answers['Framework']).toBeUndefined();
144
}
145
});
146
147
it('combines selected options and free text', async () => {
148
const input = createInput([{
149
question: 'What features do you want?',
150
header: 'Features',
151
options: [{ label: 'Auth', description: '' }, { label: 'DB', description: '' }],
152
multiSelect: true,
153
}]);
154
155
mockToolsService.setResult({
156
answers: {
157
Features: { selected: ['Auth', 'DB'], freeText: 'also caching', skipped: false }
158
}
159
});
160
161
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
162
163
expect(result.behavior).toBe('allow');
164
if (result.behavior === 'allow') {
165
const answers = result.updatedInput.answers as Record<string, string>;
166
expect(answers['What features do you want?']).toBe('Auth, DB, also caching');
167
}
168
});
169
170
it('excludes skipped questions from answers', async () => {
171
const input = createInput([
172
{
173
question: 'Which framework?',
174
header: 'Framework',
175
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
176
multiSelect: false,
177
},
178
{
179
question: 'Which database?',
180
header: 'Database',
181
options: [{ label: 'Postgres', description: '' }, { label: 'MySQL', description: '' }],
182
multiSelect: false,
183
},
184
]);
185
186
mockToolsService.setResult({
187
answers: {
188
Framework: { selected: ['React'], freeText: null, skipped: false },
189
Database: { selected: [], freeText: null, skipped: true }
190
}
191
});
192
193
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
194
195
expect(result.behavior).toBe('allow');
196
if (result.behavior === 'allow') {
197
const answers = result.updatedInput.answers as Record<string, string>;
198
expect(answers['Which framework?']).toBe('React');
199
expect(answers['Which database?']).toBeUndefined();
200
}
201
});
202
203
it('denies when all questions are skipped', async () => {
204
const input = createInput([{
205
question: 'Which framework?',
206
header: 'Framework',
207
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
208
multiSelect: false,
209
}]);
210
211
mockToolsService.setResult({
212
answers: {
213
Framework: { selected: [], freeText: null, skipped: true }
214
}
215
});
216
217
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
218
219
expect(result.behavior).toBe('deny');
220
if (result.behavior === 'deny') {
221
expect(result.message).toBe('The user cancelled the question');
222
}
223
});
224
225
it('denies when tool returns empty content', async () => {
226
const input = createInput([{
227
question: 'Which framework?',
228
header: 'Framework',
229
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
230
multiSelect: false,
231
}]);
232
233
mockToolsService.setEmptyResult();
234
235
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
236
237
expect(result.behavior).toBe('deny');
238
if (result.behavior === 'deny') {
239
expect(result.message).toBe('The user cancelled the question');
240
}
241
});
242
243
it('denies when tool throws', async () => {
244
const input = createInput([{
245
question: 'Which framework?',
246
header: 'Framework',
247
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
248
multiSelect: false,
249
}]);
250
251
mockToolsService.setShouldThrow();
252
253
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
254
255
expect(result.behavior).toBe('deny');
256
if (result.behavior === 'deny') {
257
expect(result.message).toBe('The user cancelled the question');
258
}
259
});
260
261
it('preserves original input in updatedInput alongside answers', async () => {
262
const input = createInput([{
263
question: 'Which framework?',
264
header: 'Framework',
265
options: [{ label: 'React', description: '' }, { label: 'Vue', description: '' }],
266
multiSelect: false,
267
}]);
268
269
mockToolsService.setResult({
270
answers: {
271
Framework: { selected: ['React'], freeText: null, skipped: false }
272
}
273
});
274
275
const result = await handler.handle(ClaudeToolNames.AskUserQuestion, input, createMockContext());
276
277
expect(result.behavior).toBe('allow');
278
if (result.behavior === 'allow') {
279
expect(result.updatedInput.questions).toBe(input.questions);
280
}
281
});
282
});
283
284