Path: blob/main/extensions/copilot/test/simulation/inlineChatSimulator.ts
13388 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*--------------------------------------------------------------------------------------------*/4import assert from 'assert';5import * as path from 'path';6import type * as vscode from 'vscode';7import { Intent } from '../../src/extension/common/constants';8import { CopilotInteractiveEditorResponse, InteractionOutcome, InteractionOutcomeComputer } from '../../src/extension/inlineChat/node/promptCraftingTypes';9import { ICopilotChatResult } from '../../src/extension/prompt/common/conversation';10import { ChatParticipantRequestHandler, IChatAgentArgs } from '../../src/extension/prompt/node/chatParticipantRequestHandler';11import { guessFileIndentInfo } from '../../src/extension/prompt/node/indentationGuesser';12import { IntentDetector } from '../../src/extension/prompt/node/intentDetector';13import { IIntent } from '../../src/extension/prompt/node/intents';14import { WorkingCopyOriginalDocument } from '../../src/extension/prompts/node/inline/workingCopies';15import { IToolsService } from '../../src/extension/tools/common/toolsService';16import { TestEditFileTool } from '../../src/extension/tools/node/test/testTools';17import { TestToolsService } from '../../src/extension/tools/node/test/testToolsService';18import { editorAgentName, getChatParticipantIdFromName } from '../../src/platform/chat/common/chatAgents';19import { IChatMLFetcher } from '../../src/platform/chat/common/chatMLFetcher';20import { ILanguageDiagnosticsService } from '../../src/platform/languages/common/languageDiagnosticsService';21import { ILanguageFeaturesService } from '../../src/platform/languages/common/languageFeaturesService';22import { ITabsAndEditorsService } from '../../src/platform/tabs/common/tabsAndEditorsService';23import { isInExtensionHost } from '../../src/platform/test/node/isInExtensionHost';24import { IDeserializedWorkspaceState } from '../../src/platform/test/node/promptContextModel';25import { ITestingServicesAccessor, TestingServiceCollection } from '../../src/platform/test/node/services';26import { IFile, isNotebook, SimulationWorkspace } from '../../src/platform/test/node/simulationWorkspace';27import { ChatResponseStreamImpl } from '../../src/util/common/chatResponseStreamImpl';28import { getLanguage, getLanguageForResource } from '../../src/util/common/languages';29import { ChatRequestTurn, ChatResponseTurn } from '../../src/util/common/test/shims/chatTypes';30import { ExtHostNotebookDocumentData } from '../../src/util/common/test/shims/notebookDocument';31import { createTextDocumentData, IExtHostDocumentData } from '../../src/util/common/test/shims/textDocument';32import { CancellationToken } from '../../src/util/vs/base/common/cancellation';33import { ResourceMap } from '../../src/util/vs/base/common/map';34import { isEqual } from '../../src/util/vs/base/common/resources';35import { commonPrefixLength, commonSuffixLength } from '../../src/util/vs/base/common/strings';36import { URI } from '../../src/util/vs/base/common/uri';37import { SyncDescriptor } from '../../src/util/vs/platform/instantiation/common/descriptors';38import { IInstantiationService } from '../../src/util/vs/platform/instantiation/common/instantiation';39import { ChatLocation, ChatReferenceDiagnostic, ChatRequest, ChatRequestEditorData, ChatResponseMarkdownPart, ChatResponseNotebookEditPart, ChatResponseTextEditPart, Diagnostic, DiagnosticRelatedInformation, LanguageModelToolResult, Location, NotebookRange, Range, Selection, TextEdit, Uri, WorkspaceEdit } from '../../src/vscodeTypes';40import { SimulationExtHostToolsService } from '../base/extHostContext/simulationExtHostToolsService';41import { SimulationWorkspaceExtHost } from '../base/extHostContext/simulationWorkspaceExtHost';42import { SpyingChatMLFetcher } from '../base/spyingChatMLFetcher';43import { ISimulationTestRuntime, NonExtensionConfiguration } from '../base/stest';44import { createWorkingSetFileVariable, parseQueryForTest } from '../e2e/testHelper';45import { readBuiltinIntents } from '../intent/intentTest';46import { getDiagnostics } from './diagnosticProviders';47import { convertTestToVSCodeDiagnostics } from './diagnosticProviders/utils';48import { SimulationLanguageFeaturesService } from './language/simulationLanguageFeatureService';49import { IDiagnostic, IDiagnosticComparison, INLINE_CHANGED_DOC_TAG, INLINE_INITIAL_DOC_TAG, INLINE_STATE_TAG, IRange, IWorkspaceState, IWorkspaceStateFile } from './shared/sharedTypes';50import { DiagnosticProviderId, EditTestStrategy, IDeserializedWorkspaceStateBasedScenario, IInlineEdit, IOutcome, IScenario, IScenarioDiagnostic, IScenarioQuery, OutcomeAnnotation } from './types';5152export type SimulationWorkspaceInput = { files: IFile[]; workspaceFolders?: Uri[] } | { workspaceState: IDeserializedWorkspaceState };5354export function setupSimulationWorkspace(testingServiceCollection: TestingServiceCollection, input: SimulationWorkspaceInput): SimulationWorkspace {55const workspace = isInExtensionHost ? new SimulationWorkspaceExtHost() : new SimulationWorkspace();56if ('workspaceState' in input) {57workspace.resetFromDeserializedWorkspaceState(input.workspaceState);58} else {59workspace.resetFromFiles(input.files, input.workspaceFolders);60}61workspace.setupServices(testingServiceCollection);62testingServiceCollection.define(ILanguageFeaturesService, new SyncDescriptor(SimulationLanguageFeaturesService, [workspace]));63return workspace;64}6566export async function teardownSimulationWorkspace(accessor: ITestingServicesAccessor, workbench: SimulationWorkspace): Promise<void> {67const ls = accessor.get(ILanguageFeaturesService);68if (ls instanceof SimulationLanguageFeaturesService) {69await ls.teardown();70}71workbench.dispose();72}7374function isDeserializedWorkspaceStateBasedScenario(scenario: IScenario): scenario is IDeserializedWorkspaceStateBasedScenario {75return 'workspaceState' in scenario;76}7778export function simulateInlineChatWithStrategy(strategy: EditTestStrategy, testingServiceCollection: TestingServiceCollection, scenario: IScenario) {7980if (strategy === EditTestStrategy.InlineChatIntent) {81return simulateInlineChatIntent(testingServiceCollection, scenario);82} else {83return simulateInlineChat(testingServiceCollection, scenario);84}85}8687export async function simulateInlineChat(88testingServiceCollection: TestingServiceCollection,89scenario: IScenario90): Promise<void> {91const host: EditingSimulationHost = {92prepareChatRequestLocation: (accessor: ITestingServicesAccessor, wholeRange?: Range) => {93const editor = accessor.get(ITabsAndEditorsService).activeTextEditor;94if (!editor) {95throw new Error(`No active editor`);96}97return {98location: ChatLocation.Editor,99location2: new ChatRequestEditorData(editor, editor.document, editor.selection, wholeRange ?? editor.selection),100};101}102};103return simulateEditingScenario(testingServiceCollection, scenario, host);104}105106class ChatReferenceDiagnostic2 extends ChatReferenceDiagnostic {107constructor(uri: Uri, d: Diagnostic) {108super([[uri, [d]]]);109}110}111112export async function simulateInlineChatIntent(113testingServiceCollection: TestingServiceCollection,114scenario: IScenario115): Promise<void> {116117const overrideCommand = `/${Intent.InlineChat}`;118119const ensureSlashEdit = (query: string) => {120return query.startsWith(overrideCommand) ? query : `${overrideCommand} ${query}`;121};122const prependEditToUserQueries = (queries: IScenarioQuery[]) => {123return queries.map(scenarioQuery => {124return {125...scenarioQuery,126query: ensureSlashEdit(scenarioQuery.query),127};128});129};130131const massagedScenario = { ...scenario, queries: prependEditToUserQueries(scenario.queries) };132133const host: EditingSimulationHost = {134prepareChatRequestLocation: (accessor: ITestingServicesAccessor, wholeRange?: Range) => {135const editor = accessor.get(ITabsAndEditorsService).activeTextEditor;136if (!editor) {137throw new Error(`No active editor`);138}139return {140location: ChatLocation.Editor,141location2: new ChatRequestEditorData(editor, editor.document, editor.selection, wholeRange ?? editor.selection),142};143},144contributeAdditionalReferences(accessor, existingReferences) {145const diagnosticService = accessor.get(ILanguageDiagnosticsService);146const editor = accessor.get(ITabsAndEditorsService).activeTextEditor;147if (!editor) {148return existingReferences.slice();149}150151const result = existingReferences.slice();152153const diagnostics = diagnosticService.getDiagnostics(editor.document.uri);154155for (const d of diagnostics) {156if (d.range.intersection(editor.selection)) {157result.push({158id: `diagnostic/${editor.document.uri}/${JSON.stringify(d)}`,159name: d.message,160value: new ChatReferenceDiagnostic2(editor.document.uri, d)161});162}163}164165return result;166},167};168return simulateEditingScenario(testingServiceCollection, massagedScenario, host);169}170171export type EditingSimulationHostResponseProcessor = {172spyOnStream(stream: vscode.ChatResponseStream): vscode.ChatResponseStream;173postProcess(accessor: ITestingServicesAccessor, workspace: SimulationWorkspace, stream: vscode.ChatResponseStream, result?: ICopilotChatResult): Promise<OutcomeAnnotation[]>;174};175176export interface EditingSimulationHost {177agentArgs?: IChatAgentArgs;178prepareChatRequestLocation(accessor: ITestingServicesAccessor, range?: Range): {179location: vscode.ChatLocation;180location2: vscode.ChatRequestEditorData | undefined;181};182contributeAdditionalReferences?: (accessor: ITestingServicesAccessor, existingReferences: readonly vscode.ChatPromptReference[]) => vscode.ChatPromptReference[];183provideResponseProcessor?: (query: IScenarioQuery) => EditingSimulationHostResponseProcessor;184}185186187export async function simulateEditingScenario(188testingServiceCollection: TestingServiceCollection,189scenario: IScenario,190host: EditingSimulationHost191): Promise<void> {192assert(scenario.queries.length > 0, `Cannot simulate scenario with no queries`);193assert(isDeserializedWorkspaceStateBasedScenario(scenario) || scenario.files.length > 0, `Cannot simulate scenario with no files`);194195const workspace = setupSimulationWorkspace(testingServiceCollection, scenario);196197await scenario.extraWorkspaceSetup?.(workspace);198const accessor = testingServiceCollection.createTestingAccessor();199await scenario.onBeforeStart?.(accessor);200201const instaService = accessor.get(IInstantiationService);202const testRuntime = accessor.get(ISimulationTestRuntime);203204const states: IWorkspaceState[] = [];205let range: Range | undefined;206let isFirst = true;207const history: (ChatRequestTurn | ChatResponseTurn)[] = [];208/**209* A map from doc to relative path with initial contents which is populated right before modifying a document.210*/211const changedDocsInitialStates = new Map<vscode.TextDocument, Promise<IWorkspaceStateFile> | null>();212213// run each query for the scenario214try {215const seenFiles: vscode.ChatPromptReference[] = [];216217for (const query of scenario.queries) {218219if (query.file) {220if (isNotebook(query.file)) {221const notebook = workspace.getNotebook(query.file);222if (!notebook) {223throw new Error(`Missing notebook file ${query.file}`);224}225226const cell = notebook.cellAt(query.activeCell ?? 0);227if (!cell) {228throw new Error(`Missing cell ${query.activeCell} in notebook file ${query.file}`);229}230231workspace.addNotebookDocument(notebook);232workspace.setCurrentNotebookDocument(notebook);233workspace.setCurrentDocument(cell.document.uri);234} else if (typeof query.file !== 'string') {235workspace.setCurrentDocument(query.file);236} else {237workspace.setCurrentDocument(238workspace.getDocument(query.file).document.uri);239}240}241242if (query.selection) {243const selection = toSelection(query.selection);244workspace.setCurrentSelection(selection);245}246247if (query.visibleRanges) {248workspace.setCurrentVisibleRanges(query.visibleRanges.map((range) => toRange(range)));249}250251if (query.activeCell) {252const cellSelection = new NotebookRange(query.activeCell, query.activeCell + 1);253workspace.setCurrentNotebookSelection([cellSelection]);254}255256const queryWholeRange = query.wholeRange ? toSelection(query.wholeRange) : undefined;257258const activeEditor = accessor.get(ITabsAndEditorsService).activeTextEditor;259if (query.file && !activeEditor) {260throw new Error(`query.file is defined but no editor is active`);261}262263let initialDiagnostics: ResourceMap<vscode.Diagnostic[]> | undefined;264265if (typeof query.diagnostics === 'string') {266// diagnostics are computed267try {268initialDiagnostics = await fetchDiagnostics(accessor, workspace, query.diagnostics);269workspace.setDiagnostics(initialDiagnostics);270} catch (error) {271throw new Error(`Error obtained while fetching the diagnostics: ${error}`);272}273} else if (Array.isArray(query.diagnostics)) {274if (!activeEditor) {275throw new Error(`diagnostics can only be an array if there's an active editor (is 'file' specified?)`);276}277// diagnostics are set explicitly278const diagnostics = new ResourceMap<vscode.Diagnostic[]>();279diagnostics.set(activeEditor.document.uri, convertToDiagnostics(workspace, query.diagnostics));280workspace.setDiagnostics(diagnostics);281}282283if (query.fileIndentInfo) {284workspace.setCurrentDocumentIndentInfo(query.fileIndentInfo);285} else if (activeEditor) {286workspace.setCurrentDocumentIndentInfo(guessFileIndentInfo(activeEditor.document));287}288if (isFirst && activeEditor) {289isFirst = false;290range = activeEditor.selection;291const documentUri = activeEditor.document.uri;292const workspacePath = workspace.getFilePath(documentUri);293let relativeDiskPath: string | undefined;294if (isNotebook(documentUri)) {295const notebookDocument = workspace.getNotebook(documentUri);296if (!notebookDocument) {297throw new Error(`Missing notebook document ${documentUri}`);298}299300relativeDiskPath = await testRuntime.writeFile(workspacePath + '.txt', notebookDocument.getText(), INLINE_INITIAL_DOC_TAG); // using .txt instead of real file extension to avoid breaking automation scripts301} else {302relativeDiskPath = await testRuntime.writeFile(workspacePath + '.txt', activeEditor.document.getText(), INLINE_INITIAL_DOC_TAG); // using .txt instead of real file extension to avoid breaking automation scripts303}304changedDocsInitialStates.set(activeEditor.document, null); // just mark that it doesn't get written twice305306if (!relativeDiskPath) {307throw new Error(`Failed to write initial document to disk`);308}309310states.push({311kind: 'initial',312file: {313workspacePath,314relativeDiskPath,315languageId: activeEditor.document.languageId316},317additionalFiles: [],318languageId: getLanguage(activeEditor.document).languageId,319selection: toIRange(activeEditor.selection),320range: toIRange(range),321diagnostics: workspace.activeFileDiagnostics.map(toIDiagnostic),322});323} else {324range = queryWholeRange ?? range;325states.push({326kind: 'initial',327additionalFiles: [],328diagnostics: workspace.activeFileDiagnostics.map(toIDiagnostic),329});330}331332333let command: string | undefined;334let prompt = query.query;335if (prompt.startsWith('/')) {336const groups = /\/(?<intentId>\w+)(?<restOfQuery>\s.*)?/s.exec(query.query)?.groups;337command = groups?.intentId ?? undefined;338prompt = groups?.restOfQuery?.trim() ?? '';339}340341const changedDocs: vscode.TextDocument[] = [];342const references: vscode.ChatPromptReference[] = [...seenFiles];343const toolReferences: vscode.ChatLanguageModelToolReference[] = [];344345try {346const parsedQuery = parseQueryForTest(accessor, prompt, workspace);347348for (const variable of parsedQuery.variables) {349if (!URI.isUri(variable.value)) {350references.push(variable);351continue;352}353const uri = variable.value;354if (!seenFiles.find(ref => URI.isUri(ref.value) && isEqual(ref.value, uri))) {355seenFiles.push(variable);356references.push(variable);357}358}359360toolReferences.push(...parsedQuery.toolReferences);361} catch (error) {362// No problem!363}364365references.push(...(host.contributeAdditionalReferences?.(accessor, references) ?? []));366367const { location, location2 } = host.prepareChatRequestLocation(accessor, range);368let request: vscode.ChatRequest = {369location,370location2,371command,372prompt,373references,374attempt: 0,375isParticipantDetected: false,376enableCommandDetection: true, // TODO@ulugbekna: add support for disabling intent detection?377toolReferences,378toolInvocationToken: (isInExtensionHost ? undefined : {}) as never,379model: null!, // https://github.com/microsoft/vscode-copilot/issues/9475380tools: new Map(),381id: '1',382sessionId: '1',383sessionResource: Uri.parse('chat:/1'),384hasHooksEnabled: false,385};386387// Run intent detection388if (!request.command) {389const intentDetector = instaService.createInstance(IntentDetector);390const participants = readBuiltinIntents(location);391const detectedParticipant = await intentDetector.provideParticipantDetection(request, { history, yieldRequested: false }, { participants, location: ChatLocation.Editor }, CancellationToken.None);392if (detectedParticipant?.command) {393request = { ...request, command: detectedParticipant.command };394}395}396397const markdownChunks: string[] = [];398const changedDocuments = new ResourceMap<WorkingCopyOriginalDocument>();399let hasActualEdits = false;400let stream: vscode.ChatResponseStream = new ChatResponseStreamImpl((value) => {401if (value instanceof ChatResponseTextEditPart && value.edits.length > 0) {402const { uri, edits } = value;403404let doc: IExtHostDocumentData;405if (!workspace.hasDocument(uri)) {406// this is a new file407const language = getLanguageForResource(uri);408doc = createTextDocumentData(uri, '', language.languageId);409workspace.addDocument(doc);410} else {411doc = workspace.getDocument(uri);412if (!changedDocsInitialStates.has(doc.document)) {413const workspacePath = workspace.getFilePath(doc.document.uri);414const workspaceStateFilePromise = testRuntime.writeFile(workspacePath, doc.document.getText(), INLINE_CHANGED_DOC_TAG).then((relativeDiskPath) => {415return {416workspacePath,417relativeDiskPath,418languageId: doc.document.languageId419};420});421changedDocsInitialStates.set(doc.document, workspaceStateFilePromise);422}423}424425let workingCopyDocument = changedDocuments.get(uri);426if (!workingCopyDocument) {427workingCopyDocument = new WorkingCopyOriginalDocument(doc.document.getText());428changedDocuments.set(uri, workingCopyDocument);429}430431const offsetEdits = workingCopyDocument.transformer.toOffsetEdit(edits);432if (!workingCopyDocument.isNoop(offsetEdits)) {433hasActualEdits = true;434workingCopyDocument.applyOffsetEdits(offsetEdits);435changedDocs.push(doc.document);436if (activeEditor && isEqual(doc.document.uri, activeEditor.document.uri)) {437// edit in the same document, adjust the range438range = applyEditsAndExpandRange(workspace, activeEditor.document, edits, range);439} else {440workspace.applyEdits(doc.document.uri, edits);441}442}443444} else if (value instanceof ChatResponseNotebookEditPart) {445const { uri, edits } = value;446const validEdits = edits.filter(edit => typeof edit !== 'boolean');447448let notebookDoc: ExtHostNotebookDocumentData;449450if (!workspace.hasNotebookDocument(uri)) {451notebookDoc = ExtHostNotebookDocumentData.createJupyterNotebook(uri, `{ "cells": [] }`);452workspace.addNotebookDocument(notebookDoc);453} else {454notebookDoc = workspace.getNotebook(uri);455}456457458let workingCopyDocument = changedDocuments.get(uri);459if (!workingCopyDocument) {460workingCopyDocument = new WorkingCopyOriginalDocument(notebookDoc.getText());461changedDocuments.set(uri, workingCopyDocument);462}463464if (validEdits.length > 0) {465hasActualEdits = true;466workspace.applyNotebookEdits(notebookDoc.uri, validEdits);467workingCopyDocument = new WorkingCopyOriginalDocument(notebookDoc.getText());468changedDocuments.set(uri, workingCopyDocument);469}470} else if (value instanceof ChatResponseMarkdownPart) {471markdownChunks.push(value.value.value);472}473}, () => { }, undefined, undefined, undefined, () => Promise.resolve(undefined));474const interactionOutcomeComputer = new InteractionOutcomeComputer(activeEditor?.document.uri);475stream = interactionOutcomeComputer.spyOnStream(stream);476477const responseProcessor = host.provideResponseProcessor?.(query);478if (responseProcessor) {479stream = responseProcessor.spyOnStream(stream);480}481482const documentStateBeforeInvocation = activeEditor?.document.getText();483484setupTools(stream, request, accessor);485486const agentArgs = host.agentArgs ?? {487agentId: getChatParticipantIdFromName(editorAgentName),488agentName: editorAgentName,489intentId: request.command490};491492const requestHandler = instaService.createInstance(ChatParticipantRequestHandler, history, request, stream, CancellationToken.None, agentArgs, () => false, undefined);493const result = await requestHandler.getResult();494history.push(new ChatRequestTurn(request.prompt, request.command, [...request.references], '', []));495history.push(new ChatResponseTurn([new ChatResponseMarkdownPart(markdownChunks.join(''))], result, ''));496497let annotations = await responseProcessor?.postProcess(accessor, workspace, stream, result) ?? [];498499let interactionOutcomeKind = interactionOutcomeComputer.interactionOutcome.kind;500if (interactionOutcomeKind === 'inlineEdit' || interactionOutcomeKind === 'workspaceEdit') {501// sometimes we push noop edits which can trick the outcome computer502if (!hasActualEdits) {503interactionOutcomeKind = 'noopEdit';504}505}506let intent: IIntent | undefined;507{508// TODO@Alex: extract to host object509const response = requestHandler.conversation.getLatestTurn()?.getMetadata(CopilotInteractiveEditorResponse);510intent = (response ? response.promptQuery.intent : undefined);511}512annotations = annotations.concat(requestHandler.conversation.getLatestTurn()?.getMetadata(InteractionOutcome)?.annotations ?? []);513514let outcome: IOutcome;515if (interactionOutcomeKind === 'none') {516outcome = { type: 'none', annotations, chatResponseMarkdown: markdownChunks.join('') };517} else if (result.errorDetails) {518outcome = { type: 'error', errorDetails: result.errorDetails, annotations };519} else if (interactionOutcomeKind === 'noopEdit') {520outcome = { type: 'none', annotations, chatResponseMarkdown: markdownChunks.join('') };521} else if (interactionOutcomeKind === 'inlineEdit' || interactionOutcomeKind === 'workspaceEdit') {522const outcomeFiles: IFile[] = [];523const workspaceEdit = new WorkspaceEdit();524const outcomeEdits: IInlineEdit[] = [];525for (const [uri, workingCopyDoc] of changedDocuments.entries()) {526if (uri.scheme === 'file') {527outcomeFiles.push({528kind: 'relativeFile',529fileName: path.basename(uri.fsPath),530fileContents: workspace.tryGetNotebook(uri)?.getText() ?? workspace.getDocument(uri).getText()531});532} else {533outcomeFiles.push({534kind: 'qualifiedFile',535uri: uri,536fileContents: workspace.tryGetNotebook(uri)?.getText() ?? workspace.getDocument(uri).getText()537});538}539const offsetEdits = workingCopyDoc.appliedEdits;540const textEdits = workingCopyDoc.transformer.toTextEdits(offsetEdits);541if (activeEditor && isEqual(uri, activeEditor.document.uri)) {542// edit in the same document543for (let i = 0; i < offsetEdits.replacements.length; i++) {544const offsetEdit = offsetEdits.replacements[i];545const textEdit = textEdits[i];546outcomeEdits.push({547offset: offsetEdit.replaceRange.start,548length: offsetEdit.replaceRange.length,549range: textEdit.range,550newText: textEdit.newText,551});552}553}554workspaceEdit.set(uri, textEdits);555}556557if (interactionOutcomeKind === 'inlineEdit') {558if (!activeEditor) {559throw new Error(`inlineEdit should always have an open editor`);560}561outcome = {562type: 'inlineEdit',563initialDiagnostics,564appliedEdits: outcomeEdits,565originalFileContents: documentStateBeforeInvocation ?? '',566fileContents: activeEditor.document.getText(),567chatResponseMarkdown: markdownChunks.join(''),568annotations569};570} else {571outcome = {572type: 'workspaceEdit',573files: outcomeFiles,574annotations,575edits: workspaceEdit,576chatResponseMarkdown: markdownChunks.join('')577};578}579} else {580outcome = {581type: 'conversational',582chatResponseMarkdown: markdownChunks.join(''),583annotations584};585}586587const changedFilePaths: IWorkspaceStateFile[] = [];588if (changedDocs.length > 0) {589const seenDoc = new Set<string>();590for (const changedDoc of changedDocs) {591const workspacePath = workspace.getFilePath(changedDoc.uri);592if (seenDoc.has(workspacePath)) {593continue;594}595seenDoc.add(workspacePath);596if (location !== ChatLocation.Editor && !seenFiles.find((v) => URI.isUri(v.value) && isEqual(v.value, changedDoc.uri))) {597seenFiles.push(createWorkingSetFileVariable(changedDoc.uri));598}599600if (isNotebook(changedDoc.uri)) {601const notebook = workspace.getNotebook(changedDoc.uri);602changedFilePaths.push({603workspacePath,604relativeDiskPath: await testRuntime.writeFile(workspacePath, notebook.getText(), INLINE_CHANGED_DOC_TAG),605languageId: changedDoc.languageId606});607} else {608changedFilePaths.push({609workspacePath,610relativeDiskPath: await testRuntime.writeFile(workspacePath, changedDoc.getText(), INLINE_CHANGED_DOC_TAG),611languageId: changedDoc.languageId612});613}614}615616// We managed to edit some files!617testRuntime.setOutcome({618kind: 'edit',619files: changedFilePaths.map(f => ({ srcUri: f.workspacePath, post: f.relativeDiskPath })),620annotations: outcome.annotations621});622} else {623if (activeEditor) {624const workspacePath = workspace.getFilePath(activeEditor.document.uri);625changedFilePaths.push({626workspacePath,627relativeDiskPath: await testRuntime.writeFile(workspacePath, activeEditor.document.getText(), INLINE_CHANGED_DOC_TAG),628languageId: activeEditor.document.languageId629});630}631632if (markdownChunks.length > 0) {633testRuntime.setOutcome({634kind: 'answer',635content: markdownChunks.join(''),636annotations: outcome.annotations637});638} else {639const chatMLFetcher = accessor.get(IChatMLFetcher);640let contentFilterCount = 0;641if (chatMLFetcher instanceof SpyingChatMLFetcher) {642contentFilterCount = chatMLFetcher.contentFilterCount;643}644testRuntime.setOutcome({645kind: 'failed',646hitContentFilter: contentFilterCount > 0,647error: 'No contents.',648annotations: outcome.annotations,649critical: false,650});651}652}653654let requestCount = 0;655const fetcher = accessor.get(IChatMLFetcher);656if (fetcher instanceof SpyingChatMLFetcher) {657requestCount = fetcher.interceptedRequests.length;658}659660let diagnostics: { [workspacePath: string]: IDiagnosticComparison } | undefined = undefined;661if (typeof query.diagnostics === 'string') {662const diagnosticsAfter = await fetchDiagnostics(accessor, workspace, query.diagnostics);663diagnostics = {};664for (const changedFilePath of changedFilePaths) {665const uri = workspace.getUriFromFilePath(changedFilePath.workspacePath);666const before = (initialDiagnostics?.get(uri) ?? []).map(toIDiagnostic);667const after = (diagnosticsAfter.get(uri) ?? []).map(toIDiagnostic);668diagnostics[changedFilePath.workspacePath] = { before, after };669}670}671states.push({672kind: 'interaction',673changedFiles: changedFilePaths,674annotations: outcome.annotations,675fileName: activeEditor ? workspace.getFilePath(activeEditor.document.uri) : undefined,676languageId: activeEditor?.document.languageId,677diagnostics,678selection: activeEditor ? toIRange(activeEditor.selection) : undefined,679range: activeEditor ? toIRange(range ?? activeEditor.selection) : undefined,680interaction: {681query: query.query,682actualIntent: query.expectedIntent,683detectedIntent: intent?.id,684},685requestCount,686});687688await Promise.resolve(query.validate(outcome, workspace, accessor));689}690for (const [_, workspaceStateFilePromise] of changedDocsInitialStates) {691if (workspaceStateFilePromise === null) {692continue;693}694const workspaceStateFile = await workspaceStateFilePromise;695if (states.length > 0 && states[0].kind === 'initial') {696states[0].additionalFiles?.push(workspaceStateFile);697}698}699} finally {700await teardownSimulationWorkspace(accessor, workspace);701await testRuntime.writeFile('inline-simulator.txt', JSON.stringify(states, undefined, 2), INLINE_STATE_TAG); // TODO@test: using .txt instead of .json to avoid breaking test scripts702}703}704705function setupTools(stream: vscode.ChatResponseStream, request: ChatRequest, accessor: ITestingServicesAccessor) {706const toolsService = accessor.get(IToolsService) as TestToolsService | SimulationExtHostToolsService;707const instaService = accessor.get(IInstantiationService);708const editTool = instaService.createInstance(TestEditFileTool, stream);709toolsService.addTestToolOverride(710editTool.info,711editTool);712713toolsService.addTestToolOverride(714{715name: 'inline_chat_exit',716description: '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.',717inputSchema: {},718source: undefined,719tags: [],720},721{722invoke() {723return new LanguageModelToolResult([]);724}725}726);727}728729function computeMoreMinimalEdit(document: vscode.TextDocument, edit: vscode.TextEdit): vscode.TextEdit {730edit = reduceCommonPrefix(document, edit);731edit = reduceCommonSuffix(document, edit);732return edit;733734function reduceCommonPrefix(document: vscode.TextDocument, edit: vscode.TextEdit): vscode.TextEdit {735const start = document.offsetAt(edit.range.start);736const end = document.offsetAt(edit.range.end);737const oldText = document.getText().substring(start, end);738const newText = edit.newText;739const commonPrefixLen = commonPrefixLength(oldText, newText);740741return new TextEdit(742new Range(743document.positionAt(start + commonPrefixLen),744edit.range.end745),746edit.newText.substring(commonPrefixLen)747);748}749750function reduceCommonSuffix(document: vscode.TextDocument, edit: vscode.TextEdit): vscode.TextEdit {751const start = document.offsetAt(edit.range.start);752const end = document.offsetAt(edit.range.end);753const oldText = document.getText().substring(start, end);754const newText = edit.newText;755const commonSuffixLen = commonSuffixLength(oldText, newText);756757return new TextEdit(758new Range(759edit.range.start,760document.positionAt(end - commonSuffixLen)761),762edit.newText.substring(0, newText.length - commonSuffixLen)763);764}765}766767function applyEditsAndExpandRange(workspace: SimulationWorkspace, document: vscode.TextDocument, edits: vscode.TextEdit[], range: vscode.Range): vscode.Range;768function applyEditsAndExpandRange(workspace: SimulationWorkspace, document: vscode.TextDocument, edits: vscode.TextEdit[], range: vscode.Range | undefined): vscode.Range | undefined;769function applyEditsAndExpandRange(workspace: SimulationWorkspace, document: vscode.TextDocument, edits: vscode.TextEdit[], range: vscode.Range | undefined): vscode.Range | undefined {770if (typeof range === 'undefined') {771workspace.applyEdits(document.uri, edits, range);772return undefined;773}774775edits = edits.map(edit => computeMoreMinimalEdit(document, edit));776777const touchedRanges = new Set<[number, number]>();778let deltaOffset = 0;779for (const edit of edits) {780const startOffset = deltaOffset + document.offsetAt(edit.range.start);781const endOffset = deltaOffset + document.offsetAt(edit.range.end);782const textLen = edit.newText.length;783784deltaOffset += textLen - (endOffset - startOffset);785786touchedRanges.add([startOffset, textLen]);787}788789range = workspace.applyEdits(document.uri, edits, range);790for (const touchedRange of touchedRanges) {791const [startOffset, textLen] = touchedRange;792const start = document.positionAt(startOffset);793const end = document.positionAt(startOffset + textLen);794range = range?.union(new Range(start, end));795}796return range;797}798799function convertToDiagnostics(workspace: SimulationWorkspace, diagnostics: IScenarioDiagnostic[] | undefined): vscode.Diagnostic[] {800return (diagnostics ?? []).map((d) => {801const diagnostic = new Diagnostic(new Range(d.startLine, d.startCharacter, d.endLine, d.endCharacter), d.message);802diagnostic.relatedInformation = d.relatedInformation?.map(r => {803const range = new Range(r.location.startLine, r.location.startCharacter, r.location.endLine, r.location.endCharacter);804const relatedDocument = workspace.getDocument(r.location.path);805const relatedLocation = new Location(relatedDocument.document.uri, range);806return new DiagnosticRelatedInformation(relatedLocation, r.message);807});808return diagnostic;809});810}811812async function fetchDiagnostics(accessor: ITestingServicesAccessor, workspace: SimulationWorkspace, providerId: DiagnosticProviderId) {813const files = workspace.documents.map(doc => ({ fileName: workspace.getFilePath(doc.document.uri), fileContents: doc.document.getText() }));814const diagnostics = await getDiagnostics(accessor, files, providerId);815return convertTestToVSCodeDiagnostics(diagnostics, path => workspace.getUriFromFilePath(path));816}817818function toIDiagnostic(diagnostic: vscode.Diagnostic): IDiagnostic {819return { range: toIRange(diagnostic.range), message: diagnostic.message };820}821822export function toIRange(range: vscode.Range): IRange {823return {824start: { line: range.start.line, character: range.start.character },825end: { line: range.end.line, character: range.end.character },826};827}828829export function toSelection(selection: [number, number] | [number, number, number, number]): vscode.Selection {830if (selection.length === 2) {831return new Selection(selection[0], selection[1], selection[0], selection[1]);832} else {833return new Selection(selection[0], selection[1], selection[2], selection[3]);834}835}836837export function toRange(range: [number, number] | [number, number, number, number]): vscode.Range {838if (range.length === 2) {839return new Range(range[0], 0, range[1], 0);840} else {841return new Range(range[0], range[1], range[2], range[3]);842}843}844845846export function forInlineChatIntent(callback: (strategy: EditTestStrategy, configurations: NonExtensionConfiguration[] | undefined, suffix: string) => void): void {847callback(EditTestStrategy.InlineChatIntent, [['chat.agent.autoFix', false]], '-InlineChatIntent');848}849850export function forInline(callback: (strategy: EditTestStrategy, configurations: NonExtensionConfiguration[] | undefined, suffix: string) => void): void {851callback(EditTestStrategy.Inline, undefined, '');852}853854855