Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/inlineChat2/test/node/inlineChat2Prompt.spec.tsx
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
import { expect, suite, test } from 'vitest';
7
import { TextDocumentSnapshot } from '../../../../platform/editing/common/textDocumentSnapshot';
8
import { createTextDocumentData, setDocText } from '../../../../util/common/test/shims/textDocument';
9
import { URI } from '../../../../util/vs/base/common/uri';
10
import { ExtendedLanguageModelToolResult, LanguageModelTextPart, LanguageModelToolResult, Position, Range } from '../../../../vscodeTypes';
11
import { FileContextElement, FileSelectionElement, ICompletedToolCallRound, LARGE_FILE_LINE_THRESHOLD, ToolCallRoundsElement } from '../../node/inlineChatPrompt';
12
13
function createSnapshot(content: string, languageId: string = 'typescript'): TextDocumentSnapshot {
14
const uri = URI.file('/workspace/file.ts');
15
const docData = createTextDocumentData(uri, content, languageId);
16
return TextDocumentSnapshot.create(docData.document);
17
}
18
19
suite('FileContextElement', () => {
20
21
test('cursor at the beginning of the file', async () => {
22
const content = `line 1
23
line 2
24
line 3
25
line 4
26
line 5`;
27
const snapshot = createSnapshot(content);
28
const position = new Position(0, 0);
29
30
const element = new FileContextElement({ snapshot, position });
31
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
32
33
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
34
expect(output).toContain('$CURSOR$');
35
expect(output).toContain('line 1');
36
expect(output).toContain('line 2');
37
expect(output).toContain('line 3');
38
});
39
40
test('cursor in the middle of a file', async () => {
41
const content = `line 1
42
line 2
43
line 3
44
line 4
45
line 5
46
line 6
47
line 7`;
48
const snapshot = createSnapshot(content);
49
const position = new Position(3, 2); // after "li" in "line 4"
50
51
const element = new FileContextElement({ snapshot, position });
52
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
53
54
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
55
expect(output).toContain('$CURSOR$');
56
// Should include lines before and after cursor
57
expect(output).toContain('line 2');
58
expect(output).toContain('line 3');
59
// Cursor position (3, 2) splits "line 4" into "li" + "$CURSOR$" + "ne 4"
60
expect(output).toContain('li$CURSOR$ne 4');
61
expect(output).toContain('line 5');
62
expect(output).toContain('line 6');
63
});
64
65
test('cursor at the end of file', async () => {
66
const content = `line 1
67
line 2
68
line 3
69
line 4
70
line 5`;
71
const snapshot = createSnapshot(content);
72
const position = new Position(4, 6); // end of "line 5"
73
74
const element = new FileContextElement({ snapshot, position });
75
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
76
77
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
78
expect(output).toContain('$CURSOR$');
79
expect(output).toContain('line 3');
80
expect(output).toContain('line 4');
81
expect(output).toContain('line 5');
82
});
83
84
test('cursor with empty lines - includes extra lines until non-empty', async () => {
85
const content = `
86
87
line 3
88
line 4
89
90
`;
91
const snapshot = createSnapshot(content);
92
const position = new Position(2, 0); // start of "line 3"
93
94
const element = new FileContextElement({ snapshot, position });
95
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
96
97
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
98
expect(output).toContain('$CURSOR$');
99
expect(output).toContain('line 3');
100
expect(output).toContain('line 4');
101
});
102
103
test('single line file', async () => {
104
const content = `only one line`;
105
const snapshot = createSnapshot(content);
106
const position = new Position(0, 5); // middle of line
107
108
const element = new FileContextElement({ snapshot, position });
109
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
110
111
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
112
expect(output).toContain('only $CURSOR$one line');
113
});
114
115
test('cursor position splits text correctly', async () => {
116
const content = `hello world`;
117
const snapshot = createSnapshot(content);
118
const position = new Position(0, 6); // after "hello "
119
120
const element = new FileContextElement({ snapshot, position });
121
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
122
123
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
124
expect(output).toContain('hello $CURSOR$world');
125
});
126
});
127
128
suite('FileSelectionElement', () => {
129
130
test('single line selection', async () => {
131
const content = `line 1
132
line 2
133
line 3
134
line 4
135
line 5`;
136
const snapshot = createSnapshot(content);
137
const selection = new Range(1, 0, 1, 6); // "line 2"
138
139
const element = new FileSelectionElement({ snapshot, selection });
140
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
141
142
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
143
expect(output).toContain('line 2');
144
expect(output).not.toContain('line 1');
145
expect(output).not.toContain('line 3');
146
});
147
148
test('multi-line selection', async () => {
149
const content = `line 1
150
line 2
151
line 3
152
line 4
153
line 5`;
154
const snapshot = createSnapshot(content);
155
const selection = new Range(1, 0, 3, 6); // "line 2" through "line 4"
156
157
const element = new FileSelectionElement({ snapshot, selection });
158
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
159
160
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
161
expect(output).toContain('line 2');
162
expect(output).toContain('line 3');
163
expect(output).toContain('line 4');
164
expect(output).not.toContain('line 1');
165
expect(output).not.toContain('line 5');
166
});
167
168
test('partial line selection extends to full lines', async () => {
169
const content = `line 1
170
line 2
171
line 3`;
172
const snapshot = createSnapshot(content);
173
// Select from middle of line 2 to middle of line 2 (partial)
174
const selection = new Range(1, 2, 1, 4);
175
176
const element = new FileSelectionElement({ snapshot, selection });
177
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
178
179
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
180
// Should include the full line, not just "ne"
181
expect(output).toContain('line 2');
182
});
183
184
test('selection at start of file', async () => {
185
const content = `line 1
186
line 2
187
line 3`;
188
const snapshot = createSnapshot(content);
189
const selection = new Range(0, 0, 0, 6);
190
191
const element = new FileSelectionElement({ snapshot, selection });
192
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
193
194
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
195
expect(output).toContain('line 1');
196
expect(output).not.toContain('line 2');
197
});
198
199
test('selection at end of file', async () => {
200
const content = `line 1
201
line 2
202
line 3`;
203
const snapshot = createSnapshot(content);
204
const selection = new Range(2, 0, 2, 6);
205
206
const element = new FileSelectionElement({ snapshot, selection });
207
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
208
209
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
210
expect(output).toContain('line 3');
211
expect(output).not.toContain('line 2');
212
});
213
214
test('selection spanning partial lines extends to full lines', async () => {
215
const content = `first line here
216
second line here
217
third line here`;
218
const snapshot = createSnapshot(content);
219
// Select from middle of "first" to middle of "second"
220
const selection = new Range(0, 6, 1, 7);
221
222
const element = new FileSelectionElement({ snapshot, selection });
223
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
224
225
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
226
// Should include full lines
227
expect(output).toContain('first line here');
228
expect(output).toContain('second line here');
229
expect(output).not.toContain('third line here');
230
});
231
232
test('preserves language id for code block', async () => {
233
const content = `const x = 1;`;
234
const snapshot = createSnapshot(content, 'javascript');
235
const selection = new Range(0, 0, 0, 12);
236
237
const element = new FileSelectionElement({ snapshot, selection });
238
const rendered = await element.render(undefined, { tokenBudget: 1000, countTokens: () => Promise.resolve(0), endpoint: {} as any });
239
240
const output = typeof rendered === 'string' ? rendered : JSON.stringify(rendered) ?? '';
241
expect(output).toContain('javascript');
242
});
243
});
244
245
// --- Helpers for ToolCallRoundsElement tests
246
247
function makeToolCall(id: string, name: string = 'replace_string_in_file', args: string = '{}') {
248
return { id, name, arguments: args };
249
}
250
251
function makeToolResult(text: string, hasError = false): ExtendedLanguageModelToolResult {
252
const result = new LanguageModelToolResult([new LanguageModelTextPart(text)]) as ExtendedLanguageModelToolResult;
253
(result as any).hasError = hasError;
254
return result;
255
}
256
257
function makeRound(...calls: [string, string][]): ICompletedToolCallRound {
258
return {
259
calls: calls.map(([id, resultText]) => [makeToolCall(id), makeToolResult(resultText)])
260
};
261
}
262
263
function makeDocument(content: string, languageId = 'typescript', uri = URI.file('/workspace/file.ts')) {
264
return createTextDocumentData(uri, content, languageId);
265
}
266
267
suite('ToolCallRoundsElement', () => {
268
269
test('empty rounds renders nothing', async () => {
270
const doc = makeDocument('const x = 1;');
271
const element = new ToolCallRoundsElement({
272
previousRounds: [],
273
hasFailedEdits: false,
274
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
275
documentVersionAtRequest: doc.document.version,
276
isLargeFile: false,
277
selection: new Range(0, 0, 0, 0),
278
filepath: '/workspace/file.ts',
279
});
280
const rendered = await element.render();
281
expect(rendered).toBeUndefined();
282
});
283
284
test('single round produces AssistantMessage then ToolMessage', async () => {
285
const doc = makeDocument('const x = 1;');
286
const element = new ToolCallRoundsElement({
287
previousRounds: [makeRound(['call-1', 'result-one'])],
288
hasFailedEdits: false,
289
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
290
documentVersionAtRequest: doc.document.version,
291
isLargeFile: false,
292
selection: new Range(0, 0, 0, 0),
293
filepath: '/workspace/file.ts',
294
});
295
const output = JSON.stringify(await element.render());
296
// tool call id and result text both appear
297
expect(output).toContain('call-1');
298
expect(output).toContain('result-one');
299
});
300
301
test('hasFailedEdits: false - no feedback tag', async () => {
302
const doc = makeDocument('const x = 1;');
303
const element = new ToolCallRoundsElement({
304
previousRounds: [makeRound(['call-1', 'ok'])],
305
hasFailedEdits: false,
306
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
307
documentVersionAtRequest: doc.document.version,
308
isLargeFile: false,
309
selection: new Range(0, 0, 0, 0),
310
filepath: '/workspace/file.ts',
311
});
312
const output = JSON.stringify(await element.render());
313
expect(output).not.toContain('feedback');
314
});
315
316
test('hasFailedEdits: true + document unchanged - feedback without file content', async () => {
317
const doc = makeDocument('const x = 1;');
318
const element = new ToolCallRoundsElement({
319
previousRounds: [makeRound(['call-1', 'error'])],
320
hasFailedEdits: true,
321
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
322
documentVersionAtRequest: doc.document.version, // same version = no change
323
isLargeFile: false,
324
selection: new Range(0, 0, 0, 0),
325
filepath: '/workspace/file.ts',
326
});
327
const output = JSON.stringify(await element.render());
328
expect(output).toContain('feedback');
329
expect(output).toContain('No changes were made');
330
// should NOT include the file content block
331
expect(output).not.toContain('current file content');
332
});
333
334
test('hasFailedEdits: true + document changed + small file - feedback with full file content', async () => {
335
const doc = makeDocument('const x = 1;');
336
setDocText(doc, 'const x = 2;'); // bumps version
337
const element = new ToolCallRoundsElement({
338
previousRounds: [makeRound(['call-1', 'error'])],
339
hasFailedEdits: true,
340
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
341
documentVersionAtRequest: doc.document.version - 1, // old version
342
isLargeFile: false,
343
selection: new Range(0, 0, 0, 0),
344
filepath: '/workspace/file.ts',
345
});
346
const output = JSON.stringify(await element.render());
347
expect(output).toContain('feedback');
348
expect(output).toContain('current file content');
349
expect(output).toContain('const x = 2;');
350
});
351
352
test('hasFailedEdits: true + document changed + large file - feedback uses CroppedFileContentElement', async () => {
353
// Build a document that exceeds the large-file threshold
354
const lines = Array.from({ length: LARGE_FILE_LINE_THRESHOLD + 10 }, (_, i) => `let line${i} = ${i};`);
355
const content = lines.join('\n');
356
const doc = makeDocument(content);
357
setDocText(doc, content + '\n// changed');
358
const selection = new Range(0, 0, 0, 0);
359
const element = new ToolCallRoundsElement({
360
previousRounds: [makeRound(['call-1', 'error'])],
361
hasFailedEdits: true,
362
data: { document: doc.document, selection } as any,
363
documentVersionAtRequest: doc.document.version - 1,
364
isLargeFile: true,
365
selection,
366
filepath: '/workspace/file.ts',
367
});
368
const output = JSON.stringify(await element.render());
369
expect(output).toContain('feedback');
370
expect(output).toContain('current file content');
371
// CroppedFileContentElement receives 'filepath' as a plain string prop (distinguishes it
372
// from the small-file path which uses CodeBlock with a 'code' prop instead)
373
expect(output).toContain('"filepath":"/workspace/file.ts"');
374
});
375
376
test('multiple rounds - content appears in round order', async () => {
377
const doc = makeDocument('const x = 1;');
378
const element = new ToolCallRoundsElement({
379
previousRounds: [
380
makeRound(['round1-call', 'round1-result']),
381
makeRound(['round2-call', 'round2-result']),
382
],
383
hasFailedEdits: false,
384
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
385
documentVersionAtRequest: doc.document.version,
386
isLargeFile: false,
387
selection: new Range(0, 0, 0, 0),
388
filepath: '/workspace/file.ts',
389
});
390
const output = JSON.stringify(await element.render());
391
const idx1 = output.indexOf('round1-call');
392
const idx2 = output.indexOf('round2-call');
393
expect(idx1).toBeGreaterThan(-1);
394
expect(idx2).toBeGreaterThan(-1);
395
expect(idx1).toBeLessThan(idx2);
396
});
397
398
test('multiple rounds - results are interleaved with calls (result-1 before call-2)', async () => {
399
const doc = makeDocument('const x = 1;');
400
const element = new ToolCallRoundsElement({
401
previousRounds: [
402
makeRound(['round1-call', 'round1-result']),
403
makeRound(['round2-call', 'round2-result']),
404
],
405
hasFailedEdits: false,
406
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
407
documentVersionAtRequest: doc.document.version,
408
isLargeFile: false,
409
selection: new Range(0, 0, 0, 0),
410
filepath: '/workspace/file.ts',
411
});
412
const output = JSON.stringify(await element.render());
413
// The interleaving invariant: round1 call → round1 result → round2 call → round2 result
414
const idxCall1 = output.indexOf('round1-call');
415
const idxResult1 = output.indexOf('round1-result');
416
const idxCall2 = output.indexOf('round2-call');
417
const idxResult2 = output.indexOf('round2-result');
418
expect(idxCall1).toBeGreaterThan(-1);
419
expect(idxResult1).toBeGreaterThan(-1);
420
expect(idxCall2).toBeGreaterThan(-1);
421
expect(idxResult2).toBeGreaterThan(-1);
422
// call comes before its own result
423
expect(idxCall1).toBeLessThan(idxResult1);
424
// round 1's result comes before round 2's call (not batched)
425
expect(idxResult1).toBeLessThan(idxCall2);
426
// round 2's call comes before its own result
427
expect(idxCall2).toBeLessThan(idxResult2);
428
});
429
430
test('multiple calls in one round - all calls precede their results', async () => {
431
const doc = makeDocument('const x = 1;');
432
const round: ICompletedToolCallRound = {
433
calls: [
434
[makeToolCall('read-call', 'read_file'), makeToolResult('file contents')],
435
[makeToolCall('edit-call', 'replace_string_in_file'), makeToolResult('edit result')],
436
]
437
};
438
const element = new ToolCallRoundsElement({
439
previousRounds: [round],
440
hasFailedEdits: false,
441
data: { document: doc.document, selection: new Range(0, 0, 0, 0) } as any,
442
documentVersionAtRequest: doc.document.version,
443
isLargeFile: false,
444
selection: new Range(0, 0, 0, 0),
445
filepath: '/workspace/file.ts',
446
});
447
const output = JSON.stringify(await element.render());
448
// Both call ids appear
449
expect(output).toContain('read-call');
450
expect(output).toContain('edit-call');
451
// Both results appear
452
expect(output).toContain('file contents');
453
expect(output).toContain('edit result');
454
});
455
});
456
457