Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/intents/node/testIntent/testFromSrcInvocation.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 { PromptElement, PromptElementProps, PromptSizing, SystemMessage, UserMessage } from '@vscode/prompt-tsx';
7
import assert from 'assert';
8
import type * as vscode from 'vscode';
9
import { IResponsePart } from '../../../../platform/chat/common/chatMLFetcher';
10
import { ChatLocation } from '../../../../platform/chat/common/commonTypes';
11
import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';
12
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
13
import { IParserService, treeSitterOffsetRangeToVSCodeRange as toRange, vscodeToTreeSitterOffsetRange as toTSOffsetRange } from '../../../../platform/parser/node/parserService';
14
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
15
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
16
import * as path from '../../../../util/vs/base/common/path';
17
import { assertType } from '../../../../util/vs/base/common/types';
18
import { URI } from '../../../../util/vs/base/common/uri';
19
import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringEdit';
20
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
21
import { ChatResponseMovePart, Range, Uri } from '../../../../vscodeTypes';
22
import { IBuildPromptContext } from '../../../prompt/common/intents';
23
import { IDocumentContext } from '../../../prompt/node/documentContext';
24
import { EarlyStopping, IIntentInvocation, IResponseProcessorContext, LeadingMarkdownStreaming } from '../../../prompt/node/intents';
25
import { PseudoStopStartResponseProcessor } from '../../../prompt/node/pseudoStartStopConversationCallback';
26
import { InsertionStreamingEdits, TextPieceClassifiers } from '../../../prompt/node/streamingEdits';
27
import { TestExample, TestExampleFile } from '../../../prompt/node/testExample';
28
import { isTestFile, suggestUntitledTestFileLocation, TestFileFinder } from '../../../prompt/node/testFiles';
29
import { CopilotIdentityRules } from '../../../prompts/node/base/copilotIdentity';
30
import { InstructionMessage } from '../../../prompts/node/base/instructionMessage';
31
import { PromptRenderer } from '../../../prompts/node/base/promptRenderer';
32
import { SafetyRules } from '../../../prompts/node/base/safetyRules';
33
import { Tag } from '../../../prompts/node/base/tag';
34
import { createPromptingSummarizedDocument, InlineReplyInterpreter } from '../../../prompts/node/inline/promptingSummarizedDocument';
35
import { ISummarizedDocumentSettings, ProjectedDocument, RemovableNode } from '../../../prompts/node/inline/summarizedDocument/summarizeDocument';
36
import { summarizeDocument } from '../../../prompts/node/inline/summarizedDocument/summarizeDocumentHelpers';
37
import { replaceStringInStream, StreamPipe } from '../../../prompts/node/inline/utils/streaming';
38
import { ChatToolReferences, ChatVariables } from '../../../prompts/node/panel/chatVariables';
39
import { HistoryWithInstructions } from '../../../prompts/node/panel/conversationHistory';
40
import { CustomInstructions } from '../../../prompts/node/panel/customInstructions';
41
import { CodeBlock } from '../../../prompts/node/panel/safeElements';
42
import { TestDeps } from './testDeps';
43
import { TestsIntent } from './testIntent';
44
import { formatRequestAndUserQuery, relativeToWorkspace } from './testPromptUtil';
45
46
47
type TestFileToWriteTo = {
48
kind: 'existing' | 'new';
49
uri: Uri;
50
};
51
52
/**
53
* Invoke from within a non-test file
54
*/
55
export class TestFromSourceInvocation implements IIntentInvocation {
56
57
private _testFileToWriteTo: TestFileToWriteTo | undefined;
58
private _additionalResponseParts: vscode.ExtendedChatResponsePart[] | undefined;
59
private _testFileFinder: TestFileFinder;
60
61
constructor(
62
readonly intent: TestsIntent,
63
readonly endpoint: IChatEndpoint,
64
readonly location: ChatLocation,
65
private readonly documentContext: IDocumentContext,
66
private readonly alreadyConsumedChatVariable: vscode.ChatPromptReference | undefined,
67
@IInstantiationService private readonly instantiationService: IInstantiationService,
68
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
69
@IIgnoreService private readonly ignoreService: IIgnoreService,
70
@IParserService private readonly _parserService: IParserService,
71
) {
72
this._testFileFinder = this.instantiationService.createInstance(TestFileFinder);
73
}
74
75
async buildPrompt(
76
promptContext: IBuildPromptContext,
77
progress: vscode.Progress<vscode.ChatResponseProgressPart | vscode.ChatResponseReferencePart>,
78
token: vscode.CancellationToken
79
) {
80
assert(!isTestFile(this.documentContext.document), 'TestFromSourceInvocation should not be invoked from a test file');
81
82
// identify in which file generated tests will be placed at
83
84
const testExampleFile = await this.findTestFileForSourceFile(token);
85
86
if (testExampleFile !== null && testExampleFile.kind === 'candidateTestFile') {
87
this._testFileToWriteTo = {
88
kind: 'existing',
89
uri: testExampleFile.testExampleFile,
90
};
91
} else {
92
const testFileUri = suggestUntitledTestFileLocation(this.documentContext.document);
93
this._testFileToWriteTo = {
94
kind: 'new',
95
uri: testFileUri,
96
};
97
}
98
99
let range: Range;
100
if (this._testFileToWriteTo.kind === 'new') {
101
range = new Range(0, 0, 0, 0);
102
} else {
103
104
const testFileUri = this._testFileToWriteTo.uri;
105
106
const testFile = await this.workspaceService.openTextDocument(testFileUri);
107
108
const testFileAST = this._parserService.getTreeSitterAST(testFile);
109
110
const lastTest = testFileAST ? await testFileAST.findLastTest() : null;
111
112
if (lastTest === null) {
113
range = new Range(testFile.lineCount, 0, testFile.lineCount, 0);
114
} else {
115
const lastLineOfTest = testFile.positionAt(lastTest.endIndex);
116
const lineAfterLastLine = lastLineOfTest.line + 1;
117
range = new Range(lastLineOfTest.line, lastLineOfTest.character, lineAfterLastLine, 0);
118
}
119
}
120
121
progress.report(new ChatResponseMovePart(this._testFileToWriteTo.uri, range) as any); // FIXME@ulugbekna
122
123
if (this.location === ChatLocation.Panel && !promptContext.query) {
124
promptContext = { ...promptContext, query: 'Write a set of detailed unit test functions for the code above.', };
125
}
126
127
const renderer = PromptRenderer.create(this.instantiationService, this.endpoint, Prompt, {
128
context: this.documentContext,
129
endpoint: this.endpoint,
130
location: this.location,
131
testExampleFile,
132
testFileToWriteTo: this._testFileToWriteTo,
133
promptContext,
134
alreadyConsumedChatVariable: this.alreadyConsumedChatVariable,
135
});
136
137
const result = await renderer.render(progress as any, token); // FIXME@ulugbekna
138
139
return result;
140
}
141
142
async processResponse(context: IResponseProcessorContext, inputStream: AsyncIterable<IResponsePart>, outputStream: vscode.ChatResponseStream, token: CancellationToken): Promise<void> {
143
144
if (this.location === ChatLocation.Panel) {
145
const responseProcessor = this.instantiationService.createInstance(PseudoStopStartResponseProcessor, [], undefined);
146
await responseProcessor.processResponse(context, inputStream, outputStream, token);
147
return;
148
}
149
150
const doc = this.documentContext.document;
151
152
const additionalParts = this._additionalResponseParts;
153
this._additionalResponseParts = undefined;
154
155
const testFileKind = this._testFileToWriteTo?.kind;
156
const testFileUri = this._testFileToWriteTo?.uri;
157
this._testFileToWriteTo = undefined;
158
159
if (testFileKind === undefined || testFileUri === undefined) {
160
161
assertType(additionalParts, 'Expected to have a textual response without a test file');
162
163
} else if (testFileKind === 'new') {
164
165
const range = new Range(0, 0, 0, 0);
166
167
const projectedDoc = new ProjectedDocument('', StringEdit.empty, doc.languageId);
168
169
const replyInterpreter = new InlineReplyInterpreter(
170
testFileUri,
171
projectedDoc,
172
this.documentContext.fileIndentInfo,
173
LeadingMarkdownStreaming.Emit,
174
EarlyStopping.StopAfterFirstCodeBlock,
175
(lineFilter, streamingWorkingCopyDocument) => new InsertionStreamingEdits(
176
streamingWorkingCopyDocument,
177
range.start,
178
lineFilter
179
),
180
TextPieceClassifiers.createCodeBlockClassifier(),
181
_ => true,
182
);
183
184
await replyInterpreter.processResponse(context, inputStream, outputStream, token);
185
186
} else {
187
188
const testFile = await this.workspaceService.openTextDocumentAndSnapshot(testFileUri);
189
190
const testFileAST = this._parserService.getTreeSitterAST(testFile);
191
192
const lastTest = testFileAST ? await testFileAST.findLastTest() : null;
193
194
let range: Range;
195
if (lastTest === null) {
196
range = new Range(testFile.lineCount, 0, testFile.lineCount, 0);
197
} else {
198
const lastLineOfTest = testFile.positionAt(lastTest.endIndex);
199
const lineAfterLastLine = lastLineOfTest.line + 1;
200
range = new Range(lastLineOfTest.line, lastLineOfTest.character, lineAfterLastLine, 0);
201
}
202
203
const summarizedDocument = await createPromptingSummarizedDocument(
204
this._parserService,
205
testFile,
206
this.documentContext.fileIndentInfo,
207
range,
208
testFile.getText().length // @ulugbekna: we shouldn't be restricted on the token size because we're not sending it in the prompt
209
);
210
211
const splitDoc = summarizedDocument.splitAroundOriginalSelectionEnd();
212
213
// FIXME@ulugbekna: we shouldn't need this
214
// const { codeAbove, hasContent, codeBelow } = splitDoc;
215
const placeHolder = '$PLACEHOLDER$';
216
// const code = `${codeAbove}${placeHolder}${codeBelow}`;
217
218
const replyInterpreter = splitDoc.createReplyInterpreter(
219
StreamPipe.chain(
220
markdownStream => replaceStringInStream(markdownStream, '`' + placeHolder + '`', 'selection'),
221
markdownStream => replaceStringInStream(markdownStream, placeHolder, 'selection'),
222
),
223
EarlyStopping.StopAfterFirstCodeBlock,
224
splitDoc.insertStreaming,
225
TextPieceClassifiers.createCodeBlockClassifier(),
226
line => line.value.trim() !== placeHolder
227
);
228
229
await replyInterpreter.processResponse(context, inputStream, outputStream, token);
230
}
231
232
additionalParts?.forEach(p => outputStream.push(p));
233
}
234
235
236
/**
237
* Finds either a test file corresponding to the source file or any test file within the workspace.
238
* The found test file is used in the prompt.
239
*
240
* @remark respects copilot-ignored
241
*/
242
private async findTestFileForSourceFile(token: CancellationToken): Promise<TestExampleFile | null> {
243
244
let kind: 'anyTestFile' | 'candidateTestFile';
245
246
let testExampleFile = await this._testFileFinder.findTestFileForSourceFile(this.documentContext.document, token);
247
248
if (token.isCancellationRequested) {
249
return null;
250
}
251
252
if (testExampleFile !== undefined) {
253
kind = 'candidateTestFile';
254
} else {
255
const anyTestFile = await this._testFileFinder.findAnyTestFileForSourceFile(this.documentContext.document, token);
256
257
if (token.isCancellationRequested) {
258
return null;
259
}
260
261
kind = 'anyTestFile';
262
testExampleFile = anyTestFile;
263
}
264
265
if (testExampleFile === undefined || (await this.ignoreService.isCopilotIgnored(testExampleFile))) {
266
return null;
267
}
268
269
return { kind, testExampleFile };
270
}
271
}
272
273
type Props = PromptElementProps<{
274
/**
275
* @remark Assumes the document has already been checked for copilot-ignore, ie, don't pass copilot-ignored files.
276
*/
277
context: IDocumentContext;
278
endpoint: IChatEndpoint;
279
location: ChatLocation;
280
testExampleFile: TestExampleFile | null;
281
testFileToWriteTo: TestFileToWriteTo;
282
promptContext: IBuildPromptContext;
283
alreadyConsumedChatVariable: vscode.ChatPromptReference | undefined;
284
}>;
285
286
class Prompt extends PromptElement<Props> {
287
288
constructor(
289
props: Props,
290
@IParserService private readonly parserService: IParserService,
291
@IWorkspaceService private readonly workspaceService: IWorkspaceService
292
) {
293
super(props);
294
}
295
296
override async render(state: void, sizing: PromptSizing) {
297
298
const { history, query, chatVariables, } = this.props.promptContext;
299
const { context, testExampleFile, testFileToWriteTo, location, alreadyConsumedChatVariable } = this.props;
300
301
// get testable node
302
303
const treeSitterAST = this.parserService.getTreeSitterAST(context.document);
304
305
let userSelection: vscode.Range = context.selection;
306
let testedSymbolIdentifier: string | undefined;
307
let nodeKind: string | undefined;
308
if (treeSitterAST !== undefined) {
309
const node = await treeSitterAST.getNodeToDocument(toTSOffsetRange(context.selection, context.document));
310
userSelection = toRange(context.document, node.nodeToDocument);
311
testedSymbolIdentifier = node.nodeIdentifier;
312
nodeKind = node.nodeToDocument.type;
313
}
314
315
const documentSummarizationSettings: ISummarizedDocumentSettings | undefined =
316
(
317
// special score function for TS/TSX classes and methods
318
// we want to preserve constructor's and other methods' signatures
319
['typescript', 'typescriptreact'].includes(context.document.languageId) &&
320
nodeKind !== undefined && ['class_declaration', 'method_definition'].includes(nodeKind)
321
)
322
? {
323
costFnOverride: (node: RemovableNode, currentScore: number) => {
324
return !node ? currentScore : node.kind === 'constructor' || node.kind === 'method_definition' ? 0 : currentScore;
325
}
326
}
327
: undefined
328
;
329
330
const summarization = await summarizeDocument(
331
this.parserService,
332
context.document,
333
context.fileIndentInfo,
334
userSelection,
335
sizing.tokenBudget / 2, // leave half of token budget to response
336
documentSummarizationSettings,
337
);
338
339
// get test frameworks info
340
341
342
const languageId = context.language.languageId;
343
344
const extraContext = await this.computeLangSpecificExtraGuidelines(context, testExampleFile);
345
346
const requestAndUserQuery = formatRequestAndUserQuery({
347
workspaceService: this.workspaceService,
348
chatVariables,
349
userQuery: query,
350
testFileToWriteTo: testFileToWriteTo.uri,
351
testedSymbolIdentifier,
352
context,
353
});
354
355
const srcFilePath = relativeToWorkspace(this.workspaceService, context.document.uri.path) ?? path.basename(context.document.uri.path);
356
357
const filteredChatVariables = alreadyConsumedChatVariable === undefined ? chatVariables : chatVariables.filter(v => v.reference !== alreadyConsumedChatVariable);
358
359
return (
360
<>
361
<SystemMessage priority={1000}>
362
You are an AI programming assistant.<br />
363
<CopilotIdentityRules /><br />
364
<SafetyRules />
365
</SystemMessage>
366
<HistoryWithInstructions history={history} passPriority historyPriority={700}>
367
<InstructionMessage priority={1000}>
368
{location === ChatLocation.Editor
369
? <>
370
The user has a {languageId} file opened in a code editor.<br />
371
The user includes some code snippets from the file.<br />
372
Answer with a single {languageId} code block.
373
</>
374
: location === ChatLocation.Panel
375
? <>
376
First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.<br />
377
Then output the code in a single code block.<br />
378
Minimize any other prose.<br />
379
Use Markdown formatting in your answers.<br />
380
Make sure to include the programming language name at the start of the Markdown code blocks.<br />
381
Avoid wrapping the whole response in triple backticks.<br />
382
The user works in an IDE called Visual Studio Code which has a concept for editors with open files, integrated unit test support, an output pane that shows the output of running the code as well as an integrated terminal.<br />
383
The active document is the source code the user is looking at right now.<br />
384
You can only give one reply for each conversation turn.
385
</>
386
: undefined // @ulugbekna: should be unreachable
387
}
388
{extraContext.length > 0 && <><br /> {extraContext}</>}
389
</InstructionMessage>
390
</HistoryWithInstructions>
391
<UserMessage>
392
<TestDeps languageId={languageId} priority={750} />
393
<CustomInstructions chatVariables={filteredChatVariables} languageId={context.language.languageId} includeTestGenerationInstructions={true} priority={725} />
394
395
<ChatToolReferences priority={750} promptContext={this.props.promptContext} flexGrow={1} />
396
<ChatVariables priority={750} chatVariables={filteredChatVariables} />
397
{
398
testExampleFile !== null && <TestExample priority={750} {...testExampleFile} />
399
}
400
<Tag name='currentFile' priority={900}>
401
Here is the current file at `{srcFilePath}`:<br />
402
<br />
403
<CodeBlock uri={context.document.uri} languageId={context.document.languageId} code={summarization.text} /><br />
404
<br />
405
{requestAndUserQuery}
406
</Tag>
407
</UserMessage>
408
</>
409
);
410
}
411
412
private async computeLangSpecificExtraGuidelines(context: IDocumentContext, testExampleFile: TestExampleFile | null): Promise<string> {
413
const extraContext = [];
414
415
if (context.document.languageId === 'python') {
416
const usingExistingTestFile = testExampleFile !== null && testExampleFile.kind === 'candidateTestFile';
417
418
if (!usingExistingTestFile) {
419
420
extraContext.push('Make sure your answer imports the function to test as this is a total new file.');
421
422
// this will be true if there is not a candidate test file so goal is creating a new test file which will require imports
423
const parent: string = path.dirname(context.document.uri.fsPath);
424
const init_search: string = path.join(parent, '__init__.py');
425
const workspaceRootPath: URI | undefined = this.workspaceService.getWorkspaceFolder(context.document.uri);
426
try {
427
await this.workspaceService.openTextDocument(Uri.file(init_search));
428
if (workspaceRootPath !== undefined && path.resolve(parent) === path.resolve(workspaceRootPath?.fsPath ?? '')) {
429
/* current file is at the root of the workspace */
430
extraContext.push('The file is in the root of the workspace, which has an __init__.py but use an absolute import to import the function to test.');
431
} else {
432
extraContext.push('The parent directory of the given file has an __init__.py file making it a regular package. Use a relative import to import the function to test.');
433
}
434
} catch (error) {
435
extraContext.push('The parent directory of the given file has no __init__.py file making it a namespace package. Use an absolute import to import the function to test.');
436
}
437
}
438
}
439
return extraContext.join('\n');
440
}
441
}
442
443