Path: blob/main/extensions/copilot/src/extension/intents/node/testIntent/setupTestsInvocation.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 { BasePromptElementProps, PromptElement, PromptPiece, PromptSizing, RenderPromptResult, SystemMessage, UserMessage } from '@vscode/prompt-tsx';7import type * as vscode from 'vscode';8import { FetchStreamSource, IResponsePart } from '../../../../platform/chat/common/chatMLFetcher';9import { ChatFetchResponseType, ChatLocation } from '../../../../platform/chat/common/commonTypes';10import { IRunCommandExecutionService } from '../../../../platform/commands/common/runCommandExecutionService';11import { IExtensionsService } from '../../../../platform/extensions/common/extensionsService';12import { IPackageJson } from '../../../../platform/extensions/common/packageJson';13import { IChatEndpoint } from '../../../../platform/networking/common/networking';14import { ISetupTestExtension, testExtensionsForLanguage } from '../../../../platform/testing/common/setupTestExtensions';15import { IWorkspaceMutationManager } from '../../../../platform/testing/common/workspaceMutationManager';16import { SetupConfirmationResult } from '../../../../platform/testing/node/setupTestDetector';17import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';18import { mapFindFirst } from '../../../../util/vs/base/common/arraysFind';19import { CancellationToken } from '../../../../util/vs/base/common/cancellation';20import { Lazy } from '../../../../util/vs/base/common/lazy';21import { URI } from '../../../../util/vs/base/common/uri';22import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';23import { ChatResponseExtensionsPart } from '../../../../vscodeTypes';24import { convertFileTreeToChatResponseFileTree, listFilesInResponseFileTree } from '../../../prompt/common/fileTreeParser';25import { IBuildPromptContext } from '../../../prompt/common/intents';26import { IToken, StreamingGrammar } from '../../../prompt/common/streamingGrammar';27import { IIntent, IIntentInvocation, IResponseProcessorContext, nullRenderPromptResult } from '../../../prompt/node/intents';28import { CopilotIdentityRules } from '../../../prompts/node/base/copilotIdentity';29import { InstructionMessage } from '../../../prompts/node/base/instructionMessage';30import { PromptRenderer } from '../../../prompts/node/base/promptRenderer';31import { ResponseTranslationRules } from '../../../prompts/node/base/responseTranslationRules';32import { SafetyRules } from '../../../prompts/node/base/safetyRules';33import { ChatVariablesAndQuery } from '../../../prompts/node/panel/chatVariables';34import { EditorIntegrationRules } from '../../../prompts/node/panel/editorIntegrationRules';35import { WorkspaceStructure } from '../../../prompts/node/panel/workspace/workspaceStructure';36import { SetupTestFileScheme } from '../../../testing/common/files';37import { SetupTestsFrameworkQueryInvocationRaw } from './setupTestsFrameworkQueryInvocation';383940export class SetupTestsInvocation implements IIntentInvocation {41private delegatedSetup?: SetupConfirmationResult;42private setupConfirmation?: SetupConfirmationResult;43private buildPromptContext!: IBuildPromptContext;4445constructor(46public readonly intent: IIntent,47public readonly endpoint: IChatEndpoint,48public readonly location: ChatLocation,49private readonly prompt: string,50@IInstantiationService private readonly instantiationService: IInstantiationService,51@IWorkspaceService private readonly workspaceService: IWorkspaceService,52@IWorkspaceMutationManager private readonly workspaceMutationManager: IWorkspaceMutationManager,53@IExtensionsService private readonly extensionsService: IExtensionsService,54@IRunCommandExecutionService private readonly commandService: IRunCommandExecutionService55) {56}5758async buildPrompt(context: IBuildPromptContext, progress: vscode.Progress<vscode.ChatResponseReferencePart | vscode.ChatResponseProgressPart>, token: vscode.CancellationToken): Promise<RenderPromptResult> {59this.buildPromptContext = context;6061this.delegatedSetup = await this.delegateHandling();62if (this.delegatedSetup) {63return nullRenderPromptResult();64}6566this.setupConfirmation = await this.getSetupConfirmation();67const renderer = PromptRenderer.create(this.instantiationService, this.endpoint, SetupTestsPrompt, {68endpoint: this.endpoint,69promptContext: context,70query: this.prompt,71setupConfirmation: this.setupConfirmation,72});7374return renderer.render(progress, token);75}7677async processResponse(context: IResponseProcessorContext, inputStream: AsyncIterable<IResponsePart>, outputStream: vscode.ChatResponseStream, token: CancellationToken): Promise<void> {78const enum State {79LookingForTree,80FileTree,81FoundTree,82}8384const requestId = context.turn.id;8586const pushTokens = (tokens: Iterable<IToken<State>>) => {87for (const token of tokens) {88if (token.transitionTo === State.FileTree) {89// tokens are accumulated into a file tree is seen (generally the first90// thing in the response) to avoid printing a generic "what framework are91// you using?" if the user gave a generic questioning prompt92outputStream.markdown(grammar.accumulate(0, grammar.tokens.length - 1));93} else if (token.transitionTo === State.FoundTree) {94const tree = grammar.accumulate(undefined, undefined, State.FileTree);95this.handleFileTree(requestId, tree, outputStream);96} else if (token.transitionTo === undefined && token.state !== State.FileTree && grammar.visited(State.FileTree)) {97outputStream.markdown(token.token);98}99}100};101102const grammar = new StreamingGrammar(State.LookingForTree, {103[State.LookingForTree]: { '```filetree': State.FileTree },104[State.FileTree]: { '```': State.FoundTree },105});106107for await (const { delta } of inputStream) {108pushTokens(grammar.append(delta.text));109}110pushTokens(grammar.flush());111112if (this.delegatedSetup) {113outputStream.markdown(this.delegatedSetup.message);114}115116const command = this.setupConfirmation?.command || this.delegatedSetup?.command;117if (command) {118// prompt will already include the `message` at the end, just add the button as needed119outputStream.button(command);120} else if (grammar.visited(State.FileTree)) {121await this.recommendExtension(grammar.accumulate(undefined, undefined, State.LookingForTree), outputStream, token);122} else {123// if we never saw a file tree, automatically do the generic test setup124await this.doFrameworkQuery(context, outputStream, token);125}126}127128private async doFrameworkQuery(context: IResponseProcessorContext, outputStream: vscode.ChatResponseStream, token: CancellationToken) {129const invocation = this.instantiationService.createInstance(SetupTestsFrameworkQueryInvocationRaw, this.endpoint, undefined);130const prompt = await invocation.buildPrompt(this.buildPromptContext, undefined, token);131const inputStream = new FetchStreamSource();132const responseProcessing = invocation.processResponse(context, inputStream.stream, outputStream, token);133await this.endpoint.makeChatRequest(134'testSetupAutomaticFrameworkID',135prompt.messages,136(text, _, delta) => {137inputStream.update(text, delta);138return Promise.resolve(undefined);139},140token,141this.location,142);143144inputStream.resolve();145await responseProcessing;146}147148private async getSetupConfirmation() {149const extensionInfo = this.recommendedExtension.value;150const extension = extensionInfo ? this.extensionsService.getExtension(extensionInfo.id) : undefined;151const command = (extension?.packageJSON as IPackageJson)?.copilot?.tests?.getSetupConfirmation;152if (!command) {153return;154}155156let result: SetupConfirmationResult | undefined;157try {158result = await this.commandService.executeCommand(command);159} catch {160// ignored161}162163return result;164}165166private async delegateHandling() {167const extensionInfo = this.recommendedExtension.value;168const extension = extensionInfo ? this.extensionsService.getExtension(extensionInfo.id) : undefined;169const command = (extension?.packageJSON as IPackageJson)?.copilot?.tests?.setupTests;170return command ? await this.commandService.executeCommand(command) : undefined;171}172173private recommendedExtension = new Lazy(() => getKnownExtensionInText(this.prompt));174175private async recommendExtension(outputText: string, outputStream: vscode.ChatResponseStream, token: CancellationToken) {176let searchText: string;177let extensionInfo: ISetupTestExtension | undefined;178if (this.recommendedExtension.value) {179searchText = this.prompt;180extensionInfo = this.recommendedExtension.value;181} else {182searchText = await this.deriveFrameworkFromResponse(outputText, token);183extensionInfo = getKnownExtensionInText(searchText);184}185186if (extensionInfo && this.extensionsService.getExtension(extensionInfo.id)) {187return; // extension already installed188}189190outputStream.markdown('\n\n');191192if (extensionInfo) {193outputStream.markdown(l10n.t('I also recommend installing the {0} extension to make tests easy to run in VS Code:', extensionInfo.name));194outputStream.markdown('\n\n');195outputStream.push(new ChatResponseExtensionsPart([extensionInfo.id]));196} else {197outputStream.markdown(l10n.t('You can also search for an extension to make tests easy to run in VS Code:'));198outputStream.button({199command: 'workbench.extensions.search',200title: l10n.t('Search Extensions'),201arguments: [`@category:testing ${this.prompt}`]202});203}204}205206private async deriveFrameworkFromResponse(outputText: string, token: CancellationToken) {207const deriveResponsePrompt = await PromptRenderer.create(this.instantiationService, this.endpoint, TestFrameworkFromResponsePrompt, {208query: outputText,209}).render();210const fetchResult = await this.endpoint.makeChatRequest(211'setupTestDeriveName',212deriveResponsePrompt.messages,213undefined,214token,215ChatLocation.Panel216);217218219if (fetchResult.type !== ChatFetchResponseType.Success) {220return '';221}222223return fetchResult.value.replaceAll('`', '');224}225226private handleFileTree(requestId: string, tree: string, outputStream: vscode.ChatResponseStream) {227const workspaceFolder = this.workspaceService.getWorkspaceFolders().at(0);228if (!workspaceFolder) {229return;230}231232// todo: make the preview URI a diff for existing files233const { chatResponseTree } = convertFileTreeToChatResponseFileTree(tree, () => makePreviewUri(requestId));234235// Handle a root '[project-name]' or similar fake root node236const first = chatResponseTree.value[0];237if (chatResponseTree.value.length === 1 && /^\[.+\]$/.test(first.name) && first.children) {238chatResponseTree.value = first.children!;239}240241this.workspaceMutationManager.create(requestId, {242baseURI: workspaceFolder,243files: listFilesInResponseFileTree(chatResponseTree.value),244fileTree: tree,245query: this.prompt,246});247outputStream.push(chatResponseTree);248outputStream.button({249command: 'github.copilot.tests.applyMutations',250title: l10n.t('Apply Changes'),251arguments: [requestId],252});253}254}255256const projectNameToken = '[project-name]';257258function makePreviewUri(requestId: string, filePath?: string) {259return URI.from({260scheme: SetupTestFileScheme,261authority: requestId,262path: filePath ? `/${filePath}` : '/'263});264}265266interface SetupTestsPromptProps extends BasePromptElementProps {267promptContext: IBuildPromptContext;268query: string;269endpoint: IChatEndpoint;270setupConfirmation: SetupConfirmationResult | undefined;271}272273class SetupTestsPrompt extends PromptElement<SetupTestsPromptProps> {274override render(state: void, sizing: PromptSizing): PromptPiece<any, any> | undefined {275const { query, setupConfirmation } = this.props;276return <>277<SystemMessage priority={1000}>278You are a software engineer with expert knowledge around software testing frameworks.<br />279<br />280<CopilotIdentityRules />281<SafetyRules />282<EditorIntegrationRules />283<ResponseTranslationRules />284# Additional Rules<br />2851. The user will tell you what testing framework they want to set up, and provide you their workspace structure.<br />2862. Determine how to test up the desired testing framework.<br />2873. Generate a markdown file tree structure listing files you want to create or edit in order to set up the testing framework. The tree MUST NOT include files that don't need to be modified.<br />2884. Make sure to include a basic "hello world" test to help the user get started. If you see existing test files in the workspace, make sure to try to match their naming convention.<br />2895. Do not attempt to modify the file content yourself and simply respond with the file tree structure.<br />2906. After listing the file tree structure, respond with any terminal commands the user should execute to finish installing the testing framework. Terminal commands should be wrapped in a code fence tagged with the "sh" language.<br />2917. Finally, provide a command line a user can execute to run their tests.<br />292{setupConfirmation && <>2938. At the end, include a phrase that conveys '{setupConfirmation.message}', but rephrase this to indicate that this is the last step the user needs to take to enable rich UI integration in VS Code.{setupConfirmation.command && ` This message will be followed by a button that says "${setupConfirmation.command.title}".`}<br />294</>}295<br />296# Example<br />297## Question:<br />298I want to: set up mocha tests in the workspace<br />299I am working in a workspace that has the following structure:<br />{`\`\`\`300src/301index.ts302package.json303tsconfig.json304\`\`\``}305<br />306## Response:<br />307Let's create a `.mocharc.js` file to configure your test settings, as well as a "hello world" test:<br /><br />{`\`\`\`filetree308${projectNameToken}309├── src310│ └── index.test.ts311└── mocha.opts312\`\`\``}<br />313Then, we'll need to install Mocha in your workspace:314<br />315```sh<br />316npm install --save-dev mocha317```<br />318<br />319Finally, you can run your tests with the following command:<br />320```sh<br />321npx mocha<br />322```<br />323</SystemMessage>324<UserMessage flexGrow={2}>325<SetupWorkspaceStructure />326</UserMessage>327<ChatVariablesAndQuery flexGrow={2} priority={900} chatVariables={this.props.promptContext.chatVariables} query={`I want to: ${query}`} embeddedInsideUserMessage={false} />328</>;329}330}331332class SetupWorkspaceStructure extends PromptElement {333override render(_state: void, sizing: PromptSizing): PromptPiece {334return <WorkspaceStructure maxSize={(sizing.tokenBudget * 4) / 3} />;335}336}337338339class TestFrameworkFromResponsePrompt extends PromptElement<{ query: string } & BasePromptElementProps> {340override render(): PromptPiece<any, any> | undefined {341const { query } = this.props;342return <>343<InstructionMessage priority={1000}>344# Rules:<br />3451. The user will give you instructions they were told regarding how to set up a testing framework.<br />3462. Your job is to print the name of the testing framework referred to in the response.<br />3473. Do not print any other information except for the name of the framework.<br />348<br />349# Example<br />350## Question:<br />351Given the structure of your workspace, I recommend using Mocha for testing. To set up Mocha, you should create a `.mocharc.js` file to configure your test settings, as well as a "hello world" test.352<br />353## Response:<br />354mocha355</InstructionMessage>356<UserMessage>357{query}358</UserMessage>359</>;360}361}362363function getKnownExtensionInText(text: string) {364const haystack = text.toLowerCase();365return mapFindFirst(testExtensionsForLanguage.values(), ext => {366if (ext.forLanguage?.associatedFrameworks?.some(f => haystack.includes(f))) {367return ext.forLanguage.extension;368}369370return ext.perFramework && mapFindFirst(ext.perFramework, ([f, ext]) => haystack.includes(f) ? ext : undefined);371});372}373374375