Path: blob/main/extensions/copilot/src/extension/intents/node/testIntent/testFromSrcInvocation.tsx
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { PromptElement, PromptElementProps, PromptSizing, SystemMessage, UserMessage } from '@vscode/prompt-tsx';6import assert from 'assert';7import type * as vscode from 'vscode';8import { IResponsePart } from '../../../../platform/chat/common/chatMLFetcher';9import { ChatLocation } from '../../../../platform/chat/common/commonTypes';10import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';11import { IChatEndpoint } from '../../../../platform/networking/common/networking';12import { IParserService, treeSitterOffsetRangeToVSCodeRange as toRange, vscodeToTreeSitterOffsetRange as toTSOffsetRange } from '../../../../platform/parser/node/parserService';13import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';14import { CancellationToken } from '../../../../util/vs/base/common/cancellation';15import * as path from '../../../../util/vs/base/common/path';16import { assertType } from '../../../../util/vs/base/common/types';17import { URI } from '../../../../util/vs/base/common/uri';18import { StringEdit } from '../../../../util/vs/editor/common/core/edits/stringEdit';19import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';20import { ChatResponseMovePart, Range, Uri } from '../../../../vscodeTypes';21import { IBuildPromptContext } from '../../../prompt/common/intents';22import { IDocumentContext } from '../../../prompt/node/documentContext';23import { EarlyStopping, IIntentInvocation, IResponseProcessorContext, LeadingMarkdownStreaming } from '../../../prompt/node/intents';24import { PseudoStopStartResponseProcessor } from '../../../prompt/node/pseudoStartStopConversationCallback';25import { InsertionStreamingEdits, TextPieceClassifiers } from '../../../prompt/node/streamingEdits';26import { TestExample, TestExampleFile } from '../../../prompt/node/testExample';27import { isTestFile, suggestUntitledTestFileLocation, TestFileFinder } from '../../../prompt/node/testFiles';28import { CopilotIdentityRules } from '../../../prompts/node/base/copilotIdentity';29import { InstructionMessage } from '../../../prompts/node/base/instructionMessage';30import { PromptRenderer } from '../../../prompts/node/base/promptRenderer';31import { SafetyRules } from '../../../prompts/node/base/safetyRules';32import { Tag } from '../../../prompts/node/base/tag';33import { createPromptingSummarizedDocument, InlineReplyInterpreter } from '../../../prompts/node/inline/promptingSummarizedDocument';34import { ISummarizedDocumentSettings, ProjectedDocument, RemovableNode } from '../../../prompts/node/inline/summarizedDocument/summarizeDocument';35import { summarizeDocument } from '../../../prompts/node/inline/summarizedDocument/summarizeDocumentHelpers';36import { replaceStringInStream, StreamPipe } from '../../../prompts/node/inline/utils/streaming';37import { ChatToolReferences, ChatVariables } from '../../../prompts/node/panel/chatVariables';38import { HistoryWithInstructions } from '../../../prompts/node/panel/conversationHistory';39import { CustomInstructions } from '../../../prompts/node/panel/customInstructions';40import { CodeBlock } from '../../../prompts/node/panel/safeElements';41import { TestDeps } from './testDeps';42import { TestsIntent } from './testIntent';43import { formatRequestAndUserQuery, relativeToWorkspace } from './testPromptUtil';444546type TestFileToWriteTo = {47kind: 'existing' | 'new';48uri: Uri;49};5051/**52* Invoke from within a non-test file53*/54export class TestFromSourceInvocation implements IIntentInvocation {5556private _testFileToWriteTo: TestFileToWriteTo | undefined;57private _additionalResponseParts: vscode.ExtendedChatResponsePart[] | undefined;58private _testFileFinder: TestFileFinder;5960constructor(61readonly intent: TestsIntent,62readonly endpoint: IChatEndpoint,63readonly location: ChatLocation,64private readonly documentContext: IDocumentContext,65private readonly alreadyConsumedChatVariable: vscode.ChatPromptReference | undefined,66@IInstantiationService private readonly instantiationService: IInstantiationService,67@IWorkspaceService private readonly workspaceService: IWorkspaceService,68@IIgnoreService private readonly ignoreService: IIgnoreService,69@IParserService private readonly _parserService: IParserService,70) {71this._testFileFinder = this.instantiationService.createInstance(TestFileFinder);72}7374async buildPrompt(75promptContext: IBuildPromptContext,76progress: vscode.Progress<vscode.ChatResponseProgressPart | vscode.ChatResponseReferencePart>,77token: vscode.CancellationToken78) {79assert(!isTestFile(this.documentContext.document), 'TestFromSourceInvocation should not be invoked from a test file');8081// identify in which file generated tests will be placed at8283const testExampleFile = await this.findTestFileForSourceFile(token);8485if (testExampleFile !== null && testExampleFile.kind === 'candidateTestFile') {86this._testFileToWriteTo = {87kind: 'existing',88uri: testExampleFile.testExampleFile,89};90} else {91const testFileUri = suggestUntitledTestFileLocation(this.documentContext.document);92this._testFileToWriteTo = {93kind: 'new',94uri: testFileUri,95};96}9798let range: Range;99if (this._testFileToWriteTo.kind === 'new') {100range = new Range(0, 0, 0, 0);101} else {102103const testFileUri = this._testFileToWriteTo.uri;104105const testFile = await this.workspaceService.openTextDocument(testFileUri);106107const testFileAST = this._parserService.getTreeSitterAST(testFile);108109const lastTest = testFileAST ? await testFileAST.findLastTest() : null;110111if (lastTest === null) {112range = new Range(testFile.lineCount, 0, testFile.lineCount, 0);113} else {114const lastLineOfTest = testFile.positionAt(lastTest.endIndex);115const lineAfterLastLine = lastLineOfTest.line + 1;116range = new Range(lastLineOfTest.line, lastLineOfTest.character, lineAfterLastLine, 0);117}118}119120progress.report(new ChatResponseMovePart(this._testFileToWriteTo.uri, range) as any); // FIXME@ulugbekna121122if (this.location === ChatLocation.Panel && !promptContext.query) {123promptContext = { ...promptContext, query: 'Write a set of detailed unit test functions for the code above.', };124}125126const renderer = PromptRenderer.create(this.instantiationService, this.endpoint, Prompt, {127context: this.documentContext,128endpoint: this.endpoint,129location: this.location,130testExampleFile,131testFileToWriteTo: this._testFileToWriteTo,132promptContext,133alreadyConsumedChatVariable: this.alreadyConsumedChatVariable,134});135136const result = await renderer.render(progress as any, token); // FIXME@ulugbekna137138return result;139}140141async processResponse(context: IResponseProcessorContext, inputStream: AsyncIterable<IResponsePart>, outputStream: vscode.ChatResponseStream, token: CancellationToken): Promise<void> {142143if (this.location === ChatLocation.Panel) {144const responseProcessor = this.instantiationService.createInstance(PseudoStopStartResponseProcessor, [], undefined);145await responseProcessor.processResponse(context, inputStream, outputStream, token);146return;147}148149const doc = this.documentContext.document;150151const additionalParts = this._additionalResponseParts;152this._additionalResponseParts = undefined;153154const testFileKind = this._testFileToWriteTo?.kind;155const testFileUri = this._testFileToWriteTo?.uri;156this._testFileToWriteTo = undefined;157158if (testFileKind === undefined || testFileUri === undefined) {159160assertType(additionalParts, 'Expected to have a textual response without a test file');161162} else if (testFileKind === 'new') {163164const range = new Range(0, 0, 0, 0);165166const projectedDoc = new ProjectedDocument('', StringEdit.empty, doc.languageId);167168const replyInterpreter = new InlineReplyInterpreter(169testFileUri,170projectedDoc,171this.documentContext.fileIndentInfo,172LeadingMarkdownStreaming.Emit,173EarlyStopping.StopAfterFirstCodeBlock,174(lineFilter, streamingWorkingCopyDocument) => new InsertionStreamingEdits(175streamingWorkingCopyDocument,176range.start,177lineFilter178),179TextPieceClassifiers.createCodeBlockClassifier(),180_ => true,181);182183await replyInterpreter.processResponse(context, inputStream, outputStream, token);184185} else {186187const testFile = await this.workspaceService.openTextDocumentAndSnapshot(testFileUri);188189const testFileAST = this._parserService.getTreeSitterAST(testFile);190191const lastTest = testFileAST ? await testFileAST.findLastTest() : null;192193let range: Range;194if (lastTest === null) {195range = new Range(testFile.lineCount, 0, testFile.lineCount, 0);196} else {197const lastLineOfTest = testFile.positionAt(lastTest.endIndex);198const lineAfterLastLine = lastLineOfTest.line + 1;199range = new Range(lastLineOfTest.line, lastLineOfTest.character, lineAfterLastLine, 0);200}201202const summarizedDocument = await createPromptingSummarizedDocument(203this._parserService,204testFile,205this.documentContext.fileIndentInfo,206range,207testFile.getText().length // @ulugbekna: we shouldn't be restricted on the token size because we're not sending it in the prompt208);209210const splitDoc = summarizedDocument.splitAroundOriginalSelectionEnd();211212// FIXME@ulugbekna: we shouldn't need this213// const { codeAbove, hasContent, codeBelow } = splitDoc;214const placeHolder = '$PLACEHOLDER$';215// const code = `${codeAbove}${placeHolder}${codeBelow}`;216217const replyInterpreter = splitDoc.createReplyInterpreter(218StreamPipe.chain(219markdownStream => replaceStringInStream(markdownStream, '`' + placeHolder + '`', 'selection'),220markdownStream => replaceStringInStream(markdownStream, placeHolder, 'selection'),221),222EarlyStopping.StopAfterFirstCodeBlock,223splitDoc.insertStreaming,224TextPieceClassifiers.createCodeBlockClassifier(),225line => line.value.trim() !== placeHolder226);227228await replyInterpreter.processResponse(context, inputStream, outputStream, token);229}230231additionalParts?.forEach(p => outputStream.push(p));232}233234235/**236* Finds either a test file corresponding to the source file or any test file within the workspace.237* The found test file is used in the prompt.238*239* @remark respects copilot-ignored240*/241private async findTestFileForSourceFile(token: CancellationToken): Promise<TestExampleFile | null> {242243let kind: 'anyTestFile' | 'candidateTestFile';244245let testExampleFile = await this._testFileFinder.findTestFileForSourceFile(this.documentContext.document, token);246247if (token.isCancellationRequested) {248return null;249}250251if (testExampleFile !== undefined) {252kind = 'candidateTestFile';253} else {254const anyTestFile = await this._testFileFinder.findAnyTestFileForSourceFile(this.documentContext.document, token);255256if (token.isCancellationRequested) {257return null;258}259260kind = 'anyTestFile';261testExampleFile = anyTestFile;262}263264if (testExampleFile === undefined || (await this.ignoreService.isCopilotIgnored(testExampleFile))) {265return null;266}267268return { kind, testExampleFile };269}270}271272type Props = PromptElementProps<{273/**274* @remark Assumes the document has already been checked for copilot-ignore, ie, don't pass copilot-ignored files.275*/276context: IDocumentContext;277endpoint: IChatEndpoint;278location: ChatLocation;279testExampleFile: TestExampleFile | null;280testFileToWriteTo: TestFileToWriteTo;281promptContext: IBuildPromptContext;282alreadyConsumedChatVariable: vscode.ChatPromptReference | undefined;283}>;284285class Prompt extends PromptElement<Props> {286287constructor(288props: Props,289@IParserService private readonly parserService: IParserService,290@IWorkspaceService private readonly workspaceService: IWorkspaceService291) {292super(props);293}294295override async render(state: void, sizing: PromptSizing) {296297const { history, query, chatVariables, } = this.props.promptContext;298const { context, testExampleFile, testFileToWriteTo, location, alreadyConsumedChatVariable } = this.props;299300// get testable node301302const treeSitterAST = this.parserService.getTreeSitterAST(context.document);303304let userSelection: vscode.Range = context.selection;305let testedSymbolIdentifier: string | undefined;306let nodeKind: string | undefined;307if (treeSitterAST !== undefined) {308const node = await treeSitterAST.getNodeToDocument(toTSOffsetRange(context.selection, context.document));309userSelection = toRange(context.document, node.nodeToDocument);310testedSymbolIdentifier = node.nodeIdentifier;311nodeKind = node.nodeToDocument.type;312}313314const documentSummarizationSettings: ISummarizedDocumentSettings | undefined =315(316// special score function for TS/TSX classes and methods317// we want to preserve constructor's and other methods' signatures318['typescript', 'typescriptreact'].includes(context.document.languageId) &&319nodeKind !== undefined && ['class_declaration', 'method_definition'].includes(nodeKind)320)321? {322costFnOverride: (node: RemovableNode, currentScore: number) => {323return !node ? currentScore : node.kind === 'constructor' || node.kind === 'method_definition' ? 0 : currentScore;324}325}326: undefined327;328329const summarization = await summarizeDocument(330this.parserService,331context.document,332context.fileIndentInfo,333userSelection,334sizing.tokenBudget / 2, // leave half of token budget to response335documentSummarizationSettings,336);337338// get test frameworks info339340341const languageId = context.language.languageId;342343const extraContext = await this.computeLangSpecificExtraGuidelines(context, testExampleFile);344345const requestAndUserQuery = formatRequestAndUserQuery({346workspaceService: this.workspaceService,347chatVariables,348userQuery: query,349testFileToWriteTo: testFileToWriteTo.uri,350testedSymbolIdentifier,351context,352});353354const srcFilePath = relativeToWorkspace(this.workspaceService, context.document.uri.path) ?? path.basename(context.document.uri.path);355356const filteredChatVariables = alreadyConsumedChatVariable === undefined ? chatVariables : chatVariables.filter(v => v.reference !== alreadyConsumedChatVariable);357358return (359<>360<SystemMessage priority={1000}>361You are an AI programming assistant.<br />362<CopilotIdentityRules /><br />363<SafetyRules />364</SystemMessage>365<HistoryWithInstructions history={history} passPriority historyPriority={700}>366<InstructionMessage priority={1000}>367{location === ChatLocation.Editor368? <>369The user has a {languageId} file opened in a code editor.<br />370The user includes some code snippets from the file.<br />371Answer with a single {languageId} code block.372</>373: location === ChatLocation.Panel374? <>375First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.<br />376Then output the code in a single code block.<br />377Minimize any other prose.<br />378Use Markdown formatting in your answers.<br />379Make sure to include the programming language name at the start of the Markdown code blocks.<br />380Avoid wrapping the whole response in triple backticks.<br />381The 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 />382The active document is the source code the user is looking at right now.<br />383You can only give one reply for each conversation turn.384</>385: undefined // @ulugbekna: should be unreachable386}387{extraContext.length > 0 && <><br /> {extraContext}</>}388</InstructionMessage>389</HistoryWithInstructions>390<UserMessage>391<TestDeps languageId={languageId} priority={750} />392<CustomInstructions chatVariables={filteredChatVariables} languageId={context.language.languageId} includeTestGenerationInstructions={true} priority={725} />393394<ChatToolReferences priority={750} promptContext={this.props.promptContext} flexGrow={1} />395<ChatVariables priority={750} chatVariables={filteredChatVariables} />396{397testExampleFile !== null && <TestExample priority={750} {...testExampleFile} />398}399<Tag name='currentFile' priority={900}>400Here is the current file at `{srcFilePath}`:<br />401<br />402<CodeBlock uri={context.document.uri} languageId={context.document.languageId} code={summarization.text} /><br />403<br />404{requestAndUserQuery}405</Tag>406</UserMessage>407</>408);409}410411private async computeLangSpecificExtraGuidelines(context: IDocumentContext, testExampleFile: TestExampleFile | null): Promise<string> {412const extraContext = [];413414if (context.document.languageId === 'python') {415const usingExistingTestFile = testExampleFile !== null && testExampleFile.kind === 'candidateTestFile';416417if (!usingExistingTestFile) {418419extraContext.push('Make sure your answer imports the function to test as this is a total new file.');420421// this will be true if there is not a candidate test file so goal is creating a new test file which will require imports422const parent: string = path.dirname(context.document.uri.fsPath);423const init_search: string = path.join(parent, '__init__.py');424const workspaceRootPath: URI | undefined = this.workspaceService.getWorkspaceFolder(context.document.uri);425try {426await this.workspaceService.openTextDocument(Uri.file(init_search));427if (workspaceRootPath !== undefined && path.resolve(parent) === path.resolve(workspaceRootPath?.fsPath ?? '')) {428/* current file is at the root of the workspace */429extraContext.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.');430} else {431extraContext.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.');432}433} catch (error) {434extraContext.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.');435}436}437}438return extraContext.join('\n');439}440}441442443