Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/simulation/inlineChatSimulator.ts
13388 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
import assert from 'assert';
6
import * as path from 'path';
7
import type * as vscode from 'vscode';
8
import { Intent } from '../../src/extension/common/constants';
9
import { CopilotInteractiveEditorResponse, InteractionOutcome, InteractionOutcomeComputer } from '../../src/extension/inlineChat/node/promptCraftingTypes';
10
import { ICopilotChatResult } from '../../src/extension/prompt/common/conversation';
11
import { ChatParticipantRequestHandler, IChatAgentArgs } from '../../src/extension/prompt/node/chatParticipantRequestHandler';
12
import { guessFileIndentInfo } from '../../src/extension/prompt/node/indentationGuesser';
13
import { IntentDetector } from '../../src/extension/prompt/node/intentDetector';
14
import { IIntent } from '../../src/extension/prompt/node/intents';
15
import { WorkingCopyOriginalDocument } from '../../src/extension/prompts/node/inline/workingCopies';
16
import { IToolsService } from '../../src/extension/tools/common/toolsService';
17
import { TestEditFileTool } from '../../src/extension/tools/node/test/testTools';
18
import { TestToolsService } from '../../src/extension/tools/node/test/testToolsService';
19
import { editorAgentName, getChatParticipantIdFromName } from '../../src/platform/chat/common/chatAgents';
20
import { IChatMLFetcher } from '../../src/platform/chat/common/chatMLFetcher';
21
import { ILanguageDiagnosticsService } from '../../src/platform/languages/common/languageDiagnosticsService';
22
import { ILanguageFeaturesService } from '../../src/platform/languages/common/languageFeaturesService';
23
import { ITabsAndEditorsService } from '../../src/platform/tabs/common/tabsAndEditorsService';
24
import { isInExtensionHost } from '../../src/platform/test/node/isInExtensionHost';
25
import { IDeserializedWorkspaceState } from '../../src/platform/test/node/promptContextModel';
26
import { ITestingServicesAccessor, TestingServiceCollection } from '../../src/platform/test/node/services';
27
import { IFile, isNotebook, SimulationWorkspace } from '../../src/platform/test/node/simulationWorkspace';
28
import { ChatResponseStreamImpl } from '../../src/util/common/chatResponseStreamImpl';
29
import { getLanguage, getLanguageForResource } from '../../src/util/common/languages';
30
import { ChatRequestTurn, ChatResponseTurn } from '../../src/util/common/test/shims/chatTypes';
31
import { ExtHostNotebookDocumentData } from '../../src/util/common/test/shims/notebookDocument';
32
import { createTextDocumentData, IExtHostDocumentData } from '../../src/util/common/test/shims/textDocument';
33
import { CancellationToken } from '../../src/util/vs/base/common/cancellation';
34
import { ResourceMap } from '../../src/util/vs/base/common/map';
35
import { isEqual } from '../../src/util/vs/base/common/resources';
36
import { commonPrefixLength, commonSuffixLength } from '../../src/util/vs/base/common/strings';
37
import { URI } from '../../src/util/vs/base/common/uri';
38
import { SyncDescriptor } from '../../src/util/vs/platform/instantiation/common/descriptors';
39
import { IInstantiationService } from '../../src/util/vs/platform/instantiation/common/instantiation';
40
import { ChatLocation, ChatReferenceDiagnostic, ChatRequest, ChatRequestEditorData, ChatResponseMarkdownPart, ChatResponseNotebookEditPart, ChatResponseTextEditPart, Diagnostic, DiagnosticRelatedInformation, LanguageModelToolResult, Location, NotebookRange, Range, Selection, TextEdit, Uri, WorkspaceEdit } from '../../src/vscodeTypes';
41
import { SimulationExtHostToolsService } from '../base/extHostContext/simulationExtHostToolsService';
42
import { SimulationWorkspaceExtHost } from '../base/extHostContext/simulationWorkspaceExtHost';
43
import { SpyingChatMLFetcher } from '../base/spyingChatMLFetcher';
44
import { ISimulationTestRuntime, NonExtensionConfiguration } from '../base/stest';
45
import { createWorkingSetFileVariable, parseQueryForTest } from '../e2e/testHelper';
46
import { readBuiltinIntents } from '../intent/intentTest';
47
import { getDiagnostics } from './diagnosticProviders';
48
import { convertTestToVSCodeDiagnostics } from './diagnosticProviders/utils';
49
import { SimulationLanguageFeaturesService } from './language/simulationLanguageFeatureService';
50
import { IDiagnostic, IDiagnosticComparison, INLINE_CHANGED_DOC_TAG, INLINE_INITIAL_DOC_TAG, INLINE_STATE_TAG, IRange, IWorkspaceState, IWorkspaceStateFile } from './shared/sharedTypes';
51
import { DiagnosticProviderId, EditTestStrategy, IDeserializedWorkspaceStateBasedScenario, IInlineEdit, IOutcome, IScenario, IScenarioDiagnostic, IScenarioQuery, OutcomeAnnotation } from './types';
52
53
export type SimulationWorkspaceInput = { files: IFile[]; workspaceFolders?: Uri[] } | { workspaceState: IDeserializedWorkspaceState };
54
55
export function setupSimulationWorkspace(testingServiceCollection: TestingServiceCollection, input: SimulationWorkspaceInput): SimulationWorkspace {
56
const workspace = isInExtensionHost ? new SimulationWorkspaceExtHost() : new SimulationWorkspace();
57
if ('workspaceState' in input) {
58
workspace.resetFromDeserializedWorkspaceState(input.workspaceState);
59
} else {
60
workspace.resetFromFiles(input.files, input.workspaceFolders);
61
}
62
workspace.setupServices(testingServiceCollection);
63
testingServiceCollection.define(ILanguageFeaturesService, new SyncDescriptor(SimulationLanguageFeaturesService, [workspace]));
64
return workspace;
65
}
66
67
export async function teardownSimulationWorkspace(accessor: ITestingServicesAccessor, workbench: SimulationWorkspace): Promise<void> {
68
const ls = accessor.get(ILanguageFeaturesService);
69
if (ls instanceof SimulationLanguageFeaturesService) {
70
await ls.teardown();
71
}
72
workbench.dispose();
73
}
74
75
function isDeserializedWorkspaceStateBasedScenario(scenario: IScenario): scenario is IDeserializedWorkspaceStateBasedScenario {
76
return 'workspaceState' in scenario;
77
}
78
79
export function simulateInlineChatWithStrategy(strategy: EditTestStrategy, testingServiceCollection: TestingServiceCollection, scenario: IScenario) {
80
81
if (strategy === EditTestStrategy.InlineChatIntent) {
82
return simulateInlineChatIntent(testingServiceCollection, scenario);
83
} else {
84
return simulateInlineChat(testingServiceCollection, scenario);
85
}
86
}
87
88
export async function simulateInlineChat(
89
testingServiceCollection: TestingServiceCollection,
90
scenario: IScenario
91
): Promise<void> {
92
const host: EditingSimulationHost = {
93
prepareChatRequestLocation: (accessor: ITestingServicesAccessor, wholeRange?: Range) => {
94
const editor = accessor.get(ITabsAndEditorsService).activeTextEditor;
95
if (!editor) {
96
throw new Error(`No active editor`);
97
}
98
return {
99
location: ChatLocation.Editor,
100
location2: new ChatRequestEditorData(editor, editor.document, editor.selection, wholeRange ?? editor.selection),
101
};
102
}
103
};
104
return simulateEditingScenario(testingServiceCollection, scenario, host);
105
}
106
107
class ChatReferenceDiagnostic2 extends ChatReferenceDiagnostic {
108
constructor(uri: Uri, d: Diagnostic) {
109
super([[uri, [d]]]);
110
}
111
}
112
113
export async function simulateInlineChatIntent(
114
testingServiceCollection: TestingServiceCollection,
115
scenario: IScenario
116
): Promise<void> {
117
118
const overrideCommand = `/${Intent.InlineChat}`;
119
120
const ensureSlashEdit = (query: string) => {
121
return query.startsWith(overrideCommand) ? query : `${overrideCommand} ${query}`;
122
};
123
const prependEditToUserQueries = (queries: IScenarioQuery[]) => {
124
return queries.map(scenarioQuery => {
125
return {
126
...scenarioQuery,
127
query: ensureSlashEdit(scenarioQuery.query),
128
};
129
});
130
};
131
132
const massagedScenario = { ...scenario, queries: prependEditToUserQueries(scenario.queries) };
133
134
const host: EditingSimulationHost = {
135
prepareChatRequestLocation: (accessor: ITestingServicesAccessor, wholeRange?: Range) => {
136
const editor = accessor.get(ITabsAndEditorsService).activeTextEditor;
137
if (!editor) {
138
throw new Error(`No active editor`);
139
}
140
return {
141
location: ChatLocation.Editor,
142
location2: new ChatRequestEditorData(editor, editor.document, editor.selection, wholeRange ?? editor.selection),
143
};
144
},
145
contributeAdditionalReferences(accessor, existingReferences) {
146
const diagnosticService = accessor.get(ILanguageDiagnosticsService);
147
const editor = accessor.get(ITabsAndEditorsService).activeTextEditor;
148
if (!editor) {
149
return existingReferences.slice();
150
}
151
152
const result = existingReferences.slice();
153
154
const diagnostics = diagnosticService.getDiagnostics(editor.document.uri);
155
156
for (const d of diagnostics) {
157
if (d.range.intersection(editor.selection)) {
158
result.push({
159
id: `diagnostic/${editor.document.uri}/${JSON.stringify(d)}`,
160
name: d.message,
161
value: new ChatReferenceDiagnostic2(editor.document.uri, d)
162
});
163
}
164
}
165
166
return result;
167
},
168
};
169
return simulateEditingScenario(testingServiceCollection, massagedScenario, host);
170
}
171
172
export type EditingSimulationHostResponseProcessor = {
173
spyOnStream(stream: vscode.ChatResponseStream): vscode.ChatResponseStream;
174
postProcess(accessor: ITestingServicesAccessor, workspace: SimulationWorkspace, stream: vscode.ChatResponseStream, result?: ICopilotChatResult): Promise<OutcomeAnnotation[]>;
175
};
176
177
export interface EditingSimulationHost {
178
agentArgs?: IChatAgentArgs;
179
prepareChatRequestLocation(accessor: ITestingServicesAccessor, range?: Range): {
180
location: vscode.ChatLocation;
181
location2: vscode.ChatRequestEditorData | undefined;
182
};
183
contributeAdditionalReferences?: (accessor: ITestingServicesAccessor, existingReferences: readonly vscode.ChatPromptReference[]) => vscode.ChatPromptReference[];
184
provideResponseProcessor?: (query: IScenarioQuery) => EditingSimulationHostResponseProcessor;
185
}
186
187
188
export async function simulateEditingScenario(
189
testingServiceCollection: TestingServiceCollection,
190
scenario: IScenario,
191
host: EditingSimulationHost
192
): Promise<void> {
193
assert(scenario.queries.length > 0, `Cannot simulate scenario with no queries`);
194
assert(isDeserializedWorkspaceStateBasedScenario(scenario) || scenario.files.length > 0, `Cannot simulate scenario with no files`);
195
196
const workspace = setupSimulationWorkspace(testingServiceCollection, scenario);
197
198
await scenario.extraWorkspaceSetup?.(workspace);
199
const accessor = testingServiceCollection.createTestingAccessor();
200
await scenario.onBeforeStart?.(accessor);
201
202
const instaService = accessor.get(IInstantiationService);
203
const testRuntime = accessor.get(ISimulationTestRuntime);
204
205
const states: IWorkspaceState[] = [];
206
let range: Range | undefined;
207
let isFirst = true;
208
const history: (ChatRequestTurn | ChatResponseTurn)[] = [];
209
/**
210
* A map from doc to relative path with initial contents which is populated right before modifying a document.
211
*/
212
const changedDocsInitialStates = new Map<vscode.TextDocument, Promise<IWorkspaceStateFile> | null>();
213
214
// run each query for the scenario
215
try {
216
const seenFiles: vscode.ChatPromptReference[] = [];
217
218
for (const query of scenario.queries) {
219
220
if (query.file) {
221
if (isNotebook(query.file)) {
222
const notebook = workspace.getNotebook(query.file);
223
if (!notebook) {
224
throw new Error(`Missing notebook file ${query.file}`);
225
}
226
227
const cell = notebook.cellAt(query.activeCell ?? 0);
228
if (!cell) {
229
throw new Error(`Missing cell ${query.activeCell} in notebook file ${query.file}`);
230
}
231
232
workspace.addNotebookDocument(notebook);
233
workspace.setCurrentNotebookDocument(notebook);
234
workspace.setCurrentDocument(cell.document.uri);
235
} else if (typeof query.file !== 'string') {
236
workspace.setCurrentDocument(query.file);
237
} else {
238
workspace.setCurrentDocument(
239
workspace.getDocument(query.file).document.uri);
240
}
241
}
242
243
if (query.selection) {
244
const selection = toSelection(query.selection);
245
workspace.setCurrentSelection(selection);
246
}
247
248
if (query.visibleRanges) {
249
workspace.setCurrentVisibleRanges(query.visibleRanges.map((range) => toRange(range)));
250
}
251
252
if (query.activeCell) {
253
const cellSelection = new NotebookRange(query.activeCell, query.activeCell + 1);
254
workspace.setCurrentNotebookSelection([cellSelection]);
255
}
256
257
const queryWholeRange = query.wholeRange ? toSelection(query.wholeRange) : undefined;
258
259
const activeEditor = accessor.get(ITabsAndEditorsService).activeTextEditor;
260
if (query.file && !activeEditor) {
261
throw new Error(`query.file is defined but no editor is active`);
262
}
263
264
let initialDiagnostics: ResourceMap<vscode.Diagnostic[]> | undefined;
265
266
if (typeof query.diagnostics === 'string') {
267
// diagnostics are computed
268
try {
269
initialDiagnostics = await fetchDiagnostics(accessor, workspace, query.diagnostics);
270
workspace.setDiagnostics(initialDiagnostics);
271
} catch (error) {
272
throw new Error(`Error obtained while fetching the diagnostics: ${error}`);
273
}
274
} else if (Array.isArray(query.diagnostics)) {
275
if (!activeEditor) {
276
throw new Error(`diagnostics can only be an array if there's an active editor (is 'file' specified?)`);
277
}
278
// diagnostics are set explicitly
279
const diagnostics = new ResourceMap<vscode.Diagnostic[]>();
280
diagnostics.set(activeEditor.document.uri, convertToDiagnostics(workspace, query.diagnostics));
281
workspace.setDiagnostics(diagnostics);
282
}
283
284
if (query.fileIndentInfo) {
285
workspace.setCurrentDocumentIndentInfo(query.fileIndentInfo);
286
} else if (activeEditor) {
287
workspace.setCurrentDocumentIndentInfo(guessFileIndentInfo(activeEditor.document));
288
}
289
if (isFirst && activeEditor) {
290
isFirst = false;
291
range = activeEditor.selection;
292
const documentUri = activeEditor.document.uri;
293
const workspacePath = workspace.getFilePath(documentUri);
294
let relativeDiskPath: string | undefined;
295
if (isNotebook(documentUri)) {
296
const notebookDocument = workspace.getNotebook(documentUri);
297
if (!notebookDocument) {
298
throw new Error(`Missing notebook document ${documentUri}`);
299
}
300
301
relativeDiskPath = await testRuntime.writeFile(workspacePath + '.txt', notebookDocument.getText(), INLINE_INITIAL_DOC_TAG); // using .txt instead of real file extension to avoid breaking automation scripts
302
} else {
303
relativeDiskPath = await testRuntime.writeFile(workspacePath + '.txt', activeEditor.document.getText(), INLINE_INITIAL_DOC_TAG); // using .txt instead of real file extension to avoid breaking automation scripts
304
}
305
changedDocsInitialStates.set(activeEditor.document, null); // just mark that it doesn't get written twice
306
307
if (!relativeDiskPath) {
308
throw new Error(`Failed to write initial document to disk`);
309
}
310
311
states.push({
312
kind: 'initial',
313
file: {
314
workspacePath,
315
relativeDiskPath,
316
languageId: activeEditor.document.languageId
317
},
318
additionalFiles: [],
319
languageId: getLanguage(activeEditor.document).languageId,
320
selection: toIRange(activeEditor.selection),
321
range: toIRange(range),
322
diagnostics: workspace.activeFileDiagnostics.map(toIDiagnostic),
323
});
324
} else {
325
range = queryWholeRange ?? range;
326
states.push({
327
kind: 'initial',
328
additionalFiles: [],
329
diagnostics: workspace.activeFileDiagnostics.map(toIDiagnostic),
330
});
331
}
332
333
334
let command: string | undefined;
335
let prompt = query.query;
336
if (prompt.startsWith('/')) {
337
const groups = /\/(?<intentId>\w+)(?<restOfQuery>\s.*)?/s.exec(query.query)?.groups;
338
command = groups?.intentId ?? undefined;
339
prompt = groups?.restOfQuery?.trim() ?? '';
340
}
341
342
const changedDocs: vscode.TextDocument[] = [];
343
const references: vscode.ChatPromptReference[] = [...seenFiles];
344
const toolReferences: vscode.ChatLanguageModelToolReference[] = [];
345
346
try {
347
const parsedQuery = parseQueryForTest(accessor, prompt, workspace);
348
349
for (const variable of parsedQuery.variables) {
350
if (!URI.isUri(variable.value)) {
351
references.push(variable);
352
continue;
353
}
354
const uri = variable.value;
355
if (!seenFiles.find(ref => URI.isUri(ref.value) && isEqual(ref.value, uri))) {
356
seenFiles.push(variable);
357
references.push(variable);
358
}
359
}
360
361
toolReferences.push(...parsedQuery.toolReferences);
362
} catch (error) {
363
// No problem!
364
}
365
366
references.push(...(host.contributeAdditionalReferences?.(accessor, references) ?? []));
367
368
const { location, location2 } = host.prepareChatRequestLocation(accessor, range);
369
let request: vscode.ChatRequest = {
370
location,
371
location2,
372
command,
373
prompt,
374
references,
375
attempt: 0,
376
isParticipantDetected: false,
377
enableCommandDetection: true, // TODO@ulugbekna: add support for disabling intent detection?
378
toolReferences,
379
toolInvocationToken: (isInExtensionHost ? undefined : {}) as never,
380
model: null!, // https://github.com/microsoft/vscode-copilot/issues/9475
381
tools: new Map(),
382
id: '1',
383
sessionId: '1',
384
sessionResource: Uri.parse('chat:/1'),
385
hasHooksEnabled: false,
386
};
387
388
// Run intent detection
389
if (!request.command) {
390
const intentDetector = instaService.createInstance(IntentDetector);
391
const participants = readBuiltinIntents(location);
392
const detectedParticipant = await intentDetector.provideParticipantDetection(request, { history, yieldRequested: false }, { participants, location: ChatLocation.Editor }, CancellationToken.None);
393
if (detectedParticipant?.command) {
394
request = { ...request, command: detectedParticipant.command };
395
}
396
}
397
398
const markdownChunks: string[] = [];
399
const changedDocuments = new ResourceMap<WorkingCopyOriginalDocument>();
400
let hasActualEdits = false;
401
let stream: vscode.ChatResponseStream = new ChatResponseStreamImpl((value) => {
402
if (value instanceof ChatResponseTextEditPart && value.edits.length > 0) {
403
const { uri, edits } = value;
404
405
let doc: IExtHostDocumentData;
406
if (!workspace.hasDocument(uri)) {
407
// this is a new file
408
const language = getLanguageForResource(uri);
409
doc = createTextDocumentData(uri, '', language.languageId);
410
workspace.addDocument(doc);
411
} else {
412
doc = workspace.getDocument(uri);
413
if (!changedDocsInitialStates.has(doc.document)) {
414
const workspacePath = workspace.getFilePath(doc.document.uri);
415
const workspaceStateFilePromise = testRuntime.writeFile(workspacePath, doc.document.getText(), INLINE_CHANGED_DOC_TAG).then((relativeDiskPath) => {
416
return {
417
workspacePath,
418
relativeDiskPath,
419
languageId: doc.document.languageId
420
};
421
});
422
changedDocsInitialStates.set(doc.document, workspaceStateFilePromise);
423
}
424
}
425
426
let workingCopyDocument = changedDocuments.get(uri);
427
if (!workingCopyDocument) {
428
workingCopyDocument = new WorkingCopyOriginalDocument(doc.document.getText());
429
changedDocuments.set(uri, workingCopyDocument);
430
}
431
432
const offsetEdits = workingCopyDocument.transformer.toOffsetEdit(edits);
433
if (!workingCopyDocument.isNoop(offsetEdits)) {
434
hasActualEdits = true;
435
workingCopyDocument.applyOffsetEdits(offsetEdits);
436
changedDocs.push(doc.document);
437
if (activeEditor && isEqual(doc.document.uri, activeEditor.document.uri)) {
438
// edit in the same document, adjust the range
439
range = applyEditsAndExpandRange(workspace, activeEditor.document, edits, range);
440
} else {
441
workspace.applyEdits(doc.document.uri, edits);
442
}
443
}
444
445
} else if (value instanceof ChatResponseNotebookEditPart) {
446
const { uri, edits } = value;
447
const validEdits = edits.filter(edit => typeof edit !== 'boolean');
448
449
let notebookDoc: ExtHostNotebookDocumentData;
450
451
if (!workspace.hasNotebookDocument(uri)) {
452
notebookDoc = ExtHostNotebookDocumentData.createJupyterNotebook(uri, `{ "cells": [] }`);
453
workspace.addNotebookDocument(notebookDoc);
454
} else {
455
notebookDoc = workspace.getNotebook(uri);
456
}
457
458
459
let workingCopyDocument = changedDocuments.get(uri);
460
if (!workingCopyDocument) {
461
workingCopyDocument = new WorkingCopyOriginalDocument(notebookDoc.getText());
462
changedDocuments.set(uri, workingCopyDocument);
463
}
464
465
if (validEdits.length > 0) {
466
hasActualEdits = true;
467
workspace.applyNotebookEdits(notebookDoc.uri, validEdits);
468
workingCopyDocument = new WorkingCopyOriginalDocument(notebookDoc.getText());
469
changedDocuments.set(uri, workingCopyDocument);
470
}
471
} else if (value instanceof ChatResponseMarkdownPart) {
472
markdownChunks.push(value.value.value);
473
}
474
}, () => { }, undefined, undefined, undefined, () => Promise.resolve(undefined));
475
const interactionOutcomeComputer = new InteractionOutcomeComputer(activeEditor?.document.uri);
476
stream = interactionOutcomeComputer.spyOnStream(stream);
477
478
const responseProcessor = host.provideResponseProcessor?.(query);
479
if (responseProcessor) {
480
stream = responseProcessor.spyOnStream(stream);
481
}
482
483
const documentStateBeforeInvocation = activeEditor?.document.getText();
484
485
setupTools(stream, request, accessor);
486
487
const agentArgs = host.agentArgs ?? {
488
agentId: getChatParticipantIdFromName(editorAgentName),
489
agentName: editorAgentName,
490
intentId: request.command
491
};
492
493
const requestHandler = instaService.createInstance(ChatParticipantRequestHandler, history, request, stream, CancellationToken.None, agentArgs, () => false, undefined);
494
const result = await requestHandler.getResult();
495
history.push(new ChatRequestTurn(request.prompt, request.command, [...request.references], '', []));
496
history.push(new ChatResponseTurn([new ChatResponseMarkdownPart(markdownChunks.join(''))], result, ''));
497
498
let annotations = await responseProcessor?.postProcess(accessor, workspace, stream, result) ?? [];
499
500
let interactionOutcomeKind = interactionOutcomeComputer.interactionOutcome.kind;
501
if (interactionOutcomeKind === 'inlineEdit' || interactionOutcomeKind === 'workspaceEdit') {
502
// sometimes we push noop edits which can trick the outcome computer
503
if (!hasActualEdits) {
504
interactionOutcomeKind = 'noopEdit';
505
}
506
}
507
let intent: IIntent | undefined;
508
{
509
// TODO@Alex: extract to host object
510
const response = requestHandler.conversation.getLatestTurn()?.getMetadata(CopilotInteractiveEditorResponse);
511
intent = (response ? response.promptQuery.intent : undefined);
512
}
513
annotations = annotations.concat(requestHandler.conversation.getLatestTurn()?.getMetadata(InteractionOutcome)?.annotations ?? []);
514
515
let outcome: IOutcome;
516
if (interactionOutcomeKind === 'none') {
517
outcome = { type: 'none', annotations, chatResponseMarkdown: markdownChunks.join('') };
518
} else if (result.errorDetails) {
519
outcome = { type: 'error', errorDetails: result.errorDetails, annotations };
520
} else if (interactionOutcomeKind === 'noopEdit') {
521
outcome = { type: 'none', annotations, chatResponseMarkdown: markdownChunks.join('') };
522
} else if (interactionOutcomeKind === 'inlineEdit' || interactionOutcomeKind === 'workspaceEdit') {
523
const outcomeFiles: IFile[] = [];
524
const workspaceEdit = new WorkspaceEdit();
525
const outcomeEdits: IInlineEdit[] = [];
526
for (const [uri, workingCopyDoc] of changedDocuments.entries()) {
527
if (uri.scheme === 'file') {
528
outcomeFiles.push({
529
kind: 'relativeFile',
530
fileName: path.basename(uri.fsPath),
531
fileContents: workspace.tryGetNotebook(uri)?.getText() ?? workspace.getDocument(uri).getText()
532
});
533
} else {
534
outcomeFiles.push({
535
kind: 'qualifiedFile',
536
uri: uri,
537
fileContents: workspace.tryGetNotebook(uri)?.getText() ?? workspace.getDocument(uri).getText()
538
});
539
}
540
const offsetEdits = workingCopyDoc.appliedEdits;
541
const textEdits = workingCopyDoc.transformer.toTextEdits(offsetEdits);
542
if (activeEditor && isEqual(uri, activeEditor.document.uri)) {
543
// edit in the same document
544
for (let i = 0; i < offsetEdits.replacements.length; i++) {
545
const offsetEdit = offsetEdits.replacements[i];
546
const textEdit = textEdits[i];
547
outcomeEdits.push({
548
offset: offsetEdit.replaceRange.start,
549
length: offsetEdit.replaceRange.length,
550
range: textEdit.range,
551
newText: textEdit.newText,
552
});
553
}
554
}
555
workspaceEdit.set(uri, textEdits);
556
}
557
558
if (interactionOutcomeKind === 'inlineEdit') {
559
if (!activeEditor) {
560
throw new Error(`inlineEdit should always have an open editor`);
561
}
562
outcome = {
563
type: 'inlineEdit',
564
initialDiagnostics,
565
appliedEdits: outcomeEdits,
566
originalFileContents: documentStateBeforeInvocation ?? '',
567
fileContents: activeEditor.document.getText(),
568
chatResponseMarkdown: markdownChunks.join(''),
569
annotations
570
};
571
} else {
572
outcome = {
573
type: 'workspaceEdit',
574
files: outcomeFiles,
575
annotations,
576
edits: workspaceEdit,
577
chatResponseMarkdown: markdownChunks.join('')
578
};
579
}
580
} else {
581
outcome = {
582
type: 'conversational',
583
chatResponseMarkdown: markdownChunks.join(''),
584
annotations
585
};
586
}
587
588
const changedFilePaths: IWorkspaceStateFile[] = [];
589
if (changedDocs.length > 0) {
590
const seenDoc = new Set<string>();
591
for (const changedDoc of changedDocs) {
592
const workspacePath = workspace.getFilePath(changedDoc.uri);
593
if (seenDoc.has(workspacePath)) {
594
continue;
595
}
596
seenDoc.add(workspacePath);
597
if (location !== ChatLocation.Editor && !seenFiles.find((v) => URI.isUri(v.value) && isEqual(v.value, changedDoc.uri))) {
598
seenFiles.push(createWorkingSetFileVariable(changedDoc.uri));
599
}
600
601
if (isNotebook(changedDoc.uri)) {
602
const notebook = workspace.getNotebook(changedDoc.uri);
603
changedFilePaths.push({
604
workspacePath,
605
relativeDiskPath: await testRuntime.writeFile(workspacePath, notebook.getText(), INLINE_CHANGED_DOC_TAG),
606
languageId: changedDoc.languageId
607
});
608
} else {
609
changedFilePaths.push({
610
workspacePath,
611
relativeDiskPath: await testRuntime.writeFile(workspacePath, changedDoc.getText(), INLINE_CHANGED_DOC_TAG),
612
languageId: changedDoc.languageId
613
});
614
}
615
}
616
617
// We managed to edit some files!
618
testRuntime.setOutcome({
619
kind: 'edit',
620
files: changedFilePaths.map(f => ({ srcUri: f.workspacePath, post: f.relativeDiskPath })),
621
annotations: outcome.annotations
622
});
623
} else {
624
if (activeEditor) {
625
const workspacePath = workspace.getFilePath(activeEditor.document.uri);
626
changedFilePaths.push({
627
workspacePath,
628
relativeDiskPath: await testRuntime.writeFile(workspacePath, activeEditor.document.getText(), INLINE_CHANGED_DOC_TAG),
629
languageId: activeEditor.document.languageId
630
});
631
}
632
633
if (markdownChunks.length > 0) {
634
testRuntime.setOutcome({
635
kind: 'answer',
636
content: markdownChunks.join(''),
637
annotations: outcome.annotations
638
});
639
} else {
640
const chatMLFetcher = accessor.get(IChatMLFetcher);
641
let contentFilterCount = 0;
642
if (chatMLFetcher instanceof SpyingChatMLFetcher) {
643
contentFilterCount = chatMLFetcher.contentFilterCount;
644
}
645
testRuntime.setOutcome({
646
kind: 'failed',
647
hitContentFilter: contentFilterCount > 0,
648
error: 'No contents.',
649
annotations: outcome.annotations,
650
critical: false,
651
});
652
}
653
}
654
655
let requestCount = 0;
656
const fetcher = accessor.get(IChatMLFetcher);
657
if (fetcher instanceof SpyingChatMLFetcher) {
658
requestCount = fetcher.interceptedRequests.length;
659
}
660
661
let diagnostics: { [workspacePath: string]: IDiagnosticComparison } | undefined = undefined;
662
if (typeof query.diagnostics === 'string') {
663
const diagnosticsAfter = await fetchDiagnostics(accessor, workspace, query.diagnostics);
664
diagnostics = {};
665
for (const changedFilePath of changedFilePaths) {
666
const uri = workspace.getUriFromFilePath(changedFilePath.workspacePath);
667
const before = (initialDiagnostics?.get(uri) ?? []).map(toIDiagnostic);
668
const after = (diagnosticsAfter.get(uri) ?? []).map(toIDiagnostic);
669
diagnostics[changedFilePath.workspacePath] = { before, after };
670
}
671
}
672
states.push({
673
kind: 'interaction',
674
changedFiles: changedFilePaths,
675
annotations: outcome.annotations,
676
fileName: activeEditor ? workspace.getFilePath(activeEditor.document.uri) : undefined,
677
languageId: activeEditor?.document.languageId,
678
diagnostics,
679
selection: activeEditor ? toIRange(activeEditor.selection) : undefined,
680
range: activeEditor ? toIRange(range ?? activeEditor.selection) : undefined,
681
interaction: {
682
query: query.query,
683
actualIntent: query.expectedIntent,
684
detectedIntent: intent?.id,
685
},
686
requestCount,
687
});
688
689
await Promise.resolve(query.validate(outcome, workspace, accessor));
690
}
691
for (const [_, workspaceStateFilePromise] of changedDocsInitialStates) {
692
if (workspaceStateFilePromise === null) {
693
continue;
694
}
695
const workspaceStateFile = await workspaceStateFilePromise;
696
if (states.length > 0 && states[0].kind === 'initial') {
697
states[0].additionalFiles?.push(workspaceStateFile);
698
}
699
}
700
} finally {
701
await teardownSimulationWorkspace(accessor, workspace);
702
await testRuntime.writeFile('inline-simulator.txt', JSON.stringify(states, undefined, 2), INLINE_STATE_TAG); // TODO@test: using .txt instead of .json to avoid breaking test scripts
703
}
704
}
705
706
function setupTools(stream: vscode.ChatResponseStream, request: ChatRequest, accessor: ITestingServicesAccessor) {
707
const toolsService = accessor.get(IToolsService) as TestToolsService | SimulationExtHostToolsService;
708
const instaService = accessor.get(IInstantiationService);
709
const editTool = instaService.createInstance(TestEditFileTool, stream);
710
toolsService.addTestToolOverride(
711
editTool.info,
712
editTool);
713
714
toolsService.addTestToolOverride(
715
{
716
name: 'inline_chat_exit',
717
description: 'Moves the inline chat session to the richer panel chat which supports edits across files, creating new files, and multi-turn conversations between the user and the assistant.',
718
inputSchema: {},
719
source: undefined,
720
tags: [],
721
},
722
{
723
invoke() {
724
return new LanguageModelToolResult([]);
725
}
726
}
727
);
728
}
729
730
function computeMoreMinimalEdit(document: vscode.TextDocument, edit: vscode.TextEdit): vscode.TextEdit {
731
edit = reduceCommonPrefix(document, edit);
732
edit = reduceCommonSuffix(document, edit);
733
return edit;
734
735
function reduceCommonPrefix(document: vscode.TextDocument, edit: vscode.TextEdit): vscode.TextEdit {
736
const start = document.offsetAt(edit.range.start);
737
const end = document.offsetAt(edit.range.end);
738
const oldText = document.getText().substring(start, end);
739
const newText = edit.newText;
740
const commonPrefixLen = commonPrefixLength(oldText, newText);
741
742
return new TextEdit(
743
new Range(
744
document.positionAt(start + commonPrefixLen),
745
edit.range.end
746
),
747
edit.newText.substring(commonPrefixLen)
748
);
749
}
750
751
function reduceCommonSuffix(document: vscode.TextDocument, edit: vscode.TextEdit): vscode.TextEdit {
752
const start = document.offsetAt(edit.range.start);
753
const end = document.offsetAt(edit.range.end);
754
const oldText = document.getText().substring(start, end);
755
const newText = edit.newText;
756
const commonSuffixLen = commonSuffixLength(oldText, newText);
757
758
return new TextEdit(
759
new Range(
760
edit.range.start,
761
document.positionAt(end - commonSuffixLen)
762
),
763
edit.newText.substring(0, newText.length - commonSuffixLen)
764
);
765
}
766
}
767
768
function applyEditsAndExpandRange(workspace: SimulationWorkspace, document: vscode.TextDocument, edits: vscode.TextEdit[], range: vscode.Range): vscode.Range;
769
function applyEditsAndExpandRange(workspace: SimulationWorkspace, document: vscode.TextDocument, edits: vscode.TextEdit[], range: vscode.Range | undefined): vscode.Range | undefined;
770
function applyEditsAndExpandRange(workspace: SimulationWorkspace, document: vscode.TextDocument, edits: vscode.TextEdit[], range: vscode.Range | undefined): vscode.Range | undefined {
771
if (typeof range === 'undefined') {
772
workspace.applyEdits(document.uri, edits, range);
773
return undefined;
774
}
775
776
edits = edits.map(edit => computeMoreMinimalEdit(document, edit));
777
778
const touchedRanges = new Set<[number, number]>();
779
let deltaOffset = 0;
780
for (const edit of edits) {
781
const startOffset = deltaOffset + document.offsetAt(edit.range.start);
782
const endOffset = deltaOffset + document.offsetAt(edit.range.end);
783
const textLen = edit.newText.length;
784
785
deltaOffset += textLen - (endOffset - startOffset);
786
787
touchedRanges.add([startOffset, textLen]);
788
}
789
790
range = workspace.applyEdits(document.uri, edits, range);
791
for (const touchedRange of touchedRanges) {
792
const [startOffset, textLen] = touchedRange;
793
const start = document.positionAt(startOffset);
794
const end = document.positionAt(startOffset + textLen);
795
range = range?.union(new Range(start, end));
796
}
797
return range;
798
}
799
800
function convertToDiagnostics(workspace: SimulationWorkspace, diagnostics: IScenarioDiagnostic[] | undefined): vscode.Diagnostic[] {
801
return (diagnostics ?? []).map((d) => {
802
const diagnostic = new Diagnostic(new Range(d.startLine, d.startCharacter, d.endLine, d.endCharacter), d.message);
803
diagnostic.relatedInformation = d.relatedInformation?.map(r => {
804
const range = new Range(r.location.startLine, r.location.startCharacter, r.location.endLine, r.location.endCharacter);
805
const relatedDocument = workspace.getDocument(r.location.path);
806
const relatedLocation = new Location(relatedDocument.document.uri, range);
807
return new DiagnosticRelatedInformation(relatedLocation, r.message);
808
});
809
return diagnostic;
810
});
811
}
812
813
async function fetchDiagnostics(accessor: ITestingServicesAccessor, workspace: SimulationWorkspace, providerId: DiagnosticProviderId) {
814
const files = workspace.documents.map(doc => ({ fileName: workspace.getFilePath(doc.document.uri), fileContents: doc.document.getText() }));
815
const diagnostics = await getDiagnostics(accessor, files, providerId);
816
return convertTestToVSCodeDiagnostics(diagnostics, path => workspace.getUriFromFilePath(path));
817
}
818
819
function toIDiagnostic(diagnostic: vscode.Diagnostic): IDiagnostic {
820
return { range: toIRange(diagnostic.range), message: diagnostic.message };
821
}
822
823
export function toIRange(range: vscode.Range): IRange {
824
return {
825
start: { line: range.start.line, character: range.start.character },
826
end: { line: range.end.line, character: range.end.character },
827
};
828
}
829
830
export function toSelection(selection: [number, number] | [number, number, number, number]): vscode.Selection {
831
if (selection.length === 2) {
832
return new Selection(selection[0], selection[1], selection[0], selection[1]);
833
} else {
834
return new Selection(selection[0], selection[1], selection[2], selection[3]);
835
}
836
}
837
838
export function toRange(range: [number, number] | [number, number, number, number]): vscode.Range {
839
if (range.length === 2) {
840
return new Range(range[0], 0, range[1], 0);
841
} else {
842
return new Range(range[0], range[1], range[2], range[3]);
843
}
844
}
845
846
847
export function forInlineChatIntent(callback: (strategy: EditTestStrategy, configurations: NonExtensionConfiguration[] | undefined, suffix: string) => void): void {
848
callback(EditTestStrategy.InlineChatIntent, [['chat.agent.autoFix', false]], '-InlineChatIntent');
849
}
850
851
export function forInline(callback: (strategy: EditTestStrategy, configurations: NonExtensionConfiguration[] | undefined, suffix: string) => void): void {
852
callback(EditTestStrategy.Inline, undefined, '');
853
}
854
855