Path: blob/main/extensions/copilot/src/extension/intents/node/testIntent/testIntent.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 * as l10n from '@vscode/l10n';6import type { CancellationToken, ChatPromptReference, ChatRequest, ChatResponseStream, ChatResult } from 'vscode';7import { IAuthenticationService } from '../../../../platform/authentication/common/authentication';8import { IChatHookService } from '../../../../platform/chat/common/chatHookService';9import { ChatLocation } from '../../../../platform/chat/common/commonTypes';10import { IConversationOptions } from '../../../../platform/chat/common/conversationOptions';11import { IConfigurationService } from '../../../../platform/configuration/common/configurationService';12import { TextDocumentSnapshot } from '../../../../platform/editing/common/textDocumentSnapshot';13import { IEditSurvivalTrackerService } from '../../../../platform/editSurvivalTracking/common/editSurvivalTrackerService';14import { IEndpointProvider } from '../../../../platform/endpoint/common/endpointProvider';15import { IOctoKitService } from '../../../../platform/github/common/githubService';16import { IIgnoreService } from '../../../../platform/ignore/common/ignoreService';17import { ILogService } from '../../../../platform/log/common/logService';18import { IRequestLogger } from '../../../../platform/requestLogger/common/requestLogger';19import { ISurveyService } from '../../../../platform/survey/common/surveyService';20import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';21import { ISetupTestsDetector, isStartSetupTestConfirmation, SetupTestActionType } from '../../../../platform/testing/node/setupTestDetector';22import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';23import { getLanguage } from '../../../../util/common/languages';24import { isUri } from '../../../../util/common/types';25import { URI } from '../../../../util/vs/base/common/uri';26import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';27import { Position, Range, Selection } from '../../../../vscodeTypes';28import { Intent } from '../../../common/constants';29import { Conversation } from '../../../prompt/common/conversation';30import { ChatTelemetryBuilder } from '../../../prompt/node/chatParticipantTelemetry';31import { DefaultIntentRequestHandler } from '../../../prompt/node/defaultIntentRequestHandler';32import { IDocumentContext } from '../../../prompt/node/documentContext';33import { IIntent, IIntentInvocation, IIntentInvocationContext, IIntentSlashCommandInfo } from '../../../prompt/node/intents';34import { isTestFile } from '../../../prompt/node/testFiles';35import { ContributedToolName } from '../../../tools/common/toolNames';36import { TestFromSourceInvocation } from './testFromSrcInvocation';37import { TestFromTestInvocation } from './testFromTestInvocation';38import { UserQueryParser } from './userQueryParser';394041export class TestsIntent implements IIntent {4243static readonly ID = Intent.Tests;4445readonly id = Intent.Tests;4647readonly locations = [ChatLocation.Panel, ChatLocation.Editor];4849readonly description = l10n.t('Generate unit tests for the selected code');5051readonly commandInfo: IIntentSlashCommandInfo = { toolEquivalent: ContributedToolName.FindTestFiles };5253constructor(54@IInstantiationService private readonly instantiationService: IInstantiationService,55@IEndpointProvider private readonly endpointProvider: IEndpointProvider,56@IIgnoreService private readonly ignoreService: IIgnoreService,57@IWorkspaceService private readonly workspaceService: IWorkspaceService,58@ILogService private readonly logService: ILogService,59) { }6061handleRequest(conversation: Conversation, request: ChatRequest, stream: ChatResponseStream, token: CancellationToken, documentContext: IDocumentContext | undefined, agentName: string, location: ChatLocation, chatTelemetry: ChatTelemetryBuilder): Promise<ChatResult> {62return this.instantiationService.createInstance(RequestHandler, this, conversation, request, stream, token, documentContext, location, chatTelemetry).getResult();63}6465async invoke(invocationContext: IIntentInvocationContext): Promise<IIntentInvocation> {6667let documentContext = invocationContext.documentContext;68let alreadyConsumedChatVariable: ChatPromptReference | undefined;6970// try resolving the document context programmatically71if (!documentContext) {72const r = await this.resolveDocContextProgrammatically(invocationContext);73if (r) {74documentContext = r.documentContext;75alreadyConsumedChatVariable = r.alreadyConsumedChatVariable;76}77}7879// try resolving the document context using LLM80if (!documentContext) {81const r = await this.resolveDocContextUsingLlm(invocationContext);82if (r) {83documentContext = r.documentContext;84alreadyConsumedChatVariable = r.alreadyConsumedChatVariable;85}86}8788if (!documentContext) {89throw new Error('To generate tests, open a file and select code to test.');90}9192if (await this.ignoreService.isCopilotIgnored(documentContext.document.uri)) {93throw new Error('Copilot is disabled for this file.');94}9596const location = invocationContext.location;9798const endpoint = await this.endpointProvider.getChatEndpoint(invocationContext.request);99100return isTestFile(documentContext.document)101? this.instantiationService.createInstance(TestFromTestInvocation, this, endpoint, location, documentContext, alreadyConsumedChatVariable)102: this.instantiationService.createInstance(TestFromSourceInvocation, this, endpoint, location, documentContext, alreadyConsumedChatVariable);103}104105private async resolveDocContextProgrammatically(invocationContext: IIntentInvocationContext) {106107const refs = invocationContext.request.references;108109// find a #file to use for testing110111// count #file's because we use LLM if there're more than 1 in the prompt112let hashFileCount = 0;113114const fileRefs: [ChatPromptReference, URI][] = [];115116for (const ref of refs) {117if (ref.id === 'copilot.file' || ref.id === 'vscode.file') {118if (isUri(ref.value)) {119hashFileCount += 1;120fileRefs.push([ref, ref.value]);121}122} else {123if (!isUri(ref.id)) {124continue;125}126const uri = URI.parse(ref.id);127if (uri !== undefined) {128fileRefs.push([ref, uri]);129}130}131}132133if (hashFileCount > 1 // use LLM if there's more than 1 file reference134|| fileRefs.length === 0135) {136return;137}138139const [ref, fileUri] = fileRefs[0];140141return {142documentContext: await this.createDocumentContext(fileUri),143alreadyConsumedChatVariable: ref,144};145}146147private async resolveDocContextUsingLlm(invocationContext: IIntentInvocationContext) {148149const queryParser = this.instantiationService.createInstance(UserQueryParser);150const parsedQuery = await queryParser.parse(invocationContext.request.prompt);151152if (parsedQuery === null) {153return;154}155156// FIXME@ulugbekna: UserQueryParser also returns symbols that need testing; we should use that info157const { fileToTest, } = parsedQuery;158159// if parser couldn't identify the file, if there's only one file referenced, use that160if (fileToTest === undefined) {161return;162}163164for (let i = 0; i < invocationContext.request.references.length; i++) {165166const ref = invocationContext.request.references[i];167168// FIXME@ulugbekna: I don't like how I fish for #file references169170if (ref.id !== 'vscode.file' && ref.id !== 'copilot.file') {171continue;172}173174const [kind, fileName] = ref.name.trim().split(':');175if (kind !== 'file' ||176fileName === undefined ||177!(URI.isUri(ref.value)) ||178fileName !== fileToTest179) {180continue;181}182183return {184documentContext: await this.createDocumentContext(ref.value),185alreadyConsumedChatVariable: ref,186};187}188}189190/**191*192* @param selection defaults to whole file193*/194private async createDocumentContext(file: URI, selection?: Range) {195let td: TextDocumentSnapshot | undefined;196try {197td = await this.workspaceService.openTextDocumentAndSnapshot(file);198} catch (e) {199this.log(`Tried opening file ${file.toString()} but got error: ${e}`);200return;201}202203const wholeFile = selection ?? new Range(204new Position(0, 0),205new Position(td.lineCount - 1, td.lineAt(td.lineCount - 1).text.length)206);207208return {209document: td,210fileIndentInfo: undefined,211language: getLanguage(td.languageId),212wholeRange: wholeFile,213selection: new Selection(wholeFile.start, wholeFile.end),214} satisfies IDocumentContext;215}216217private log(...args: any[]): void {218const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, '\t') : arg).join('\n');219this.logService.debug(`[TestsIntent] ${message}`);220}221}222223class RequestHandler extends DefaultIntentRequestHandler {224constructor(225intent: IIntent,226conversation: Conversation,227request: ChatRequest,228stream: ChatResponseStream,229token: CancellationToken,230documentContext: IDocumentContext | undefined,231location: ChatLocation,232chatTelemetry: ChatTelemetryBuilder,233@IInstantiationService instantiationService: IInstantiationService,234@IConversationOptions conversationOptions: IConversationOptions,235@ITelemetryService telemetryService: ITelemetryService,236@ILogService logService: ILogService,237@ISurveyService surveyService: ISurveyService,238@ISetupTestsDetector private readonly setupTestsDetector: ISetupTestsDetector,239@IRequestLogger requestLogger: IRequestLogger,240@IEditSurvivalTrackerService editSurvivalTrackerService: IEditSurvivalTrackerService,241@IAuthenticationService authenticationService: IAuthenticationService,242@IChatHookService chatHookService: IChatHookService,243@IOctoKitService octoKitService: IOctoKitService,244@IConfigurationService configurationService: IConfigurationService,245) {246super(intent, conversation, request, stream, token, documentContext, location, chatTelemetry, undefined, undefined, instantiationService, conversationOptions, telemetryService, logService, surveyService, requestLogger, editSurvivalTrackerService, authenticationService, chatHookService, octoKitService, configurationService);247}248249/**250* - Delegates out to setting up tests if the user confirmed they wanted to do that251* - Otherwise try to detect if setup should be shown252* - If not, just delegate to the base class253* - If so, either return just that or append a reminder.254*/255public override async getResult(): Promise<ChatResult> {256// if the user is starting test setup, we need to finish this request257// before they can prompt us with the new one258if (this.request.acceptedConfirmationData?.some(isStartSetupTestConfirmation)) {259setTimeout(() => this.getResultInner());260return {};261}262263return this.getResultInner();264}265private async getResultInner(): Promise<ChatResult> {266const suggestion = this.documentContext && await this.setupTestsDetector.shouldSuggestSetup(this.documentContext, this.request, this.stream);267if (!suggestion) {268return super.getResult();269}270271let result: ChatResult = {};272if (suggestion.type === SetupTestActionType.Remind) {273result = await super.getResult();274}275276this.setupTestsDetector.showSuggestion(suggestion).forEach(p => this.stream.push(p));277278return result;279}280}281282283