Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/browser/languageModelToolsService.test.ts
3296 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 * as assert from 'assert';
7
import { Barrier } from '../../../../../base/common/async.js';
8
import { VSBuffer } from '../../../../../base/common/buffer.js';
9
import { CancellationToken } from '../../../../../base/common/cancellation.js';
10
import { CancellationError, isCancellationError } from '../../../../../base/common/errors.js';
11
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
12
import { IAccessibilityService } from '../../../../../platform/accessibility/common/accessibility.js';
13
import { TestAccessibilityService } from '../../../../../platform/accessibility/test/common/testAccessibilityService.js';
14
import { AccessibilitySignal, IAccessibilitySignalService } from '../../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js';
15
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
16
import { ContextKeyService } from '../../../../../platform/contextkey/browser/contextKeyService.js';
17
import { ContextKeyEqualsExpr, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js';
18
import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js';
19
import { workbenchInstantiationService } from '../../../../test/browser/workbenchTestServices.js';
20
import { LanguageModelToolsService } from '../../browser/languageModelToolsService.js';
21
import { IChatModel } from '../../common/chatModel.js';
22
import { IChatService, IChatToolInputInvocationData } from '../../common/chatService.js';
23
import { IToolData, IToolImpl, IToolInvocation, ToolDataSource } from '../../common/languageModelToolsService.js';
24
import { MockChatService } from '../common/mockChatService.js';
25
import { IConfigurationChangeEvent } from '../../../../../platform/configuration/common/configuration.js';
26
27
// --- Test helpers to reduce repetition and improve readability ---
28
29
class TestAccessibilitySignalService implements Partial<IAccessibilitySignalService> {
30
public signalPlayedCalls: { signal: AccessibilitySignal; options?: any }[] = [];
31
32
async playSignal(signal: AccessibilitySignal, options?: any): Promise<void> {
33
this.signalPlayedCalls.push({ signal, options });
34
}
35
36
reset() {
37
this.signalPlayedCalls = [];
38
}
39
}
40
41
class TestTelemetryService implements Partial<ITelemetryService> {
42
public events: Array<{ eventName: string; data: any }> = [];
43
44
publicLog2<E extends Record<string, any>, T extends Record<string, any>>(eventName: string, data?: E): void {
45
this.events.push({ eventName, data });
46
}
47
48
reset() {
49
this.events = [];
50
}
51
}
52
53
function registerToolForTest(service: LanguageModelToolsService, store: any, id: string, impl: IToolImpl, data?: Partial<IToolData>) {
54
const toolData: IToolData = {
55
id,
56
modelDescription: data?.modelDescription ?? 'Test Tool',
57
displayName: data?.displayName ?? 'Test Tool',
58
source: ToolDataSource.Internal,
59
...data,
60
};
61
store.add(service.registerTool(toolData, impl));
62
return {
63
id,
64
makeDto: (parameters: any, context?: { sessionId: string }, callId: string = '1'): IToolInvocation => ({
65
callId,
66
toolId: id,
67
tokenBudget: 100,
68
parameters,
69
context,
70
}),
71
};
72
}
73
74
function stubGetSession(chatService: MockChatService, sessionId: string, options?: { requestId?: string; capture?: { invocation?: any } }): IChatModel {
75
const requestId = options?.requestId ?? 'requestId';
76
const capture = options?.capture;
77
const fakeModel = {
78
sessionId,
79
getRequests: () => [{ id: requestId, modelId: 'test-model' }],
80
acceptResponseProgress: (_req: any, progress: any) => { if (capture) { capture.invocation = progress; } },
81
} as IChatModel;
82
chatService.addSession(fakeModel);
83
return fakeModel;
84
}
85
86
async function waitForPublishedInvocation(capture: { invocation?: any }, tries = 5): Promise<any> {
87
for (let i = 0; i < tries && !capture.invocation; i++) {
88
await Promise.resolve();
89
}
90
return capture.invocation;
91
}
92
93
suite('LanguageModelToolsService', () => {
94
const store = ensureNoDisposablesAreLeakedInTestSuite();
95
96
let contextKeyService: IContextKeyService;
97
let service: LanguageModelToolsService;
98
let chatService: MockChatService;
99
let configurationService: TestConfigurationService;
100
101
setup(() => {
102
configurationService = new TestConfigurationService();
103
const instaService = workbenchInstantiationService({
104
contextKeyService: () => store.add(new ContextKeyService(configurationService)),
105
configurationService: () => configurationService
106
}, store);
107
contextKeyService = instaService.get(IContextKeyService);
108
chatService = new MockChatService();
109
instaService.stub(IChatService, chatService);
110
service = store.add(instaService.createInstance(LanguageModelToolsService));
111
});
112
113
test('registerToolData', () => {
114
const toolData: IToolData = {
115
id: 'testTool',
116
modelDescription: 'Test Tool',
117
displayName: 'Test Tool',
118
source: ToolDataSource.Internal,
119
};
120
121
const disposable = service.registerToolData(toolData);
122
assert.strictEqual(service.getTool('testTool')?.id, 'testTool');
123
disposable.dispose();
124
assert.strictEqual(service.getTool('testTool'), undefined);
125
});
126
127
test('registerToolImplementation', () => {
128
const toolData: IToolData = {
129
id: 'testTool',
130
modelDescription: 'Test Tool',
131
displayName: 'Test Tool',
132
source: ToolDataSource.Internal,
133
};
134
135
store.add(service.registerToolData(toolData));
136
137
const toolImpl: IToolImpl = {
138
invoke: async () => ({ content: [{ kind: 'text', value: 'result' }] }),
139
};
140
141
store.add(service.registerToolImplementation('testTool', toolImpl));
142
assert.strictEqual(service.getTool('testTool')?.id, 'testTool');
143
});
144
145
test('getTools', () => {
146
contextKeyService.createKey('testKey', true);
147
const toolData1: IToolData = {
148
id: 'testTool1',
149
modelDescription: 'Test Tool 1',
150
when: ContextKeyEqualsExpr.create('testKey', false),
151
displayName: 'Test Tool',
152
source: ToolDataSource.Internal,
153
};
154
155
const toolData2: IToolData = {
156
id: 'testTool2',
157
modelDescription: 'Test Tool 2',
158
when: ContextKeyEqualsExpr.create('testKey', true),
159
displayName: 'Test Tool',
160
source: ToolDataSource.Internal,
161
};
162
163
const toolData3: IToolData = {
164
id: 'testTool3',
165
modelDescription: 'Test Tool 3',
166
displayName: 'Test Tool',
167
source: ToolDataSource.Internal,
168
};
169
170
store.add(service.registerToolData(toolData1));
171
store.add(service.registerToolData(toolData2));
172
store.add(service.registerToolData(toolData3));
173
174
const tools = Array.from(service.getTools());
175
assert.strictEqual(tools.length, 2);
176
assert.strictEqual(tools[0].id, 'testTool2');
177
assert.strictEqual(tools[1].id, 'testTool3');
178
});
179
180
test('getToolByName', () => {
181
contextKeyService.createKey('testKey', true);
182
const toolData1: IToolData = {
183
id: 'testTool1',
184
toolReferenceName: 'testTool1',
185
modelDescription: 'Test Tool 1',
186
when: ContextKeyEqualsExpr.create('testKey', false),
187
displayName: 'Test Tool',
188
source: ToolDataSource.Internal,
189
};
190
191
const toolData2: IToolData = {
192
id: 'testTool2',
193
toolReferenceName: 'testTool2',
194
modelDescription: 'Test Tool 2',
195
when: ContextKeyEqualsExpr.create('testKey', true),
196
displayName: 'Test Tool',
197
source: ToolDataSource.Internal,
198
};
199
200
const toolData3: IToolData = {
201
id: 'testTool3',
202
toolReferenceName: 'testTool3',
203
modelDescription: 'Test Tool 3',
204
displayName: 'Test Tool',
205
source: ToolDataSource.Internal,
206
};
207
208
store.add(service.registerToolData(toolData1));
209
store.add(service.registerToolData(toolData2));
210
store.add(service.registerToolData(toolData3));
211
212
assert.strictEqual(service.getToolByName('testTool1'), undefined);
213
assert.strictEqual(service.getToolByName('testTool1', true)?.id, 'testTool1');
214
assert.strictEqual(service.getToolByName('testTool2')?.id, 'testTool2');
215
assert.strictEqual(service.getToolByName('testTool3')?.id, 'testTool3');
216
});
217
218
test('invokeTool', async () => {
219
const toolData: IToolData = {
220
id: 'testTool',
221
modelDescription: 'Test Tool',
222
displayName: 'Test Tool',
223
source: ToolDataSource.Internal,
224
};
225
226
store.add(service.registerToolData(toolData));
227
228
const toolImpl: IToolImpl = {
229
invoke: async (invocation) => {
230
assert.strictEqual(invocation.callId, '1');
231
assert.strictEqual(invocation.toolId, 'testTool');
232
assert.deepStrictEqual(invocation.parameters, { a: 1 });
233
return { content: [{ kind: 'text', value: 'result' }] };
234
}
235
};
236
237
store.add(service.registerToolImplementation('testTool', toolImpl));
238
239
const dto: IToolInvocation = {
240
callId: '1',
241
toolId: 'testTool',
242
tokenBudget: 100,
243
parameters: {
244
a: 1
245
},
246
context: undefined,
247
};
248
249
const result = await service.invokeTool(dto, async () => 0, CancellationToken.None);
250
assert.strictEqual(result.content[0].value, 'result');
251
});
252
253
test('invocation parameters are overridden by input toolSpecificData', async () => {
254
const rawInput = { b: 2 };
255
const tool = registerToolForTest(service, store, 'testToolInputOverride', {
256
prepareToolInvocation: async () => ({
257
toolSpecificData: { kind: 'input', rawInput } satisfies IChatToolInputInvocationData,
258
confirmationMessages: {
259
title: 'a',
260
message: 'b',
261
}
262
}),
263
invoke: async (invocation) => {
264
// The service should replace parameters with rawInput and strip toolSpecificData
265
assert.deepStrictEqual(invocation.parameters, rawInput);
266
assert.strictEqual(invocation.toolSpecificData, undefined);
267
return { content: [{ kind: 'text', value: 'ok' }] };
268
},
269
});
270
271
const sessionId = 'sessionId';
272
const capture: { invocation?: any } = {};
273
stubGetSession(chatService, sessionId, { requestId: 'requestId-io', capture });
274
const dto = tool.makeDto({ a: 1 }, { sessionId });
275
276
const invokeP = service.invokeTool(dto, async () => 0, CancellationToken.None);
277
const published = await waitForPublishedInvocation(capture);
278
published.confirmed.complete(true);
279
const result = await invokeP;
280
assert.strictEqual(result.content[0].value, 'ok');
281
});
282
283
test('chat invocation injects input toolSpecificData for confirmation when alwaysDisplayInputOutput', async () => {
284
const toolData: IToolData = {
285
id: 'testToolDisplayIO',
286
modelDescription: 'Test Tool',
287
displayName: 'Test Tool',
288
source: ToolDataSource.Internal,
289
alwaysDisplayInputOutput: true,
290
};
291
292
const tool = registerToolForTest(service, store, 'testToolDisplayIO', {
293
prepareToolInvocation: async () => ({
294
confirmationMessages: { title: 'Confirm', message: 'Proceed?' }
295
}),
296
invoke: async () => ({ content: [{ kind: 'text', value: 'done' }] }),
297
}, toolData);
298
299
const sessionId = 'sessionId-io';
300
const capture: { invocation?: any } = {};
301
stubGetSession(chatService, sessionId, { requestId: 'requestId-io', capture });
302
303
const dto = tool.makeDto({ a: 1 }, { sessionId });
304
305
const invokeP = service.invokeTool(dto, async () => 0, CancellationToken.None);
306
const published = await waitForPublishedInvocation(capture);
307
assert.ok(published, 'expected ChatToolInvocation to be published');
308
assert.strictEqual(published.toolId, tool.id);
309
// The service should have injected input toolSpecificData with the raw parameters
310
assert.strictEqual(published.toolSpecificData?.kind, 'input');
311
assert.deepStrictEqual(published.toolSpecificData?.rawInput, dto.parameters);
312
313
// Confirm to let invoke proceed
314
published.confirmed.complete(true);
315
const result = await invokeP;
316
assert.strictEqual(result.content[0].value, 'done');
317
});
318
319
test('chat invocation waits for user confirmation before invoking', async () => {
320
const toolData: IToolData = {
321
id: 'testToolConfirm',
322
modelDescription: 'Test Tool',
323
displayName: 'Test Tool',
324
source: ToolDataSource.Internal,
325
};
326
327
let invoked = false;
328
const tool = registerToolForTest(service, store, toolData.id, {
329
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Confirm', message: 'Go?' } }),
330
invoke: async () => {
331
invoked = true;
332
return { content: [{ kind: 'text', value: 'ran' }] };
333
},
334
}, toolData);
335
336
const sessionId = 'sessionId-confirm';
337
const capture: { invocation?: any } = {};
338
stubGetSession(chatService, sessionId, { requestId: 'requestId-confirm', capture });
339
340
const dto = tool.makeDto({ x: 1 }, { sessionId });
341
342
const promise = service.invokeTool(dto, async () => 0, CancellationToken.None);
343
const published = await waitForPublishedInvocation(capture);
344
assert.ok(published, 'expected ChatToolInvocation to be published');
345
assert.strictEqual(invoked, false, 'invoke should not run before confirmation');
346
347
published.confirmed.complete(true);
348
const result = await promise;
349
assert.strictEqual(invoked, true, 'invoke should have run after confirmation');
350
assert.strictEqual(result.content[0].value, 'ran');
351
});
352
353
test('cancel tool call', async () => {
354
const toolBarrier = new Barrier();
355
const tool = registerToolForTest(service, store, 'testTool', {
356
invoke: async (invocation, countTokens, progress, cancelToken) => {
357
assert.strictEqual(invocation.callId, '1');
358
assert.strictEqual(invocation.toolId, 'testTool');
359
assert.deepStrictEqual(invocation.parameters, { a: 1 });
360
await toolBarrier.wait();
361
if (cancelToken.isCancellationRequested) {
362
throw new CancellationError();
363
} else {
364
throw new Error('Tool call should be cancelled');
365
}
366
}
367
});
368
369
const sessionId = 'sessionId';
370
const requestId = 'requestId';
371
const dto = tool.makeDto({ a: 1 }, { sessionId });
372
stubGetSession(chatService, sessionId, { requestId });
373
const toolPromise = service.invokeTool(dto, async () => 0, CancellationToken.None);
374
service.cancelToolCallsForRequest(requestId);
375
toolBarrier.open();
376
await assert.rejects(toolPromise, err => {
377
return isCancellationError(err);
378
}, 'Expected tool call to be cancelled');
379
});
380
381
test('toToolEnablementMap', () => {
382
const toolData1: IToolData = {
383
id: 'tool1',
384
toolReferenceName: 'refTool1',
385
modelDescription: 'Test Tool 1',
386
displayName: 'Test Tool 1',
387
source: ToolDataSource.Internal,
388
};
389
390
const toolData2: IToolData = {
391
id: 'tool2',
392
toolReferenceName: 'refTool2',
393
modelDescription: 'Test Tool 2',
394
displayName: 'Test Tool 2',
395
source: ToolDataSource.Internal,
396
};
397
398
const toolData3: IToolData = {
399
id: 'tool3',
400
// No toolReferenceName
401
modelDescription: 'Test Tool 3',
402
displayName: 'Test Tool 3',
403
source: ToolDataSource.Internal,
404
};
405
406
store.add(service.registerToolData(toolData1));
407
store.add(service.registerToolData(toolData2));
408
store.add(service.registerToolData(toolData3));
409
410
// Test with enabled tools
411
const enabledToolNames = new Set(['refTool1']);
412
const result1 = service.toToolEnablementMap(enabledToolNames);
413
414
assert.strictEqual(result1['tool1'], true, 'tool1 should be enabled');
415
assert.strictEqual(result1['tool2'], false, 'tool2 should be disabled');
416
assert.strictEqual(result1['tool3'], false, 'tool3 should be disabled (no reference name)');
417
418
// Test with multiple enabled tools
419
const multipleEnabledToolNames = new Set(['refTool1', 'refTool2']);
420
const result2 = service.toToolEnablementMap(multipleEnabledToolNames);
421
422
assert.strictEqual(result2['tool1'], true, 'tool1 should be enabled');
423
assert.strictEqual(result2['tool2'], true, 'tool2 should be enabled');
424
assert.strictEqual(result2['tool3'], false, 'tool3 should be disabled');
425
426
// Test with no enabled tools
427
const noEnabledToolNames = new Set<string>();
428
const result3 = service.toToolEnablementMap(noEnabledToolNames);
429
430
assert.strictEqual(result3['tool1'], false, 'tool1 should be disabled');
431
assert.strictEqual(result3['tool2'], false, 'tool2 should be disabled');
432
assert.strictEqual(result3['tool3'], false, 'tool3 should be disabled');
433
});
434
435
test('toToolEnablementMap with tool sets', () => {
436
// Register individual tools
437
const toolData1: IToolData = {
438
id: 'tool1',
439
toolReferenceName: 'refTool1',
440
modelDescription: 'Test Tool 1',
441
displayName: 'Test Tool 1',
442
source: ToolDataSource.Internal,
443
};
444
445
const toolData2: IToolData = {
446
id: 'tool2',
447
modelDescription: 'Test Tool 2',
448
displayName: 'Test Tool 2',
449
source: ToolDataSource.Internal,
450
};
451
452
store.add(service.registerToolData(toolData1));
453
store.add(service.registerToolData(toolData2));
454
455
// Create a tool set
456
const toolSet = store.add(service.createToolSet(
457
ToolDataSource.Internal,
458
'testToolSet',
459
'refToolSet',
460
{ description: 'Test Tool Set' }
461
));
462
463
// Add tools to the tool set
464
const toolSetTool1: IToolData = {
465
id: 'toolSetTool1',
466
modelDescription: 'Tool Set Tool 1',
467
displayName: 'Tool Set Tool 1',
468
source: ToolDataSource.Internal,
469
};
470
471
const toolSetTool2: IToolData = {
472
id: 'toolSetTool2',
473
modelDescription: 'Tool Set Tool 2',
474
displayName: 'Tool Set Tool 2',
475
source: ToolDataSource.Internal,
476
};
477
478
store.add(service.registerToolData(toolSetTool1));
479
store.add(service.registerToolData(toolSetTool2));
480
store.add(toolSet.addTool(toolSetTool1));
481
store.add(toolSet.addTool(toolSetTool2));
482
483
// Test enabling the tool set
484
const enabledNames = new Set(['refToolSet', 'refTool1']);
485
const result = service.toToolEnablementMap(enabledNames);
486
487
assert.strictEqual(result['tool1'], true, 'individual tool should be enabled');
488
assert.strictEqual(result['tool2'], false);
489
assert.strictEqual(result['toolSetTool1'], true, 'tool set tool 1 should be enabled');
490
assert.strictEqual(result['toolSetTool2'], true, 'tool set tool 2 should be enabled');
491
});
492
493
test('toToolEnablementMap with non-existent tool names', () => {
494
const toolData: IToolData = {
495
id: 'tool1',
496
toolReferenceName: 'refTool1',
497
modelDescription: 'Test Tool 1',
498
displayName: 'Test Tool 1',
499
source: ToolDataSource.Internal,
500
};
501
502
store.add(service.registerToolData(toolData));
503
504
// Test with non-existent tool names
505
const enabledNames = new Set(['nonExistentTool', 'refTool1']);
506
const result = service.toToolEnablementMap(enabledNames);
507
508
assert.strictEqual(result['tool1'], true, 'existing tool should be enabled');
509
// Non-existent tools should not appear in the result map
510
assert.strictEqual(result['nonExistentTool'], undefined, 'non-existent tool should not be in result');
511
});
512
513
test('accessibility signal for tool confirmation', async () => {
514
// Create a test configuration service with proper settings
515
const testConfigService = new TestConfigurationService();
516
testConfigService.setUserConfiguration('chat.tools.global.autoApprove', false);
517
testConfigService.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' });
518
519
// Create a test accessibility service that simulates screen reader being enabled
520
const testAccessibilityService = new class extends TestAccessibilityService {
521
override isScreenReaderOptimized(): boolean { return true; }
522
}();
523
524
// Create a test accessibility signal service that tracks calls
525
const testAccessibilitySignalService = new TestAccessibilitySignalService();
526
527
// Create a new service instance with the test services
528
const instaService = workbenchInstantiationService({
529
contextKeyService: () => store.add(new ContextKeyService(testConfigService)),
530
configurationService: () => testConfigService
531
}, store);
532
instaService.stub(IChatService, chatService);
533
instaService.stub(IAccessibilityService, testAccessibilityService);
534
instaService.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
535
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
536
537
const toolData: IToolData = {
538
id: 'testAccessibilityTool',
539
modelDescription: 'Test Accessibility Tool',
540
displayName: 'Test Accessibility Tool',
541
source: ToolDataSource.Internal,
542
};
543
544
const tool = registerToolForTest(testService, store, toolData.id, {
545
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Accessibility Test', message: 'Testing accessibility signal' } }),
546
invoke: async () => ({ content: [{ kind: 'text', value: 'executed' }] }),
547
}, toolData);
548
549
const sessionId = 'sessionId-accessibility';
550
const capture: { invocation?: any } = {};
551
stubGetSession(chatService, sessionId, { requestId: 'requestId-accessibility', capture });
552
553
const dto = tool.makeDto({ param: 'value' }, { sessionId });
554
555
const promise = testService.invokeTool(dto, async () => 0, CancellationToken.None);
556
const published = await waitForPublishedInvocation(capture);
557
558
assert.ok(published, 'expected ChatToolInvocation to be published');
559
assert.ok(published.confirmationMessages, 'should have confirmation messages');
560
561
// The accessibility signal should have been played
562
assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 1, 'accessibility signal should have been played once');
563
const signalCall = testAccessibilitySignalService.signalPlayedCalls[0];
564
assert.strictEqual(signalCall.signal, AccessibilitySignal.chatUserActionRequired, 'correct signal should be played');
565
assert.ok(signalCall.options?.customAlertMessage.includes('Accessibility Test'), 'alert message should include tool title');
566
assert.ok(signalCall.options?.customAlertMessage.includes('Chat confirmation required'), 'alert message should include confirmation text');
567
568
// Complete the invocation
569
published.confirmed.complete(true);
570
const result = await promise;
571
assert.strictEqual(result.content[0].value, 'executed');
572
});
573
574
test('accessibility signal respects autoApprove configuration', async () => {
575
// Create a test configuration service with auto-approve enabled
576
const testConfigService = new TestConfigurationService();
577
testConfigService.setUserConfiguration('chat.tools.global.autoApprove', true);
578
testConfigService.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' });
579
580
// Create a test accessibility service that simulates screen reader being enabled
581
const testAccessibilityService = new class extends TestAccessibilityService {
582
override isScreenReaderOptimized(): boolean { return true; }
583
}();
584
585
// Create a test accessibility signal service that tracks calls
586
const testAccessibilitySignalService = new TestAccessibilitySignalService();
587
588
// Create a new service instance with the test services
589
const instaService = workbenchInstantiationService({
590
contextKeyService: () => store.add(new ContextKeyService(testConfigService)),
591
configurationService: () => testConfigService
592
}, store);
593
instaService.stub(IChatService, chatService);
594
instaService.stub(IAccessibilityService, testAccessibilityService);
595
instaService.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
596
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
597
598
const toolData: IToolData = {
599
id: 'testAutoApproveTool',
600
modelDescription: 'Test Auto Approve Tool',
601
displayName: 'Test Auto Approve Tool',
602
source: ToolDataSource.Internal,
603
};
604
605
const tool = registerToolForTest(testService, store, toolData.id, {
606
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Auto Approve Test', message: 'Testing auto approve' } }),
607
invoke: async () => ({ content: [{ kind: 'text', value: 'auto approved' }] }),
608
}, toolData);
609
610
const sessionId = 'sessionId-auto-approve';
611
const capture: { invocation?: any } = {};
612
stubGetSession(chatService, sessionId, { requestId: 'requestId-auto-approve', capture });
613
614
const dto = tool.makeDto({ config: 'test' }, { sessionId });
615
616
// When auto-approve is enabled, tool should complete without user intervention
617
const result = await testService.invokeTool(dto, async () => 0, CancellationToken.None);
618
619
// Verify the tool completed and no accessibility signal was played
620
assert.strictEqual(result.content[0].value, 'auto approved');
621
assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 0, 'accessibility signal should not be played when auto-approve is enabled');
622
});
623
624
test('shouldAutoConfirm with basic configuration', async () => {
625
// Test basic shouldAutoConfirm behavior with simple configuration
626
const testConfigService = new TestConfigurationService();
627
testConfigService.setUserConfiguration('chat.tools.global.autoApprove', true); // Global enabled
628
629
const instaService = workbenchInstantiationService({
630
contextKeyService: () => store.add(new ContextKeyService(testConfigService)),
631
configurationService: () => testConfigService
632
}, store);
633
instaService.stub(IChatService, chatService);
634
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
635
636
// Register a tool that should be auto-approved
637
const autoTool = registerToolForTest(testService, store, 'autoTool', {
638
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Test', message: 'Should auto-approve' } }),
639
invoke: async () => ({ content: [{ kind: 'text', value: 'auto approved' }] })
640
});
641
642
const sessionId = 'test-basic-config';
643
stubGetSession(chatService, sessionId, { requestId: 'req1' });
644
645
// Tool should be auto-approved (global config = true)
646
const result = await testService.invokeTool(
647
autoTool.makeDto({ test: 1 }, { sessionId }),
648
async () => 0,
649
CancellationToken.None
650
);
651
assert.strictEqual(result.content[0].value, 'auto approved');
652
});
653
654
test('shouldAutoConfirm with per-tool configuration object', async () => {
655
// Test per-tool configuration: { toolId: true/false }
656
const testConfigService = new TestConfigurationService();
657
testConfigService.setUserConfiguration('chat.tools.global.autoApprove', {
658
'approvedTool': true,
659
'deniedTool': false
660
});
661
662
const instaService = workbenchInstantiationService({
663
contextKeyService: () => store.add(new ContextKeyService(testConfigService)),
664
configurationService: () => testConfigService
665
}, store);
666
instaService.stub(IChatService, chatService);
667
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
668
669
// Tool explicitly approved
670
const approvedTool = registerToolForTest(testService, store, 'approvedTool', {
671
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Test', message: 'Should auto-approve' } }),
672
invoke: async () => ({ content: [{ kind: 'text', value: 'approved' }] })
673
});
674
675
const sessionId = 'test-per-tool';
676
stubGetSession(chatService, sessionId, { requestId: 'req1' });
677
678
// Approved tool should auto-approve
679
const approvedResult = await testService.invokeTool(
680
approvedTool.makeDto({ test: 1 }, { sessionId }),
681
async () => 0,
682
CancellationToken.None
683
);
684
assert.strictEqual(approvedResult.content[0].value, 'approved');
685
686
// Test that non-specified tools require confirmation (default behavior)
687
const unspecifiedTool = registerToolForTest(testService, store, 'unspecifiedTool', {
688
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Test', message: 'Should require confirmation' } }),
689
invoke: async () => ({ content: [{ kind: 'text', value: 'unspecified' }] })
690
});
691
692
const capture: { invocation?: any } = {};
693
stubGetSession(chatService, sessionId + '2', { requestId: 'req2', capture });
694
const unspecifiedPromise = testService.invokeTool(
695
unspecifiedTool.makeDto({ test: 2 }, { sessionId: sessionId + '2' }),
696
async () => 0,
697
CancellationToken.None
698
);
699
const published = await waitForPublishedInvocation(capture);
700
assert.ok(published?.confirmationMessages, 'unspecified tool should require confirmation');
701
702
published.confirmed.complete(true);
703
const unspecifiedResult = await unspecifiedPromise;
704
assert.strictEqual(unspecifiedResult.content[0].value, 'unspecified');
705
});
706
707
test('tool content formatting with alwaysDisplayInputOutput', async () => {
708
// Test ensureToolDetails, formatToolInput, and toolResultToIO
709
const toolData: IToolData = {
710
id: 'formatTool',
711
modelDescription: 'Format Test Tool',
712
displayName: 'Format Test Tool',
713
source: ToolDataSource.Internal,
714
alwaysDisplayInputOutput: true
715
};
716
717
const tool = registerToolForTest(service, store, toolData.id, {
718
prepareToolInvocation: async () => ({}),
719
invoke: async (invocation) => ({
720
content: [
721
{ kind: 'text', value: 'Text result' },
722
{ kind: 'data', value: { data: VSBuffer.fromByteArray([1, 2, 3]), mimeType: 'application/octet-stream' } }
723
]
724
})
725
}, toolData);
726
727
const input = { a: 1, b: 'test', c: [1, 2, 3] };
728
const result = await service.invokeTool(
729
tool.makeDto(input),
730
async () => 0,
731
CancellationToken.None
732
);
733
734
// Should have tool result details because alwaysDisplayInputOutput = true
735
assert.ok(result.toolResultDetails, 'should have toolResultDetails');
736
const details = result.toolResultDetails as any; // Type assertion needed for test
737
738
// Test formatToolInput - should be formatted JSON
739
const expectedInputJson = JSON.stringify(input, undefined, 2);
740
assert.strictEqual(details.input, expectedInputJson, 'input should be formatted JSON');
741
742
// Test toolResultToIO - should convert different content types
743
assert.strictEqual(details.output.length, 2, 'should have 2 output items');
744
745
// Text content
746
const textOutput = details.output[0];
747
assert.strictEqual(textOutput.type, 'embed');
748
assert.strictEqual(textOutput.isText, true);
749
assert.strictEqual(textOutput.value, 'Text result');
750
751
// Data content (base64 encoded)
752
const dataOutput = details.output[1];
753
assert.strictEqual(dataOutput.type, 'embed');
754
assert.strictEqual(dataOutput.mimeType, 'application/octet-stream');
755
assert.strictEqual(dataOutput.value, 'AQID'); // base64 of [1,2,3]
756
});
757
758
test('tool error handling and telemetry', async () => {
759
const testTelemetryService = new TestTelemetryService();
760
761
const instaService = workbenchInstantiationService({
762
contextKeyService: () => store.add(new ContextKeyService(configurationService)),
763
configurationService: () => configurationService
764
}, store);
765
instaService.stub(IChatService, chatService);
766
instaService.stub(ITelemetryService, testTelemetryService as any);
767
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
768
769
// Test successful invocation telemetry
770
const successTool = registerToolForTest(testService, store, 'successTool', {
771
prepareToolInvocation: async () => ({}),
772
invoke: async () => ({ content: [{ kind: 'text', value: 'success' }] })
773
});
774
775
const sessionId = 'telemetry-test';
776
stubGetSession(chatService, sessionId, { requestId: 'req1' });
777
778
await testService.invokeTool(
779
successTool.makeDto({ test: 1 }, { sessionId }),
780
async () => 0,
781
CancellationToken.None
782
);
783
784
// Check success telemetry
785
const successEvents = testTelemetryService.events.filter(e => e.eventName === 'languageModelToolInvoked');
786
assert.strictEqual(successEvents.length, 1, 'should have success telemetry event');
787
assert.strictEqual(successEvents[0].data.result, 'success');
788
assert.strictEqual(successEvents[0].data.toolId, 'successTool');
789
assert.strictEqual(successEvents[0].data.chatSessionId, sessionId);
790
791
testTelemetryService.reset();
792
793
// Test error telemetry
794
const errorTool = registerToolForTest(testService, store, 'errorTool', {
795
prepareToolInvocation: async () => ({}),
796
invoke: async () => { throw new Error('Tool error'); }
797
});
798
799
stubGetSession(chatService, sessionId + '2', { requestId: 'req2' });
800
801
try {
802
await testService.invokeTool(
803
errorTool.makeDto({ test: 2 }, { sessionId: sessionId + '2' }),
804
async () => 0,
805
CancellationToken.None
806
);
807
assert.fail('Should have thrown');
808
} catch (err) {
809
// Expected
810
}
811
812
// Check error telemetry
813
const errorEvents = testTelemetryService.events.filter(e => e.eventName === 'languageModelToolInvoked');
814
assert.strictEqual(errorEvents.length, 1, 'should have error telemetry event');
815
assert.strictEqual(errorEvents[0].data.result, 'error');
816
assert.strictEqual(errorEvents[0].data.toolId, 'errorTool');
817
});
818
819
test('call tracking and cleanup', async () => {
820
// Test that cancelToolCallsForRequest method exists and can be called
821
// (The detailed cancellation behavior is already tested in "cancel tool call" test)
822
const sessionId = 'tracking-session';
823
const requestId = 'tracking-request';
824
stubGetSession(chatService, sessionId, { requestId });
825
826
// Just verify the method exists and doesn't throw
827
assert.doesNotThrow(() => {
828
service.cancelToolCallsForRequest(requestId);
829
}, 'cancelToolCallsForRequest should not throw');
830
831
// Verify calling with non-existent request ID doesn't throw
832
assert.doesNotThrow(() => {
833
service.cancelToolCallsForRequest('non-existent-request');
834
}, 'cancelToolCallsForRequest with non-existent ID should not throw');
835
});
836
837
test('accessibility signal with different settings combinations', async () => {
838
const testAccessibilitySignalService = new TestAccessibilitySignalService();
839
840
// Test case 1: Sound enabled, announcement disabled, screen reader off
841
const testConfigService1 = new TestConfigurationService();
842
testConfigService1.setUserConfiguration('chat.tools.global.autoApprove', false);
843
testConfigService1.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'on', announcement: 'off' });
844
845
const testAccessibilityService1 = new class extends TestAccessibilityService {
846
override isScreenReaderOptimized(): boolean { return false; }
847
}();
848
849
const instaService1 = workbenchInstantiationService({
850
contextKeyService: () => store.add(new ContextKeyService(testConfigService1)),
851
configurationService: () => testConfigService1
852
}, store);
853
instaService1.stub(IChatService, chatService);
854
instaService1.stub(IAccessibilityService, testAccessibilityService1);
855
instaService1.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
856
const testService1 = store.add(instaService1.createInstance(LanguageModelToolsService));
857
858
const tool1 = registerToolForTest(testService1, store, 'soundOnlyTool', {
859
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Sound Test', message: 'Testing sound only' } }),
860
invoke: async () => ({ content: [{ kind: 'text', value: 'executed' }] })
861
});
862
863
const sessionId1 = 'sound-test';
864
const capture1: { invocation?: any } = {};
865
stubGetSession(chatService, sessionId1, { requestId: 'req1', capture: capture1 });
866
867
const promise1 = testService1.invokeTool(tool1.makeDto({ test: 1 }, { sessionId: sessionId1 }), async () => 0, CancellationToken.None);
868
const published1 = await waitForPublishedInvocation(capture1);
869
870
// Signal should be played (sound=on, no screen reader requirement)
871
assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 1, 'sound should be played when sound=on');
872
const call1 = testAccessibilitySignalService.signalPlayedCalls[0];
873
assert.strictEqual(call1.options?.modality, undefined, 'should use default modality for sound');
874
875
published1.confirmed.complete(true);
876
await promise1;
877
878
testAccessibilitySignalService.reset();
879
880
// Test case 2: Sound auto, announcement auto, screen reader on
881
const testConfigService2 = new TestConfigurationService();
882
testConfigService2.setUserConfiguration('chat.tools.global.autoApprove', false);
883
testConfigService2.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'auto', announcement: 'auto' });
884
885
const testAccessibilityService2 = new class extends TestAccessibilityService {
886
override isScreenReaderOptimized(): boolean { return true; }
887
}();
888
889
const instaService2 = workbenchInstantiationService({
890
contextKeyService: () => store.add(new ContextKeyService(testConfigService2)),
891
configurationService: () => testConfigService2
892
}, store);
893
instaService2.stub(IChatService, chatService);
894
instaService2.stub(IAccessibilityService, testAccessibilityService2);
895
instaService2.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
896
const testService2 = store.add(instaService2.createInstance(LanguageModelToolsService));
897
898
const tool2 = registerToolForTest(testService2, store, 'autoScreenReaderTool', {
899
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Auto Test', message: 'Testing auto with screen reader' } }),
900
invoke: async () => ({ content: [{ kind: 'text', value: 'executed' }] })
901
});
902
903
const sessionId2 = 'auto-sr-test';
904
const capture2: { invocation?: any } = {};
905
stubGetSession(chatService, sessionId2, { requestId: 'req2', capture: capture2 });
906
907
const promise2 = testService2.invokeTool(tool2.makeDto({ test: 2 }, { sessionId: sessionId2 }), async () => 0, CancellationToken.None);
908
const published2 = await waitForPublishedInvocation(capture2);
909
910
// Signal should be played (both sound and announcement enabled for screen reader)
911
assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 1, 'signal should be played with screen reader optimization');
912
const call2 = testAccessibilitySignalService.signalPlayedCalls[0];
913
assert.ok(call2.options?.customAlertMessage, 'should have custom alert message');
914
assert.strictEqual(call2.options?.userGesture, true, 'should mark as user gesture');
915
916
published2.confirmed.complete(true);
917
await promise2;
918
919
testAccessibilitySignalService.reset();
920
921
// Test case 3: Sound off, announcement off - no signal
922
const testConfigService3 = new TestConfigurationService();
923
testConfigService3.setUserConfiguration('chat.tools.global.autoApprove', false);
924
testConfigService3.setUserConfiguration('accessibility.signals.chatUserActionRequired', { sound: 'off', announcement: 'off' });
925
926
const testAccessibilityService3 = new class extends TestAccessibilityService {
927
override isScreenReaderOptimized(): boolean { return true; }
928
}();
929
930
const instaService3 = workbenchInstantiationService({
931
contextKeyService: () => store.add(new ContextKeyService(testConfigService3)),
932
configurationService: () => testConfigService3
933
}, store);
934
instaService3.stub(IChatService, chatService);
935
instaService3.stub(IAccessibilityService, testAccessibilityService3);
936
instaService3.stub(IAccessibilitySignalService, testAccessibilitySignalService as unknown as IAccessibilitySignalService);
937
const testService3 = store.add(instaService3.createInstance(LanguageModelToolsService));
938
939
const tool3 = registerToolForTest(testService3, store, 'offTool', {
940
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Off Test', message: 'Testing off settings' } }),
941
invoke: async () => ({ content: [{ kind: 'text', value: 'executed' }] })
942
});
943
944
const sessionId3 = 'off-test';
945
const capture3: { invocation?: any } = {};
946
stubGetSession(chatService, sessionId3, { requestId: 'req3', capture: capture3 });
947
948
const promise3 = testService3.invokeTool(tool3.makeDto({ test: 3 }, { sessionId: sessionId3 }), async () => 0, CancellationToken.None);
949
const published3 = await waitForPublishedInvocation(capture3);
950
951
// No signal should be played
952
assert.strictEqual(testAccessibilitySignalService.signalPlayedCalls.length, 0, 'no signal should be played when both sound and announcement are off');
953
954
published3.confirmed.complete(true);
955
await promise3;
956
});
957
958
test('setToolAutoConfirmation and getToolAutoConfirmation', () => {
959
const toolId = 'testAutoConfirmTool';
960
961
// Initially should be 'never'
962
assert.strictEqual(service.getToolAutoConfirmation(toolId), 'never');
963
964
// Set to workspace scope
965
service.setToolAutoConfirmation(toolId, 'workspace');
966
assert.strictEqual(service.getToolAutoConfirmation(toolId), 'workspace');
967
968
// Set to profile scope
969
service.setToolAutoConfirmation(toolId, 'profile');
970
assert.strictEqual(service.getToolAutoConfirmation(toolId), 'profile');
971
972
// Set to session scope
973
service.setToolAutoConfirmation(toolId, 'session');
974
assert.strictEqual(service.getToolAutoConfirmation(toolId), 'session');
975
976
// Set back to never
977
service.setToolAutoConfirmation(toolId, 'never');
978
assert.strictEqual(service.getToolAutoConfirmation(toolId), 'never');
979
});
980
981
test('resetToolAutoConfirmation', () => {
982
const toolId1 = 'testTool1';
983
const toolId2 = 'testTool2';
984
985
// Set different auto-confirmations
986
service.setToolAutoConfirmation(toolId1, 'workspace');
987
service.setToolAutoConfirmation(toolId2, 'session');
988
989
// Verify they're set
990
assert.strictEqual(service.getToolAutoConfirmation(toolId1), 'workspace');
991
assert.strictEqual(service.getToolAutoConfirmation(toolId2), 'session');
992
993
// Reset all
994
service.resetToolAutoConfirmation();
995
996
// Should all be back to 'never'
997
assert.strictEqual(service.getToolAutoConfirmation(toolId1), 'never');
998
assert.strictEqual(service.getToolAutoConfirmation(toolId2), 'never');
999
});
1000
1001
test('createToolSet and getToolSet', () => {
1002
const toolSet = store.add(service.createToolSet(
1003
ToolDataSource.Internal,
1004
'testToolSetId',
1005
'testToolSetName',
1006
{ icon: undefined, description: 'Test tool set' }
1007
));
1008
1009
// Should be able to retrieve by ID
1010
const retrieved = service.getToolSet('testToolSetId');
1011
assert.ok(retrieved);
1012
assert.strictEqual(retrieved.id, 'testToolSetId');
1013
assert.strictEqual(retrieved.referenceName, 'testToolSetName');
1014
1015
// Should not find non-existent tool set
1016
assert.strictEqual(service.getToolSet('nonExistentId'), undefined);
1017
1018
// Dispose should remove it
1019
toolSet.dispose();
1020
assert.strictEqual(service.getToolSet('testToolSetId'), undefined);
1021
});
1022
1023
test('getToolSetByName', () => {
1024
store.add(service.createToolSet(
1025
ToolDataSource.Internal,
1026
'toolSet1',
1027
'refName1'
1028
));
1029
1030
store.add(service.createToolSet(
1031
ToolDataSource.Internal,
1032
'toolSet2',
1033
'refName2'
1034
));
1035
1036
// Should find by reference name
1037
assert.strictEqual(service.getToolSetByName('refName1')?.id, 'toolSet1');
1038
assert.strictEqual(service.getToolSetByName('refName2')?.id, 'toolSet2');
1039
1040
// Should not find non-existent name
1041
assert.strictEqual(service.getToolSetByName('nonExistentName'), undefined);
1042
});
1043
1044
test('getTools with includeDisabled parameter', () => {
1045
// Test the includeDisabled parameter behavior with context keys
1046
contextKeyService.createKey('testKey', false);
1047
const disabledTool: IToolData = {
1048
id: 'disabledTool',
1049
modelDescription: 'Disabled Tool',
1050
displayName: 'Disabled Tool',
1051
source: ToolDataSource.Internal,
1052
when: ContextKeyEqualsExpr.create('testKey', true), // Will be disabled since testKey is false
1053
};
1054
1055
const enabledTool: IToolData = {
1056
id: 'enabledTool',
1057
modelDescription: 'Enabled Tool',
1058
displayName: 'Enabled Tool',
1059
source: ToolDataSource.Internal,
1060
};
1061
1062
store.add(service.registerToolData(disabledTool));
1063
store.add(service.registerToolData(enabledTool));
1064
1065
const enabledTools = Array.from(service.getTools());
1066
assert.strictEqual(enabledTools.length, 1, 'Should only return enabled tools');
1067
assert.strictEqual(enabledTools[0].id, 'enabledTool');
1068
1069
const allTools = Array.from(service.getTools(true));
1070
assert.strictEqual(allTools.length, 2, 'includeDisabled should return all tools');
1071
});
1072
1073
test('tool registration duplicate error', () => {
1074
const toolData: IToolData = {
1075
id: 'duplicateTool',
1076
modelDescription: 'Duplicate Tool',
1077
displayName: 'Duplicate Tool',
1078
source: ToolDataSource.Internal,
1079
};
1080
1081
// First registration should succeed
1082
store.add(service.registerToolData(toolData));
1083
1084
// Second registration should throw
1085
assert.throws(() => {
1086
service.registerToolData(toolData);
1087
}, /Tool "duplicateTool" is already registered/);
1088
});
1089
1090
test('tool implementation registration without data throws', () => {
1091
const toolImpl: IToolImpl = {
1092
invoke: async () => ({ content: [] }),
1093
};
1094
1095
// Should throw when registering implementation for non-existent tool
1096
assert.throws(() => {
1097
service.registerToolImplementation('nonExistentTool', toolImpl);
1098
}, /Tool "nonExistentTool" was not contributed/);
1099
});
1100
1101
test('tool implementation duplicate registration throws', () => {
1102
const toolData: IToolData = {
1103
id: 'testTool',
1104
modelDescription: 'Test Tool',
1105
displayName: 'Test Tool',
1106
source: ToolDataSource.Internal,
1107
};
1108
1109
const toolImpl1: IToolImpl = {
1110
invoke: async () => ({ content: [] }),
1111
};
1112
1113
const toolImpl2: IToolImpl = {
1114
invoke: async () => ({ content: [] }),
1115
};
1116
1117
store.add(service.registerToolData(toolData));
1118
store.add(service.registerToolImplementation('testTool', toolImpl1));
1119
1120
// Second implementation should throw
1121
assert.throws(() => {
1122
service.registerToolImplementation('testTool', toolImpl2);
1123
}, /Tool "testTool" already has an implementation/);
1124
});
1125
1126
test('invokeTool with unknown tool throws', async () => {
1127
const dto: IToolInvocation = {
1128
callId: '1',
1129
toolId: 'unknownTool',
1130
tokenBudget: 100,
1131
parameters: {},
1132
context: undefined,
1133
};
1134
1135
await assert.rejects(
1136
service.invokeTool(dto, async () => 0, CancellationToken.None),
1137
/Tool unknownTool was not contributed/
1138
);
1139
});
1140
1141
test('invokeTool without implementation activates extension and throws if still not found', async () => {
1142
const toolData: IToolData = {
1143
id: 'extensionActivationTool',
1144
modelDescription: 'Extension Tool',
1145
displayName: 'Extension Tool',
1146
source: ToolDataSource.Internal,
1147
};
1148
1149
store.add(service.registerToolData(toolData));
1150
1151
const dto: IToolInvocation = {
1152
callId: '1',
1153
toolId: 'extensionActivationTool',
1154
tokenBudget: 100,
1155
parameters: {},
1156
context: undefined,
1157
};
1158
1159
// Should throw after attempting extension activation
1160
await assert.rejects(
1161
service.invokeTool(dto, async () => 0, CancellationToken.None),
1162
/Tool extensionActivationTool does not have an implementation registered/
1163
);
1164
});
1165
1166
test('invokeTool without context (non-chat scenario)', async () => {
1167
const tool = registerToolForTest(service, store, 'nonChatTool', {
1168
invoke: async (invocation) => {
1169
assert.strictEqual(invocation.context, undefined);
1170
return { content: [{ kind: 'text', value: 'non-chat result' }] };
1171
}
1172
});
1173
1174
const dto = tool.makeDto({ test: 1 }); // No context
1175
1176
const result = await service.invokeTool(dto, async () => 0, CancellationToken.None);
1177
assert.strictEqual(result.content[0].value, 'non-chat result');
1178
});
1179
1180
test('invokeTool with unknown chat session throws', async () => {
1181
const tool = registerToolForTest(service, store, 'unknownSessionTool', {
1182
invoke: async () => ({ content: [{ kind: 'text', value: 'should not reach' }] })
1183
});
1184
1185
const dto = tool.makeDto({ test: 1 }, { sessionId: 'unknownSession' });
1186
1187
// Test that it throws, regardless of exact error message
1188
let threwError = false;
1189
try {
1190
await service.invokeTool(dto, async () => 0, CancellationToken.None);
1191
} catch (err) {
1192
threwError = true;
1193
// Verify it's one of the expected error types
1194
assert.ok(
1195
err instanceof Error && (
1196
err.message.includes('Tool called for unknown chat session') ||
1197
err.message.includes('getRequests is not a function')
1198
),
1199
`Unexpected error: ${err.message}`
1200
);
1201
}
1202
assert.strictEqual(threwError, true, 'Should have thrown an error');
1203
});
1204
1205
test('tool error with alwaysDisplayInputOutput includes details', async () => {
1206
const toolData: IToolData = {
1207
id: 'errorToolWithIO',
1208
modelDescription: 'Error Tool With IO',
1209
displayName: 'Error Tool With IO',
1210
source: ToolDataSource.Internal,
1211
alwaysDisplayInputOutput: true
1212
};
1213
1214
const tool = registerToolForTest(service, store, toolData.id, {
1215
invoke: async () => { throw new Error('Tool execution failed'); }
1216
}, toolData);
1217
1218
const input = { param: 'testValue' };
1219
1220
try {
1221
await service.invokeTool(
1222
tool.makeDto(input),
1223
async () => 0,
1224
CancellationToken.None
1225
);
1226
assert.fail('Should have thrown');
1227
} catch (err: any) {
1228
// The error should bubble up, but we need to check if toolResultError is set
1229
// This tests the internal error handling path
1230
assert.strictEqual(err.message, 'Tool execution failed');
1231
}
1232
});
1233
1234
test('context key changes trigger tool updates', async () => {
1235
let changeEventFired = false;
1236
const disposable = service.onDidChangeTools(() => {
1237
changeEventFired = true;
1238
});
1239
store.add(disposable);
1240
1241
// Create a tool with a context key dependency
1242
contextKeyService.createKey('dynamicKey', false);
1243
const toolData: IToolData = {
1244
id: 'contextTool',
1245
modelDescription: 'Context Tool',
1246
displayName: 'Context Tool',
1247
source: ToolDataSource.Internal,
1248
when: ContextKeyEqualsExpr.create('dynamicKey', true),
1249
};
1250
1251
store.add(service.registerToolData(toolData));
1252
1253
// Change the context key value
1254
contextKeyService.createKey('dynamicKey', true);
1255
1256
// Wait a bit for the scheduler
1257
await new Promise(resolve => setTimeout(resolve, 800));
1258
1259
assert.strictEqual(changeEventFired, true, 'onDidChangeTools should fire when context keys change');
1260
});
1261
1262
test('configuration changes trigger tool updates', async () => {
1263
let changeEventFired = false;
1264
const disposable = service.onDidChangeTools(() => {
1265
changeEventFired = true;
1266
});
1267
store.add(disposable);
1268
1269
// Change the correct configuration key
1270
configurationService.setUserConfiguration('chat.extensionTools.enabled', false);
1271
// Fire the configuration change event manually
1272
configurationService.onDidChangeConfigurationEmitter.fire({ affectsConfiguration: () => true, affectedKeys: new Set(['chat.extensionTools.enabled']) } as any as IConfigurationChangeEvent);
1273
1274
// Wait a bit for the scheduler
1275
await new Promise(resolve => setTimeout(resolve, 800));
1276
1277
assert.strictEqual(changeEventFired, true, 'onDidChangeTools should fire when configuration changes');
1278
});
1279
1280
test('toToolAndToolSetEnablementMap with MCP toolset enables contained tools', () => {
1281
// Create MCP toolset
1282
const mcpToolSet = store.add(service.createToolSet(
1283
{ type: 'mcp', label: 'testServer', serverLabel: 'testServer', instructions: undefined, collectionId: 'testCollection', definitionId: 'testDef' },
1284
'mcpSet',
1285
'mcpSetRef'
1286
));
1287
1288
const mcpTool: IToolData = {
1289
id: 'mcpTool',
1290
modelDescription: 'MCP Tool',
1291
displayName: 'MCP Tool',
1292
source: { type: 'mcp', label: 'testServer', serverLabel: 'testServer', instructions: undefined, collectionId: 'testCollection', definitionId: 'testDef' },
1293
canBeReferencedInPrompt: true,
1294
toolReferenceName: 'mcpToolRef'
1295
};
1296
1297
store.add(service.registerToolData(mcpTool));
1298
store.add(mcpToolSet.addTool(mcpTool));
1299
1300
// Enable the MCP toolset
1301
const result = service.toToolAndToolSetEnablementMap(['mcpSetRef']);
1302
1303
let toolSetEnabled = false;
1304
let toolEnabled = false;
1305
for (const [toolOrSet, enabled] of result) {
1306
if ('referenceName' in toolOrSet && toolOrSet.referenceName === 'mcpSetRef') {
1307
toolSetEnabled = enabled;
1308
}
1309
if ('id' in toolOrSet && toolOrSet.id === 'mcpTool') {
1310
toolEnabled = enabled;
1311
}
1312
}
1313
1314
assert.strictEqual(toolSetEnabled, true, 'MCP toolset should be enabled');
1315
assert.strictEqual(toolEnabled, true, 'MCP tool should be enabled when its toolset is enabled');
1316
});
1317
1318
test('shouldAutoConfirm with workspace-specific tool configuration', async () => {
1319
const testConfigService = new TestConfigurationService();
1320
// Configure per-tool settings at different scopes
1321
testConfigService.setUserConfiguration('chat.tools.global.autoApprove', { 'workspaceTool': true });
1322
1323
const instaService = workbenchInstantiationService({
1324
contextKeyService: () => store.add(new ContextKeyService(testConfigService)),
1325
configurationService: () => testConfigService
1326
}, store);
1327
instaService.stub(IChatService, chatService);
1328
const testService = store.add(instaService.createInstance(LanguageModelToolsService));
1329
1330
const workspaceTool = registerToolForTest(testService, store, 'workspaceTool', {
1331
prepareToolInvocation: async () => ({ confirmationMessages: { title: 'Test', message: 'Workspace tool' } }),
1332
invoke: async () => ({ content: [{ kind: 'text', value: 'workspace result' }] })
1333
}, { runsInWorkspace: true });
1334
1335
const sessionId = 'workspace-test';
1336
stubGetSession(chatService, sessionId, { requestId: 'req1' });
1337
1338
// Should auto-approve based on user configuration
1339
const result = await testService.invokeTool(
1340
workspaceTool.makeDto({ test: 1 }, { sessionId }),
1341
async () => 0,
1342
CancellationToken.None
1343
);
1344
assert.strictEqual(result.content[0].value, 'workspace result');
1345
});
1346
});
1347
1348