Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/intents/node/testIntent/setupTestsInvocation.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 * as l10n from '@vscode/l10n';
7
import { BasePromptElementProps, PromptElement, PromptPiece, PromptSizing, RenderPromptResult, SystemMessage, UserMessage } from '@vscode/prompt-tsx';
8
import type * as vscode from 'vscode';
9
import { FetchStreamSource, IResponsePart } from '../../../../platform/chat/common/chatMLFetcher';
10
import { ChatFetchResponseType, ChatLocation } from '../../../../platform/chat/common/commonTypes';
11
import { IRunCommandExecutionService } from '../../../../platform/commands/common/runCommandExecutionService';
12
import { IExtensionsService } from '../../../../platform/extensions/common/extensionsService';
13
import { IPackageJson } from '../../../../platform/extensions/common/packageJson';
14
import { IChatEndpoint } from '../../../../platform/networking/common/networking';
15
import { ISetupTestExtension, testExtensionsForLanguage } from '../../../../platform/testing/common/setupTestExtensions';
16
import { IWorkspaceMutationManager } from '../../../../platform/testing/common/workspaceMutationManager';
17
import { SetupConfirmationResult } from '../../../../platform/testing/node/setupTestDetector';
18
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
19
import { mapFindFirst } from '../../../../util/vs/base/common/arraysFind';
20
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
21
import { Lazy } from '../../../../util/vs/base/common/lazy';
22
import { URI } from '../../../../util/vs/base/common/uri';
23
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
24
import { ChatResponseExtensionsPart } from '../../../../vscodeTypes';
25
import { convertFileTreeToChatResponseFileTree, listFilesInResponseFileTree } from '../../../prompt/common/fileTreeParser';
26
import { IBuildPromptContext } from '../../../prompt/common/intents';
27
import { IToken, StreamingGrammar } from '../../../prompt/common/streamingGrammar';
28
import { IIntent, IIntentInvocation, IResponseProcessorContext, nullRenderPromptResult } from '../../../prompt/node/intents';
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 { ResponseTranslationRules } from '../../../prompts/node/base/responseTranslationRules';
33
import { SafetyRules } from '../../../prompts/node/base/safetyRules';
34
import { ChatVariablesAndQuery } from '../../../prompts/node/panel/chatVariables';
35
import { EditorIntegrationRules } from '../../../prompts/node/panel/editorIntegrationRules';
36
import { WorkspaceStructure } from '../../../prompts/node/panel/workspace/workspaceStructure';
37
import { SetupTestFileScheme } from '../../../testing/common/files';
38
import { SetupTestsFrameworkQueryInvocationRaw } from './setupTestsFrameworkQueryInvocation';
39
40
41
export class SetupTestsInvocation implements IIntentInvocation {
42
private delegatedSetup?: SetupConfirmationResult;
43
private setupConfirmation?: SetupConfirmationResult;
44
private buildPromptContext!: IBuildPromptContext;
45
46
constructor(
47
public readonly intent: IIntent,
48
public readonly endpoint: IChatEndpoint,
49
public readonly location: ChatLocation,
50
private readonly prompt: string,
51
@IInstantiationService private readonly instantiationService: IInstantiationService,
52
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
53
@IWorkspaceMutationManager private readonly workspaceMutationManager: IWorkspaceMutationManager,
54
@IExtensionsService private readonly extensionsService: IExtensionsService,
55
@IRunCommandExecutionService private readonly commandService: IRunCommandExecutionService
56
) {
57
}
58
59
async buildPrompt(context: IBuildPromptContext, progress: vscode.Progress<vscode.ChatResponseReferencePart | vscode.ChatResponseProgressPart>, token: vscode.CancellationToken): Promise<RenderPromptResult> {
60
this.buildPromptContext = context;
61
62
this.delegatedSetup = await this.delegateHandling();
63
if (this.delegatedSetup) {
64
return nullRenderPromptResult();
65
}
66
67
this.setupConfirmation = await this.getSetupConfirmation();
68
const renderer = PromptRenderer.create(this.instantiationService, this.endpoint, SetupTestsPrompt, {
69
endpoint: this.endpoint,
70
promptContext: context,
71
query: this.prompt,
72
setupConfirmation: this.setupConfirmation,
73
});
74
75
return renderer.render(progress, token);
76
}
77
78
async processResponse(context: IResponseProcessorContext, inputStream: AsyncIterable<IResponsePart>, outputStream: vscode.ChatResponseStream, token: CancellationToken): Promise<void> {
79
const enum State {
80
LookingForTree,
81
FileTree,
82
FoundTree,
83
}
84
85
const requestId = context.turn.id;
86
87
const pushTokens = (tokens: Iterable<IToken<State>>) => {
88
for (const token of tokens) {
89
if (token.transitionTo === State.FileTree) {
90
// tokens are accumulated into a file tree is seen (generally the first
91
// thing in the response) to avoid printing a generic "what framework are
92
// you using?" if the user gave a generic questioning prompt
93
outputStream.markdown(grammar.accumulate(0, grammar.tokens.length - 1));
94
} else if (token.transitionTo === State.FoundTree) {
95
const tree = grammar.accumulate(undefined, undefined, State.FileTree);
96
this.handleFileTree(requestId, tree, outputStream);
97
} else if (token.transitionTo === undefined && token.state !== State.FileTree && grammar.visited(State.FileTree)) {
98
outputStream.markdown(token.token);
99
}
100
}
101
};
102
103
const grammar = new StreamingGrammar(State.LookingForTree, {
104
[State.LookingForTree]: { '```filetree': State.FileTree },
105
[State.FileTree]: { '```': State.FoundTree },
106
});
107
108
for await (const { delta } of inputStream) {
109
pushTokens(grammar.append(delta.text));
110
}
111
pushTokens(grammar.flush());
112
113
if (this.delegatedSetup) {
114
outputStream.markdown(this.delegatedSetup.message);
115
}
116
117
const command = this.setupConfirmation?.command || this.delegatedSetup?.command;
118
if (command) {
119
// prompt will already include the `message` at the end, just add the button as needed
120
outputStream.button(command);
121
} else if (grammar.visited(State.FileTree)) {
122
await this.recommendExtension(grammar.accumulate(undefined, undefined, State.LookingForTree), outputStream, token);
123
} else {
124
// if we never saw a file tree, automatically do the generic test setup
125
await this.doFrameworkQuery(context, outputStream, token);
126
}
127
}
128
129
private async doFrameworkQuery(context: IResponseProcessorContext, outputStream: vscode.ChatResponseStream, token: CancellationToken) {
130
const invocation = this.instantiationService.createInstance(SetupTestsFrameworkQueryInvocationRaw, this.endpoint, undefined);
131
const prompt = await invocation.buildPrompt(this.buildPromptContext, undefined, token);
132
const inputStream = new FetchStreamSource();
133
const responseProcessing = invocation.processResponse(context, inputStream.stream, outputStream, token);
134
await this.endpoint.makeChatRequest(
135
'testSetupAutomaticFrameworkID',
136
prompt.messages,
137
(text, _, delta) => {
138
inputStream.update(text, delta);
139
return Promise.resolve(undefined);
140
},
141
token,
142
this.location,
143
);
144
145
inputStream.resolve();
146
await responseProcessing;
147
}
148
149
private async getSetupConfirmation() {
150
const extensionInfo = this.recommendedExtension.value;
151
const extension = extensionInfo ? this.extensionsService.getExtension(extensionInfo.id) : undefined;
152
const command = (extension?.packageJSON as IPackageJson)?.copilot?.tests?.getSetupConfirmation;
153
if (!command) {
154
return;
155
}
156
157
let result: SetupConfirmationResult | undefined;
158
try {
159
result = await this.commandService.executeCommand(command);
160
} catch {
161
// ignored
162
}
163
164
return result;
165
}
166
167
private async delegateHandling() {
168
const extensionInfo = this.recommendedExtension.value;
169
const extension = extensionInfo ? this.extensionsService.getExtension(extensionInfo.id) : undefined;
170
const command = (extension?.packageJSON as IPackageJson)?.copilot?.tests?.setupTests;
171
return command ? await this.commandService.executeCommand(command) : undefined;
172
}
173
174
private recommendedExtension = new Lazy(() => getKnownExtensionInText(this.prompt));
175
176
private async recommendExtension(outputText: string, outputStream: vscode.ChatResponseStream, token: CancellationToken) {
177
let searchText: string;
178
let extensionInfo: ISetupTestExtension | undefined;
179
if (this.recommendedExtension.value) {
180
searchText = this.prompt;
181
extensionInfo = this.recommendedExtension.value;
182
} else {
183
searchText = await this.deriveFrameworkFromResponse(outputText, token);
184
extensionInfo = getKnownExtensionInText(searchText);
185
}
186
187
if (extensionInfo && this.extensionsService.getExtension(extensionInfo.id)) {
188
return; // extension already installed
189
}
190
191
outputStream.markdown('\n\n');
192
193
if (extensionInfo) {
194
outputStream.markdown(l10n.t('I also recommend installing the {0} extension to make tests easy to run in VS Code:', extensionInfo.name));
195
outputStream.markdown('\n\n');
196
outputStream.push(new ChatResponseExtensionsPart([extensionInfo.id]));
197
} else {
198
outputStream.markdown(l10n.t('You can also search for an extension to make tests easy to run in VS Code:'));
199
outputStream.button({
200
command: 'workbench.extensions.search',
201
title: l10n.t('Search Extensions'),
202
arguments: [`@category:testing ${this.prompt}`]
203
});
204
}
205
}
206
207
private async deriveFrameworkFromResponse(outputText: string, token: CancellationToken) {
208
const deriveResponsePrompt = await PromptRenderer.create(this.instantiationService, this.endpoint, TestFrameworkFromResponsePrompt, {
209
query: outputText,
210
}).render();
211
const fetchResult = await this.endpoint.makeChatRequest(
212
'setupTestDeriveName',
213
deriveResponsePrompt.messages,
214
undefined,
215
token,
216
ChatLocation.Panel
217
);
218
219
220
if (fetchResult.type !== ChatFetchResponseType.Success) {
221
return '';
222
}
223
224
return fetchResult.value.replaceAll('`', '');
225
}
226
227
private handleFileTree(requestId: string, tree: string, outputStream: vscode.ChatResponseStream) {
228
const workspaceFolder = this.workspaceService.getWorkspaceFolders().at(0);
229
if (!workspaceFolder) {
230
return;
231
}
232
233
// todo: make the preview URI a diff for existing files
234
const { chatResponseTree } = convertFileTreeToChatResponseFileTree(tree, () => makePreviewUri(requestId));
235
236
// Handle a root '[project-name]' or similar fake root node
237
const first = chatResponseTree.value[0];
238
if (chatResponseTree.value.length === 1 && /^\[.+\]$/.test(first.name) && first.children) {
239
chatResponseTree.value = first.children!;
240
}
241
242
this.workspaceMutationManager.create(requestId, {
243
baseURI: workspaceFolder,
244
files: listFilesInResponseFileTree(chatResponseTree.value),
245
fileTree: tree,
246
query: this.prompt,
247
});
248
outputStream.push(chatResponseTree);
249
outputStream.button({
250
command: 'github.copilot.tests.applyMutations',
251
title: l10n.t('Apply Changes'),
252
arguments: [requestId],
253
});
254
}
255
}
256
257
const projectNameToken = '[project-name]';
258
259
function makePreviewUri(requestId: string, filePath?: string) {
260
return URI.from({
261
scheme: SetupTestFileScheme,
262
authority: requestId,
263
path: filePath ? `/${filePath}` : '/'
264
});
265
}
266
267
interface SetupTestsPromptProps extends BasePromptElementProps {
268
promptContext: IBuildPromptContext;
269
query: string;
270
endpoint: IChatEndpoint;
271
setupConfirmation: SetupConfirmationResult | undefined;
272
}
273
274
class SetupTestsPrompt extends PromptElement<SetupTestsPromptProps> {
275
override render(state: void, sizing: PromptSizing): PromptPiece<any, any> | undefined {
276
const { query, setupConfirmation } = this.props;
277
return <>
278
<SystemMessage priority={1000}>
279
You are a software engineer with expert knowledge around software testing frameworks.<br />
280
<br />
281
<CopilotIdentityRules />
282
<SafetyRules />
283
<EditorIntegrationRules />
284
<ResponseTranslationRules />
285
# Additional Rules<br />
286
1. The user will tell you what testing framework they want to set up, and provide you their workspace structure.<br />
287
2. Determine how to test up the desired testing framework.<br />
288
3. 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 />
289
4. 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 />
290
5. Do not attempt to modify the file content yourself and simply respond with the file tree structure.<br />
291
6. 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 />
292
7. Finally, provide a command line a user can execute to run their tests.<br />
293
{setupConfirmation && <>
294
8. 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 />
295
</>}
296
<br />
297
# Example<br />
298
## Question:<br />
299
I want to: set up mocha tests in the workspace<br />
300
I am working in a workspace that has the following structure:<br />{`\`\`\`
301
src/
302
index.ts
303
package.json
304
tsconfig.json
305
\`\`\``}
306
<br />
307
## Response:<br />
308
Let's create a `.mocharc.js` file to configure your test settings, as well as a "hello world" test:<br /><br />{`\`\`\`filetree
309
${projectNameToken}
310
├── src
311
│ └── index.test.ts
312
└── mocha.opts
313
\`\`\``}<br />
314
Then, we'll need to install Mocha in your workspace:
315
<br />
316
```sh<br />
317
npm install --save-dev mocha
318
```<br />
319
<br />
320
Finally, you can run your tests with the following command:<br />
321
```sh<br />
322
npx mocha<br />
323
```<br />
324
</SystemMessage>
325
<UserMessage flexGrow={2}>
326
<SetupWorkspaceStructure />
327
</UserMessage>
328
<ChatVariablesAndQuery flexGrow={2} priority={900} chatVariables={this.props.promptContext.chatVariables} query={`I want to: ${query}`} embeddedInsideUserMessage={false} />
329
</>;
330
}
331
}
332
333
class SetupWorkspaceStructure extends PromptElement {
334
override render(_state: void, sizing: PromptSizing): PromptPiece {
335
return <WorkspaceStructure maxSize={(sizing.tokenBudget * 4) / 3} />;
336
}
337
}
338
339
340
class TestFrameworkFromResponsePrompt extends PromptElement<{ query: string } & BasePromptElementProps> {
341
override render(): PromptPiece<any, any> | undefined {
342
const { query } = this.props;
343
return <>
344
<InstructionMessage priority={1000}>
345
# Rules:<br />
346
1. The user will give you instructions they were told regarding how to set up a testing framework.<br />
347
2. Your job is to print the name of the testing framework referred to in the response.<br />
348
3. Do not print any other information except for the name of the framework.<br />
349
<br />
350
# Example<br />
351
## Question:<br />
352
Given 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.
353
<br />
354
## Response:<br />
355
mocha
356
</InstructionMessage>
357
<UserMessage>
358
{query}
359
</UserMessage>
360
</>;
361
}
362
}
363
364
function getKnownExtensionInText(text: string) {
365
const haystack = text.toLowerCase();
366
return mapFindFirst(testExtensionsForLanguage.values(), ext => {
367
if (ext.forLanguage?.associatedFrameworks?.some(f => haystack.includes(f))) {
368
return ext.forLanguage.extension;
369
}
370
371
return ext.perFramework && mapFindFirst(ext.perFramework, ([f, ext]) => haystack.includes(f) ? ext : undefined);
372
});
373
}
374
375