Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompts/node/panel/test/toolCalling.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 { describe, expect, test } from 'vitest';
7
import type * as vscode from 'vscode';
8
import { IChatHookService, type IPreToolUseHookResult } from '../../../../../platform/chat/common/chatHookService';
9
import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
10
import { IEndpointProvider } from '../../../../../platform/endpoint/common/endpointProvider';
11
import { DeferredPromise } from '../../../../../util/vs/base/common/async';
12
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
13
import { Event } from '../../../../../util/vs/base/common/event';
14
import { constObservable } from '../../../../../util/vs/base/common/observable';
15
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
16
import { LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResult } from '../../../../../vscodeTypes';
17
import { ChatVariablesCollection } from '../../../../prompt/common/chatVariablesCollection';
18
import type { Conversation } from '../../../../prompt/common/conversation';
19
import type { IBuildPromptContext, IToolCallRound } from '../../../../prompt/common/intents';
20
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
21
import { ToolName } from '../../../../tools/common/toolNames';
22
import { IToolsService, type IToolValidationResult } from '../../../../tools/common/toolsService';
23
import { renderPromptElement } from '../../base/promptRenderer';
24
import { ChatToolCalls } from '../toolCalling';
25
26
class CapturingChatHookService implements IChatHookService {
27
declare readonly _serviceBrand: undefined;
28
29
public lastPreToolUseCall: {
30
readonly toolName: string;
31
readonly toolInput: unknown;
32
readonly toolCallId: string;
33
readonly hooks: vscode.ChatRequestHooks | undefined;
34
readonly sessionId: string | undefined;
35
readonly token: vscode.CancellationToken | undefined;
36
} | undefined;
37
38
public postToolUseCalled = false;
39
40
constructor(
41
private readonly hookResult: IPreToolUseHookResult | undefined,
42
) { }
43
44
logConfiguredHooks(): void { }
45
46
async executeHook(): Promise<never[]> {
47
return [];
48
}
49
50
async executePreToolUseHook(
51
toolName: string,
52
toolInput: unknown,
53
toolCallId: string,
54
hooks: vscode.ChatRequestHooks | undefined,
55
sessionId?: string,
56
token?: vscode.CancellationToken,
57
): Promise<IPreToolUseHookResult | undefined> {
58
this.lastPreToolUseCall = { toolName, toolInput, toolCallId, hooks, sessionId, token };
59
return this.hookResult;
60
}
61
62
async executePostToolUseHook(): Promise<undefined> {
63
this.postToolUseCalled = true;
64
return undefined;
65
}
66
}
67
68
class CapturingToolsService implements IToolsService {
69
declare readonly _serviceBrand: undefined;
70
71
onWillInvokeTool = Event.None;
72
73
readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation>;
74
readonly copilotTools = new Map();
75
readonly modelSpecificTools = constObservable([]);
76
77
public lastInvocation: {
78
readonly name: string;
79
readonly options: vscode.LanguageModelToolInvocationOptions<unknown>;
80
readonly endpointModel: string | undefined;
81
readonly token: vscode.CancellationToken;
82
} | undefined;
83
84
public lastToolResult: vscode.LanguageModelToolResult2 | undefined;
85
86
constructor(tool: vscode.LanguageModelToolInformation) {
87
this.tools = [tool];
88
}
89
90
getCopilotTool(): undefined {
91
return undefined;
92
}
93
94
invokeTool(): Thenable<vscode.LanguageModelToolResult2> {
95
throw new Error('Not implemented in test');
96
}
97
98
async invokeToolWithEndpoint(
99
name: string,
100
options: vscode.LanguageModelToolInvocationOptions<unknown>,
101
endpoint: { model: string } | undefined,
102
token: vscode.CancellationToken,
103
): Promise<vscode.LanguageModelToolResult2> {
104
this.lastInvocation = { name, options, endpointModel: endpoint?.model, token };
105
const result = new LanguageModelToolResult([new LanguageModelTextPart('tool-ok')]);
106
this.lastToolResult = result;
107
return result;
108
}
109
110
getTool(name: string): vscode.LanguageModelToolInformation | undefined {
111
return this.tools.find(t => t.name === name);
112
}
113
114
getToolByToolReferenceName(): undefined {
115
return undefined;
116
}
117
118
validateToolInput(_name: string, input: string): IToolValidationResult {
119
return { inputObj: JSON.parse(input) };
120
}
121
122
validateToolName(): undefined {
123
return undefined;
124
}
125
126
getEnabledTools(): vscode.LanguageModelToolInformation[] {
127
return [];
128
}
129
}
130
131
class ParallelAwareToolsService implements IToolsService {
132
declare readonly _serviceBrand: undefined;
133
134
onWillInvokeTool = Event.None;
135
136
readonly tools: ReadonlyArray<vscode.LanguageModelToolInformation>;
137
readonly copilotTools = new Map();
138
readonly modelSpecificTools = constObservable([]);
139
140
public readonly startedCallIds: string[] = [];
141
private readonly pendingCalls = new Map<string, DeferredPromise<vscode.LanguageModelToolResult2>>();
142
private readonly startedWaiters: Array<{ expectedCount: number; deferred: DeferredPromise<void> }> = [];
143
144
constructor(tool: vscode.LanguageModelToolInformation) {
145
this.tools = [tool];
146
}
147
148
getCopilotTool(): undefined {
149
return undefined;
150
}
151
152
invokeTool(): Thenable<vscode.LanguageModelToolResult2> {
153
throw new Error('Not implemented in test');
154
}
155
156
invokeToolWithEndpoint(
157
_name: string,
158
options: vscode.LanguageModelToolInvocationOptions<unknown>,
159
_endpoint: { model: string } | undefined,
160
_token: vscode.CancellationToken,
161
): Promise<vscode.LanguageModelToolResult2> {
162
const callId = options.chatStreamToolCallId ?? `missing-${this.startedCallIds.length}`;
163
this.startedCallIds.push(callId);
164
this.resolveStartedWaiters();
165
const deferred = new DeferredPromise<vscode.LanguageModelToolResult2>();
166
this.pendingCalls.set(callId, deferred);
167
return deferred.p;
168
}
169
170
waitForStartedCalls(expectedCount: number): Promise<void> {
171
if (this.startedCallIds.length >= expectedCount) {
172
return Promise.resolve();
173
}
174
175
const deferred = new DeferredPromise<void>();
176
this.startedWaiters.push({ expectedCount, deferred });
177
return deferred.p;
178
}
179
180
private resolveStartedWaiters(): void {
181
for (let index = this.startedWaiters.length - 1; index >= 0; index--) {
182
const waiter = this.startedWaiters[index];
183
if (this.startedCallIds.length >= waiter.expectedCount) {
184
void waiter.deferred.complete();
185
this.startedWaiters.splice(index, 1);
186
}
187
}
188
}
189
190
resolveCall(callId: string, value = 'tool-ok'): void {
191
const pending = this.pendingCalls.get(callId);
192
if (!pending) {
193
throw new Error(`Missing pending call: ${callId}`);
194
}
195
196
void pending.complete(new LanguageModelToolResult([new LanguageModelTextPart(value)]));
197
this.pendingCalls.delete(callId);
198
}
199
200
getTool(name: string): vscode.LanguageModelToolInformation | undefined {
201
return this.tools.find(t => t.name === name);
202
}
203
204
getToolByToolReferenceName(): undefined {
205
return undefined;
206
}
207
208
validateToolInput(_name: string, input: string): IToolValidationResult {
209
return { inputObj: JSON.parse(input) };
210
}
211
212
validateToolName(): undefined {
213
return undefined;
214
}
215
216
getEnabledTools(): vscode.LanguageModelToolInformation[] {
217
return [];
218
}
219
}
220
221
describe('ChatToolCalls (toolCalling.tsx)', () => {
222
test('starts multiple sub-agent tool calls in parallel', async () => {
223
const toolName = ToolName.CoreRunSubagent;
224
const firstCallId = 'subagent-call-1';
225
const secondCallId = 'subagent-call-2';
226
227
const toolInfo: vscode.LanguageModelToolInformation = {
228
name: toolName,
229
description: 'sub-agent tool',
230
source: undefined,
231
inputSchema: undefined,
232
tags: [],
233
};
234
235
const testingServiceCollection = createExtensionUnitTestingServices();
236
const toolsService = new ParallelAwareToolsService(toolInfo);
237
testingServiceCollection.define(IToolsService, toolsService);
238
239
const accessor = testingServiceCollection.createTestingAccessor();
240
const instantiationService = accessor.get(IInstantiationService);
241
const endpointProvider = accessor.get(IEndpointProvider);
242
const endpoint = await endpointProvider.getChatEndpoint('copilot-base');
243
244
const round: IToolCallRound = {
245
id: 'round-1',
246
response: 'calling sub-agents',
247
toolInputRetry: 0,
248
toolCalls: [
249
{ name: toolName, arguments: JSON.stringify({ query: 'one' }), id: firstCallId },
250
{ name: toolName, arguments: JSON.stringify({ query: 'two' }), id: secondCallId },
251
],
252
};
253
254
const promptContext: IBuildPromptContext = {
255
query: 'test',
256
history: [],
257
chatVariables: new ChatVariablesCollection(),
258
conversation: { sessionId: 'session-123' } as unknown as Conversation,
259
request: {} as vscode.ChatRequest,
260
tools: {
261
toolReferences: [],
262
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
263
availableTools: [toolInfo],
264
},
265
};
266
267
const renderPromise = renderPromptElement(instantiationService, endpoint, ChatToolCalls, {
268
promptContext,
269
toolCallRounds: [round],
270
toolCallResults: undefined,
271
});
272
273
await toolsService.waitForStartedCalls(2);
274
275
expect(toolsService.startedCallIds).toEqual([firstCallId, secondCallId]);
276
277
toolsService.resolveCall(firstCallId);
278
toolsService.resolveCall(secondCallId);
279
280
await renderPromise;
281
});
282
283
test('calls preToolUse hook with validated input and respects hook output', async () => {
284
const toolName = 'myTool';
285
const toolArgs = JSON.stringify({ x: 1 });
286
const toolCallId = 'call-1';
287
const hookContext = 'extra policy context';
288
289
const updatedInput = { x: 2, safe: true };
290
const hooks: vscode.ChatRequestHooks = { PreToolUse: [] };
291
292
const hookResult: IPreToolUseHookResult = {
293
permissionDecision: 'ask',
294
permissionDecisionReason: 'Needs confirmation',
295
updatedInput,
296
additionalContext: [hookContext],
297
};
298
299
const toolInfo: vscode.LanguageModelToolInformation = {
300
name: toolName,
301
description: 'test tool',
302
source: undefined,
303
inputSchema: undefined,
304
tags: [],
305
};
306
307
const testingServiceCollection = createExtensionUnitTestingServices();
308
const toolsService = new CapturingToolsService(toolInfo);
309
const hookService = new CapturingChatHookService(hookResult);
310
testingServiceCollection.define(IToolsService, toolsService);
311
testingServiceCollection.define(IChatHookService, hookService);
312
313
const accessor = testingServiceCollection.createTestingAccessor();
314
const instantiationService = accessor.get(IInstantiationService);
315
const endpointProvider = accessor.get(IEndpointProvider);
316
const endpoint = await endpointProvider.getChatEndpoint('copilot-base');
317
318
const round: IToolCallRound = {
319
id: 'round-1',
320
response: 'calling tool',
321
toolInputRetry: 0,
322
toolCalls: [{ name: toolName, arguments: toolArgs, id: toolCallId }],
323
};
324
325
const conversation = { sessionId: 'session-123' } as unknown as Conversation;
326
const promptContext: IBuildPromptContext = {
327
query: 'test',
328
history: [],
329
chatVariables: new ChatVariablesCollection(),
330
conversation,
331
request: { hooks } as unknown as vscode.ChatRequest,
332
tools: {
333
toolReferences: [],
334
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
335
availableTools: [toolInfo],
336
},
337
};
338
339
await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {
340
promptContext,
341
toolCallRounds: [round],
342
toolCallResults: undefined,
343
});
344
345
// Hook called with validated (original) input
346
expect(hookService.lastPreToolUseCall).toEqual({
347
toolName,
348
toolInput: { x: 1 },
349
toolCallId,
350
hooks,
351
sessionId: 'session-123',
352
token: CancellationToken.None,
353
});
354
355
// Tool invoked with updatedInput from hook
356
expect(toolsService.lastInvocation?.name).toBe(toolName);
357
expect(toolsService.lastInvocation?.options.input).toEqual(updatedInput);
358
expect(toolsService.lastInvocation?.options.preToolUseResult).toEqual({
359
permissionDecision: 'ask',
360
permissionDecisionReason: 'Needs confirmation',
361
updatedInput,
362
});
363
364
// Hook additionalContext is appended to the tool result content
365
const contentText = (toolsService.lastToolResult?.content ?? [])
366
.filter((p): p is LanguageModelTextPart => p instanceof LanguageModelTextPart)
367
.map(p => p.value)
368
.join('\n');
369
expect(contentText).toContain('<PreToolUse-context>');
370
expect(contentText).toContain(hookContext);
371
});
372
373
test('skips postToolUse hook when preToolUse denies the tool but still appends preToolUse context', async () => {
374
const toolName = 'blockedTool';
375
const toolArgs = JSON.stringify({ cmd: 'dangerous' });
376
const toolCallId = 'call-denied';
377
const denyContext = 'This tool was blocked by policy';
378
379
const hookResult: IPreToolUseHookResult = {
380
permissionDecision: 'deny',
381
permissionDecisionReason: 'Blocked by security policy',
382
additionalContext: [denyContext],
383
};
384
385
const toolInfo: vscode.LanguageModelToolInformation = {
386
name: toolName,
387
description: 'blocked tool',
388
source: undefined,
389
inputSchema: undefined,
390
tags: [],
391
};
392
393
const testingServiceCollection = createExtensionUnitTestingServices();
394
const toolsService = new CapturingToolsService(toolInfo);
395
const hookService = new CapturingChatHookService(hookResult);
396
testingServiceCollection.define(IToolsService, toolsService);
397
testingServiceCollection.define(IChatHookService, hookService);
398
399
const accessor = testingServiceCollection.createTestingAccessor();
400
const instantiationService = accessor.get(IInstantiationService);
401
const endpointProvider = accessor.get(IEndpointProvider);
402
const endpoint = await endpointProvider.getChatEndpoint('copilot-base');
403
404
const round: IToolCallRound = {
405
id: 'round-1',
406
response: 'calling tool',
407
toolInputRetry: 0,
408
toolCalls: [{ name: toolName, arguments: toolArgs, id: toolCallId }],
409
};
410
411
const hooks: vscode.ChatRequestHooks = { PreToolUse: [] };
412
const promptContext: IBuildPromptContext = {
413
query: 'test',
414
history: [],
415
chatVariables: new ChatVariablesCollection(),
416
conversation: { sessionId: 'session-deny' } as unknown as Conversation,
417
request: { hooks } as unknown as vscode.ChatRequest,
418
tools: {
419
toolReferences: [],
420
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
421
availableTools: [toolInfo],
422
},
423
};
424
425
await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {
426
promptContext,
427
toolCallRounds: [round],
428
toolCallResults: undefined,
429
});
430
431
// PreToolUse hook was called
432
expect(hookService.lastPreToolUseCall).toBeDefined();
433
434
// PostToolUse hook should NOT have been called since PreToolUse denied the tool
435
expect(hookService.postToolUseCalled).toBe(false);
436
437
// The tool is still invoked with the deny preToolUseResult passed through
438
expect(toolsService.lastInvocation).toBeDefined();
439
expect(toolsService.lastInvocation?.name).toBe('blockedTool');
440
expect(toolsService.lastInvocation?.options.preToolUseResult).toEqual({
441
permissionDecision: 'deny',
442
permissionDecisionReason: 'Blocked by security policy',
443
updatedInput: undefined,
444
});
445
// PreToolUse context should still be appended to the tool result
446
const contentText = (toolsService.lastToolResult?.content ?? [])
447
.filter((p): p is LanguageModelTextPart => p instanceof LanguageModelTextPart)
448
.map(p => p.value)
449
.join('\n');
450
expect(contentText).toContain('<PreToolUse-context>');
451
expect(contentText).toContain(denyContext);
452
expect(contentText).not.toContain('<PostToolUse-context>');
453
});
454
455
test('replaces images with placeholders for historical turns', async () => {
456
const toolName = 'viewImage';
457
const toolCallId = 'call-img-1';
458
459
const toolInfo: vscode.LanguageModelToolInformation = {
460
name: toolName,
461
description: 'view image tool',
462
source: undefined,
463
inputSchema: undefined,
464
tags: [],
465
};
466
467
const testingServiceCollection = createExtensionUnitTestingServices();
468
const toolsService = new CapturingToolsService(toolInfo);
469
testingServiceCollection.define(IToolsService, toolsService);
470
471
const accessor = testingServiceCollection.createTestingAccessor();
472
const instantiationService = accessor.get(IInstantiationService);
473
const endpointProvider = accessor.get(IEndpointProvider);
474
const endpoint = await endpointProvider.getChatEndpoint('copilot-base');
475
476
const imageData = new Uint8Array(1024);
477
const toolCallResults: Record<string, vscode.LanguageModelToolResult> = {
478
[toolCallId]: new LanguageModelToolResult([
479
new LanguageModelTextPart('some text result'),
480
LanguageModelDataPart.image(imageData, 'image/png'),
481
]),
482
};
483
484
const round: IToolCallRound = {
485
id: 'round-1',
486
response: 'viewing image',
487
toolInputRetry: 0,
488
toolCalls: [{ name: toolName, arguments: '{}', id: toolCallId }],
489
};
490
491
const promptContext: IBuildPromptContext = {
492
query: 'test',
493
history: [],
494
chatVariables: new ChatVariablesCollection(),
495
conversation: { sessionId: 'session-img' } as unknown as Conversation,
496
request: {} as vscode.ChatRequest,
497
tools: {
498
toolReferences: [],
499
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
500
availableTools: [toolInfo],
501
},
502
};
503
504
const { messages } = await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {
505
promptContext,
506
toolCallRounds: [round],
507
toolCallResults,
508
isHistorical: true,
509
});
510
511
const serialized = JSON.stringify(messages);
512
expect(serialized).toContain('Image was previously shown to you');
513
expect(serialized).toContain('some text result');
514
// Should not contain base64 image data
515
expect(serialized).not.toContain('image_url');
516
});
517
518
test('enforces shared image budget across tool results', async () => {
519
const toolName = 'viewImage';
520
const firstCallId = 'call-big-1';
521
const secondCallId = 'call-big-2';
522
523
const toolInfo: vscode.LanguageModelToolInformation = {
524
name: toolName,
525
description: 'view image tool',
526
source: undefined,
527
inputSchema: undefined,
528
tags: [],
529
};
530
531
const testingServiceCollection = createExtensionUnitTestingServices();
532
const toolsService = new CapturingToolsService(toolInfo);
533
testingServiceCollection.define(IToolsService, toolsService);
534
535
const accessor = testingServiceCollection.createTestingAccessor();
536
const instantiationService = accessor.get(IInstantiationService);
537
const endpointProvider = accessor.get(IEndpointProvider);
538
const endpoint = await endpointProvider.getChatEndpoint('copilot-base');
539
540
// Disable image uploads so images go through the base64 path where the budget applies
541
const configService = accessor.get(IConfigurationService);
542
await configService.setConfig(ConfigKey.EnableChatImageUpload, false);
543
544
// Each image is 3MB — individually exceeds the 2.5MB shared budget (half of 5MB CAPI limit)
545
const bigImage = new Uint8Array(3 * 1024 * 1024);
546
const toolCallResults: Record<string, vscode.LanguageModelToolResult> = {
547
[firstCallId]: new LanguageModelToolResult([
548
LanguageModelDataPart.image(bigImage, 'image/png'),
549
]),
550
[secondCallId]: new LanguageModelToolResult([
551
LanguageModelDataPart.image(bigImage, 'image/png'),
552
]),
553
};
554
555
const round: IToolCallRound = {
556
id: 'round-1',
557
response: 'viewing images',
558
toolInputRetry: 0,
559
toolCalls: [
560
{ name: toolName, arguments: '{}', id: firstCallId },
561
{ name: toolName, arguments: '{}', id: secondCallId },
562
],
563
};
564
565
const promptContext: IBuildPromptContext = {
566
query: 'test',
567
history: [],
568
chatVariables: new ChatVariablesCollection(),
569
conversation: { sessionId: 'session-budget' } as unknown as Conversation,
570
request: {} as vscode.ChatRequest,
571
tools: {
572
toolReferences: [],
573
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
574
availableTools: [toolInfo],
575
},
576
};
577
578
const { messages } = await renderPromptElement(instantiationService, endpoint, ChatToolCalls, {
579
promptContext,
580
toolCallRounds: [round],
581
toolCallResults,
582
});
583
584
const serialized = JSON.stringify(messages);
585
// Both images exceed the 2.5MB shared budget and should be replaced with placeholders
586
expect(serialized).toContain('context image budget exceeded');
587
expect(serialized).not.toContain('image_url');
588
});
589
});
590
591