Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/prompt/node/test/defaultIntentRequestHandler.spec.ts
13405 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
7
import { Raw, RenderPromptResult } from '@vscode/prompt-tsx';
8
import { afterEach, beforeEach, expect, suite, test, vi } from 'vitest';
9
import type { ChatLanguageModelToolReference, ChatPromptReference, ChatRequest, ExtendedChatResponsePart, LanguageModelChat } from 'vscode';
10
import { IChatMLFetcher } from '../../../../platform/chat/common/chatMLFetcher';
11
import { toTextPart } from '../../../../platform/chat/common/globalStringUtils';
12
import { StaticChatMLFetcher } from '../../../../platform/chat/test/common/staticChatMLFetcher';
13
import { MockEndpoint } from '../../../../platform/endpoint/test/node/mockEndpoint';
14
import { IResponseDelta } from '../../../../platform/networking/common/fetch';
15
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
16
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
17
import { SpyingTelemetryService } from '../../../../platform/telemetry/node/spyingTelemetryService';
18
import { ITestingServicesAccessor } from '../../../../platform/test/node/services';
19
import { NullWorkspaceFileIndex } from '../../../../platform/workspaceChunkSearch/node/nullWorkspaceFileIndex';
20
import { IWorkspaceFileIndex } from '../../../../platform/workspaceChunkSearch/node/workspaceFileIndex';
21
import { ChatResponseStreamImpl } from '../../../../util/common/chatResponseStreamImpl';
22
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
23
import { isObject, isUndefinedOrNull } from '../../../../util/vs/base/common/types';
24
import { generateUuid } from '../../../../util/vs/base/common/uuid';
25
import { SyncDescriptor } from '../../../../util/vs/platform/instantiation/common/descriptors';
26
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
27
import { ChatLocation, ChatResponseConfirmationPart, ChatResponseMarkdownPart, LanguageModelTextPart, LanguageModelToolResult, Uri } from '../../../../vscodeTypes';
28
import { ToolCallingLoop } from '../../../intents/node/toolCallingLoop';
29
import { ToolResultMetadata } from '../../../prompts/node/panel/toolCalling';
30
import { createExtensionUnitTestingServices } from '../../../test/node/services';
31
import { Conversation, Turn } from '../../common/conversation';
32
import { IBuildPromptContext } from '../../common/intents';
33
import { ToolCallRound } from '../../common/toolCallRound';
34
import { ChatTelemetryBuilder } from '../chatParticipantTelemetry';
35
import { DefaultIntentRequestHandler } from '../defaultIntentRequestHandler';
36
import { IIntent, IIntentInvocation, nullRenderPromptResult, promptResultMetadata } from '../intents';
37
38
suite('defaultIntentRequestHandler', () => {
39
let accessor: ITestingServicesAccessor;
40
let response: ExtendedChatResponsePart[];
41
let chatResponse: (string | IResponseDelta[])[] = [];
42
let promptResult: RenderPromptResult | RenderPromptResult[];
43
let telemetry: SpyingTelemetryService;
44
let fetcher: StaticChatMLFetcher;
45
let endpoint: IChatEndpoint;
46
let turnIdCounter = 0;
47
let builtPrompts: IBuildPromptContext[] = [];
48
const sessionId = 'some-session-id';
49
50
const getTurnId = () => `turn-id-${turnIdCounter}`;
51
52
beforeEach(async () => {
53
const services = createExtensionUnitTestingServices();
54
telemetry = new SpyingTelemetryService();
55
chatResponse = [];
56
fetcher = new StaticChatMLFetcher(chatResponse);
57
services.define(ITelemetryService, telemetry);
58
services.define(IChatMLFetcher, fetcher);
59
services.define(IWorkspaceFileIndex, new SyncDescriptor(NullWorkspaceFileIndex));
60
61
accessor = services.createTestingAccessor();
62
endpoint = accessor.get(IInstantiationService).createInstance(MockEndpoint, undefined);
63
builtPrompts = [];
64
response = [];
65
promptResult = nullRenderPromptResult();
66
turnIdCounter = 0;
67
(ToolCallingLoop as any).NextToolCallId = 0;
68
(ToolCallRound as any).generateID = () => 'static-id';
69
vi.spyOn(Date, 'now').mockReturnValue(0);
70
});
71
72
afterEach(() => {
73
vi.restoreAllMocks();
74
accessor.dispose();
75
});
76
77
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
78
79
function getDerandomizedTelemetry() {
80
const evts = telemetry.getEvents();
81
return cloneAndChangeWithKey(evts, (e, key) => {
82
if (typeof e === 'string' && uuidRegex.test(e)) {
83
return 'some-uuid';
84
} else if (typeof e === 'number' && typeof key === 'string' && key.startsWith('timeTo')) {
85
return '<duration>';
86
}
87
});
88
}
89
90
class TestIntent implements IIntent {
91
id = 'test';
92
description = 'test intent';
93
locations = [ChatLocation.Panel];
94
invoke(): Promise<IIntentInvocation> {
95
return Promise.resolve(new TestIntentInvocation(this, this.locations[0], endpoint));
96
}
97
}
98
99
class TestIntentInvocation implements IIntentInvocation {
100
public readonly context: IBuildPromptContext[] = [];
101
102
constructor(
103
readonly intent: IIntent,
104
readonly location: ChatLocation,
105
readonly endpoint: IChatEndpoint,
106
) { }
107
108
async buildPrompt(context: IBuildPromptContext): Promise<RenderPromptResult> {
109
builtPrompts.push(context);
110
if (Array.isArray(promptResult)) {
111
const next = promptResult.shift();
112
if (!next) {
113
throw new Error('ran out of prompts');
114
}
115
return next;
116
}
117
118
return promptResult;
119
}
120
}
121
122
class TestChatRequest implements ChatRequest {
123
toolInvocationToken!: never;
124
acceptedConfirmationData?: any[] | undefined;
125
rejectedConfirmationData?: any[] | undefined;
126
attempt = 1;
127
enableCommandDetection = false;
128
isParticipantDetected = false;
129
location = ChatLocation.Panel;
130
location2 = undefined;
131
prompt = 'hello world!';
132
command: string | undefined;
133
references: readonly ChatPromptReference[] = [];
134
toolReferences: readonly ChatLanguageModelToolReference[] = [];
135
model: LanguageModelChat = { family: '' } as any;
136
tools = new Map();
137
id = generateUuid();
138
sessionId = generateUuid();
139
sessionResource = Uri.parse(`test://session/${this.sessionId}`);
140
hasHooksEnabled = false;
141
}
142
143
const responseStream = new ChatResponseStreamImpl(p => response.push(p), () => { }, undefined, undefined, undefined, () => Promise.resolve(undefined));
144
const maxToolCallIterations = 3;
145
146
const makeHandler = ({
147
request = new TestChatRequest(),
148
turns = []
149
}: { request?: ChatRequest; turns?: Turn[] } = {}) => {
150
turns.push(new Turn(
151
getTurnId(),
152
{ type: 'user', message: request.prompt },
153
undefined,
154
));
155
156
const instaService = accessor.get(IInstantiationService);
157
return instaService.createInstance(
158
DefaultIntentRequestHandler,
159
new TestIntent(),
160
new Conversation(sessionId, turns),
161
request,
162
responseStream,
163
CancellationToken.None,
164
undefined,
165
ChatLocation.Panel,
166
instaService.createInstance(ChatTelemetryBuilder, Date.now(), sessionId, undefined, turns.length > 1, request, undefined),
167
{ maxToolCallIterations },
168
undefined,
169
);
170
};
171
172
test('avoids requests when handler return is null', async () => {
173
const handler = makeHandler();
174
const result = await handler.getResult();
175
expect(result).to.deep.equal({});
176
expect(getDerandomizedTelemetry()).toMatchSnapshot();
177
});
178
179
test('makes a successful request with a single turn', async () => {
180
const handler = makeHandler();
181
chatResponse[0] = 'some response here :)';
182
promptResult = {
183
...nullRenderPromptResult(),
184
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
185
};
186
187
const result = await handler.getResult();
188
expect(result).toMatchSnapshot();
189
// Wait for event loop to finish as we often fire off telemetry without properly awaiting it as it doesn't matter when it is sent
190
await new Promise(setImmediate);
191
expect(getDerandomizedTelemetry()).toMatchSnapshot();
192
});
193
194
test('propagates resolvedModel into result metadata from a successful response', async () => {
195
fetcher.resolvedModel = 'gpt-4o-resolved';
196
const handler = makeHandler();
197
chatResponse[0] = 'some response here :)';
198
promptResult = {
199
...nullRenderPromptResult(),
200
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
201
};
202
203
const result = await handler.getResult();
204
expect(result.metadata?.resolvedModel).toBe('gpt-4o-resolved');
205
});
206
207
test('ignores stateful marker when mode instructions changed on responses api requests', async () => {
208
const request = new TestChatRequest();
209
(request as any).modeInstructions2 = { name: 'Agent', content: 'agent instructions', isBuiltin: true };
210
(endpoint as any).apiType = 'responses';
211
const requestSpy = vi.spyOn(endpoint, 'makeChatRequest2');
212
const previousTurn = new Turn(generateUuid(), { message: 'previous', type: 'user' }, undefined, [], undefined, undefined, false, { name: 'Plan', content: 'plan instructions', isBuiltin: true } as any);
213
const handler = makeHandler({ request, turns: [previousTurn] });
214
chatResponse[0] = 'some response here :)';
215
promptResult = {
216
...nullRenderPromptResult(),
217
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
218
};
219
220
await handler.getResult();
221
222
expect(requestSpy).toHaveBeenCalledOnce();
223
expect(requestSpy.mock.calls[0][0].modeChanged).toBe(true);
224
expect(requestSpy.mock.calls[0][0].ignoreStatefulMarker).toBeUndefined();
225
});
226
227
test('preserves default stateful marker behavior when mode instructions are unchanged on responses api requests', async () => {
228
const request = new TestChatRequest();
229
(request as any).modeInstructions2 = { name: 'Agent', content: 'agent instructions', isBuiltin: true };
230
(endpoint as any).apiType = 'responses';
231
const requestSpy = vi.spyOn(endpoint, 'makeChatRequest2');
232
const previousTurn = new Turn(generateUuid(), { message: 'previous', type: 'user' }, undefined, [], undefined, undefined, false, { name: 'Agent', content: 'agent instructions', isBuiltin: true } as any);
233
const handler = makeHandler({ request, turns: [previousTurn] });
234
chatResponse[0] = 'some response here :)';
235
promptResult = {
236
...nullRenderPromptResult(),
237
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
238
};
239
240
await handler.getResult();
241
242
expect(requestSpy).toHaveBeenCalledOnce();
243
expect(requestSpy.mock.calls[0][0].modeChanged).toBe(false);
244
expect(requestSpy.mock.calls[0][0].ignoreStatefulMarker).toBeUndefined();
245
});
246
247
test('makes a tool call turn', async () => {
248
const handler = makeHandler();
249
chatResponse[0] = [{
250
text: 'some response here :)',
251
copilotToolCalls: [{
252
arguments: 'some args here',
253
name: 'my_func',
254
id: 'tool_call_id',
255
}],
256
}];
257
chatResponse[1] = 'response to tool call';
258
259
const toolResult = new LanguageModelToolResult([new LanguageModelTextPart('tool-result')]);
260
261
promptResult = {
262
...nullRenderPromptResult(),
263
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
264
metadata: promptResultMetadata([new ToolResultMetadata('tool_call_id__vscode-0', toolResult)])
265
};
266
267
const result = await handler.getResult();
268
expect(result).toMatchSnapshot();
269
// Wait for event loop to finish as we often fire off telemetry without properly awaiting it as it doesn't matter when it is sent
270
await new Promise(setImmediate);
271
expect(getDerandomizedTelemetry()).toMatchSnapshot();
272
273
expect(builtPrompts).toHaveLength(2);
274
expect(builtPrompts[1].toolCallResults).toEqual({ 'tool_call_id__vscode-0': toolResult });
275
expect(builtPrompts[1].toolCallRounds).toMatchObject([
276
{
277
toolCalls: [{ arguments: 'some args here', name: 'my_func', id: 'tool_call_id__vscode-0' }],
278
toolInputRetry: 0,
279
response: 'some response here :)',
280
},
281
{
282
toolCalls: [],
283
toolInputRetry: 0,
284
response: 'response to tool call',
285
},
286
]);
287
});
288
289
function fillWithToolCalls(insertN = 20) {
290
promptResult = [];
291
for (let i = 0; i < insertN; i++) {
292
chatResponse[i] = [{
293
text: `response number ${i}`,
294
copilotToolCalls: [{
295
arguments: 'some args here',
296
name: 'my_func',
297
id: `tool_call_id_${i}`,
298
}],
299
}];
300
const toolResult = new LanguageModelToolResult([new LanguageModelTextPart(`tool-result-${i}`)]);
301
promptResult[i] = {
302
...nullRenderPromptResult(),
303
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
304
metadata: promptResultMetadata([new ToolResultMetadata(`tool_call_id_${i}__vscode-${i}`, toolResult)])
305
};
306
}
307
}
308
309
function setupMultiturnToolCalls(turns: number, roundsPerTurn: number) {
310
// Matches the counter in ToolCallingLoop
311
let toolCallCounter = 0;
312
promptResult = [];
313
const setupOneRound = (startIdx: number) => {
314
const endIdx = startIdx + roundsPerTurn;
315
for (let i = startIdx; i < endIdx; i++) {
316
const isLast = i === endIdx - 1;
317
chatResponse[i] = [{
318
text: `response number ${i}`,
319
copilotToolCalls: isLast ?
320
undefined :
321
[{
322
arguments: 'some args here',
323
name: 'my_func',
324
id: `tool_call_id_${toolCallCounter++}`,
325
}],
326
}];
327
328
// ToolResultMetadata is reported by the prompt for all tool calls, in history or called this round
329
const promptMetadata: ToolResultMetadata[] = [];
330
for (let toolResultIdx = 0; toolResultIdx <= toolCallCounter; toolResultIdx++) {
331
// For each request in a round, all the previous and current ToolResultMetadata are reported
332
const toolResult = new LanguageModelToolResult([new LanguageModelTextPart(`tool-result-${toolResultIdx}`)]);
333
promptMetadata.push(new ToolResultMetadata(`tool_call_id_${toolResultIdx}__vscode-${toolResultIdx}`, toolResult));
334
}
335
(promptResult as RenderPromptResult[])[i] = {
336
...nullRenderPromptResult(),
337
messages: [{ role: Raw.ChatRole.User, content: [toTextPart('hello world!')] }],
338
metadata: promptResultMetadata(promptMetadata)
339
};
340
}
341
};
342
343
for (let i = 0; i < turns; i++) {
344
setupOneRound(i * roundsPerTurn);
345
}
346
}
347
348
test('confirms on max tool call iterations, and continues to iterate', async () => {
349
const handler = makeHandler();
350
fillWithToolCalls();
351
const result1 = await handler.getResult();
352
expect(result1).toMatchSnapshot();
353
354
const last = response.at(-1);
355
expect(last).toBeInstanceOf(ChatResponseConfirmationPart);
356
357
const request = new TestChatRequest();
358
request.acceptedConfirmationData = [(last as ChatResponseConfirmationPart).data];
359
const handler2 = makeHandler({ request });
360
expect(await handler2.getResult()).toMatchSnapshot();
361
362
expect(response).toMatchSnapshot();
363
// Wait for event loop to finish as we often fire off telemetry without properly awaiting it as it doesn't matter when it is sent
364
await new Promise(setImmediate);
365
expect(getDerandomizedTelemetry()).toMatchSnapshot();
366
});
367
368
test('ChatResult metadata after multiple turns only has tool results from current turn', async () => {
369
const request = new TestChatRequest();
370
const handler = makeHandler();
371
setupMultiturnToolCalls(2, maxToolCallIterations);
372
const result1 = await handler.getResult();
373
expect(result1.metadata).toMatchSnapshot();
374
375
const turn1 = new Turn(generateUuid(), { message: request.prompt, type: 'user' }, undefined);
376
const handler2 = makeHandler({ request, turns: [turn1] });
377
const result2 = await handler2.getResult();
378
expect(result2.metadata).toMatchSnapshot();
379
});
380
381
test('aborts on max tool call iterations', async () => {
382
fillWithToolCalls();
383
const handler = makeHandler();
384
await handler.getResult();
385
386
const last = response.at(-1);
387
expect(last).toBeInstanceOf(ChatResponseConfirmationPart);
388
389
const request = new TestChatRequest();
390
request.rejectedConfirmationData = [(last as ChatResponseConfirmationPart).data];
391
request.prompt = (last as ChatResponseConfirmationPart).buttons![1];
392
const handler2 = makeHandler({ request });
393
await handler2.getResult();
394
395
const last2 = response.at(-1);
396
expect(last2).toBeInstanceOf(ChatResponseMarkdownPart);
397
expect((last2 as ChatResponseMarkdownPart).value.value).toMatchInlineSnapshot(`"Let me know if there's anything else I can help with!"`);
398
});
399
});
400
401
402
function cloneAndChangeWithKey(obj: any, changer: (orig: any, key?: string | number) => any): any {
403
return _cloneAndChangeWithKey(obj, changer, new Set(), undefined);
404
}
405
406
function _cloneAndChangeWithKey(obj: any, changer: (orig: any, key?: string | number) => any, seen: Set<any>, key: string | number | undefined): any {
407
if (isUndefinedOrNull(obj)) {
408
return obj;
409
}
410
411
const changed = changer(obj, key);
412
if (typeof changed !== 'undefined') {
413
return changed;
414
}
415
416
if (Array.isArray(obj)) {
417
const r1: any[] = [];
418
for (const [i, e] of obj.entries()) {
419
r1.push(_cloneAndChangeWithKey(e, changer, seen, i));
420
}
421
return r1;
422
}
423
424
if (isObject(obj)) {
425
if (seen.has(obj)) {
426
throw new Error('Cannot clone recursive data-structure');
427
}
428
seen.add(obj);
429
const r2 = {};
430
for (const i2 in obj) {
431
if (Object.prototype.hasOwnProperty.call(obj, i2)) {
432
(r2 as any)[i2] = _cloneAndChangeWithKey(obj[i2], changer, seen, i2);
433
}
434
}
435
seen.delete(obj);
436
return r2;
437
}
438
439
return obj;
440
}
441
442