Path: blob/main/extensions/copilot/test/e2e/scenarioTest.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*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import * as fs from 'fs';7import type { ChatErrorDetails, LanguageModelToolInformation, MappedEditsResponseStream, TextDocument } from 'vscode';8import { CodeBlocksMetadata } from '../../src/extension/codeBlocks/node/codeBlockProcessor';9import { agentsToCommands, Intent } from '../../src/extension/common/constants';10import '../../src/extension/intents/node/allIntents';11import { ChatParticipantRequestHandler } from '../../src/extension/prompt/node/chatParticipantRequestHandler';12import { IDocumentContext } from '../../src/extension/prompt/node/documentContext';13import { CodeMapper, ICodeMapperExistingDocument } from '../../src/extension/prompts/node/codeMapper/codeMapper';14import { getContributedToolName } from '../../src/extension/tools/common/toolNames';15import '../../src/extension/tools/node/allTools';16import { getChatParticipantIdFromName } from '../../src/platform/chat/common/chatAgents';17import { IConversationOptions } from '../../src/platform/chat/common/conversationOptions';18import { ITabsAndEditorsService } from '../../src/platform/tabs/common/tabsAndEditorsService';19import { isInExtensionHost } from '../../src/platform/test/node/isInExtensionHost';20import { isNotebook, SimulationWorkspace } from '../../src/platform/test/node/simulationWorkspace';21import { SpyChatResponseStream } from '../../src/util/common/test/mockChatResponseStream';22import { ChatRequestTurn, ChatResponseTurn } from '../../src/util/common/test/shims/chatTypes';23import { CancellationToken } from '../../src/util/vs/base/common/cancellation';24import { DisposableStore } from '../../src/util/vs/base/common/lifecycle';25import { IInstantiationService } from '../../src/util/vs/platform/instantiation/common/instantiation';26import { ChatLocation, ChatRequest, ChatResponseAnchorPart, ChatResponseMarkdownPart, Uri } from '../../src/vscodeTypes';27import { SimulationWorkspaceExtHost } from '../base/extHostContext/simulationWorkspaceExtHost';28import { ISimulationTestRuntime, SimulationTestFunction } from '../base/stest';29import { INLINE_CHANGED_DOC_TAG, INLINE_INITIAL_DOC_TAG, IWorkspaceStateFile } from '../simulation/shared/sharedTypes';30import { Scenario, ScenarioEvaluator } from './scenarioLoader';31import { parseQueryForScenarioTest } from './testHelper';3233/**34* Grabs the default conversation options. Copied over from conversationFeature.ts35* TODO @lramos15, these should use the same code as conversationFeature.ts36*/37export function fetchConversationOptions() {38const maxResponseTokens = undefined;39const temperature = 0.1;40const topP = 1;4142const options: IConversationOptions = {43_serviceBrand: undefined,4445maxResponseTokens: maxResponseTokens,46temperature: temperature,47topP: topP,48rejectionMessage: 'Sorry, but I can only assist with programming related questions.',49};50return options;51}5253export function generateScenarioTestRunner(scenario: Scenario, evaluator: ScenarioEvaluator): SimulationTestFunction {54return async function (testingServiceCollection) {55const disposables = new DisposableStore();56try {57testingServiceCollection.define(IConversationOptions, fetchConversationOptions());58const simulationWorkspace = disposables.add(isInExtensionHost ? new SimulationWorkspaceExtHost() : new SimulationWorkspace());59simulationWorkspace.setupServices(testingServiceCollection);60const accessor = testingServiceCollection.createTestingAccessor();6162const testContext = accessor.get(ISimulationTestRuntime);63const log = (message: string, err?: any) => testContext.log(message, err);6465const history: (ChatRequestTurn | ChatResponseTurn)[] = [];66for (let i = 0; i < scenario.length; i++) {67const testCase = scenario[i];68simulationWorkspace.resetFromDeserializedWorkspaceState(testCase.getState?.());69await testCase.setupCase?.(accessor, simulationWorkspace);70const mockProgressReporter = new SpyChatResponseStream();71log(`> Query "${testCase.question}"\n`);7273const parsedQuery = await parseQueryForScenarioTest(accessor, testCase, simulationWorkspace);74const participantId = (parsedQuery.participantName && getChatParticipantIdFromName(parsedQuery.participantName)) ?? '';75const request: ChatRequest = { prompt: parsedQuery.query, references: parsedQuery.variables, command: parsedQuery.command, location: ChatLocation.Panel, location2: undefined, attempt: 0, enableCommandDetection: false, isParticipantDetected: false, toolReferences: parsedQuery.toolReferences, toolInvocationToken: undefined as never, model: null!, tools: new Map(), id: '1', sessionId: '1', sessionResource: Uri.parse('chat:/1'), hasHooksEnabled: false };76if (testCase.tools) {77for (const [toolName, shouldUse] of Object.entries(testCase.tools)) {78request.tools.set({ name: getContributedToolName(toolName) } as LanguageModelToolInformation, shouldUse);79}80}81const interactiveSession = accessor.get(IInstantiationService).createInstance(82ChatParticipantRequestHandler,83history,84request,85mockProgressReporter,86CancellationToken.None,87{88agentId: participantId,89agentName: parsedQuery.participantName || '',90intentId: (!parsedQuery.participantName && parsedQuery.command) ? parsedQuery.command :91parsedQuery.command ? agentsToCommands[parsedQuery.participantName as Intent]![parsedQuery.command] :92parsedQuery.participantName,93},94() => false,95undefined,96);97const result = await interactiveSession.getResult();98assert.ok(!result.errorDetails, result.errorDetails?.message);99100history.push(new ChatRequestTurn(request.prompt, request.command, [...request.references], getChatParticipantIdFromName(participantId), []));101history.push(new ChatResponseTurn(mockProgressReporter.items.filter(x => x instanceof ChatResponseMarkdownPart || x instanceof ChatResponseAnchorPart), result, participantId, request.command));102103testCase.answer = mockProgressReporter.currentProgress;104105const turn = interactiveSession.conversation.getLatestTurn();106const fullResponse = turn?.responseMessage?.message ?? '';107108accessor.get(ISimulationTestRuntime).setOutcome({109kind: 'answer',110content: fullResponse111});112113// Use the evaluator passed to us to evaluate if the response is correct114log(`## Response:\n${fullResponse}\n`);115log(`## Commands:\n`);116const commands = mockProgressReporter.commandButtons;117for (const command of commands) {118log(`- ${JSON.stringify(command)}\n`);119}120121if (scenario[i].applyChatCodeBlocks) {122const codeBlocks = turn?.getMetadata(CodeBlocksMetadata)?.codeBlocks ?? [];123const testRuntime = accessor.get(ISimulationTestRuntime);124125if (codeBlocks.length !== 0) {126const codeMapper = accessor.get(IInstantiationService).createInstance(CodeMapper);127const changedDocs: Map<string, { document: TextDocument; originalContent: string; postContent: string }> = new Map();128129// Apply Code Block Changes130let codeBlockApplyErrorDetails: ChatErrorDetails | undefined = undefined;131for (const codeBlock of codeBlocks) {132const prevDocument = simulationWorkspace.activeTextEditor?.document!;133// Set the active document if the code resource has a uri134if (codeBlock.resource) {135simulationWorkspace.setCurrentDocument(codeBlock.resource);136}137const editor = accessor.get(ITabsAndEditorsService).activeTextEditor!;138const codeMap = codeBlock.code;139const document = simulationWorkspace.activeTextEditor!.document;140const documentContext = IDocumentContext.fromEditor(editor);141const workspacePath = simulationWorkspace.getFilePath(document.uri);142143const previousTextContent = document.getText();144const response: MappedEditsResponseStream = {145textEdit(target, edits) {146simulationWorkspace.applyEdits(target, Array.isArray(edits) ? edits : [edits]);147},148notebookEdit(target, edits) {149simulationWorkspace.applyNotebookEdits(target, Array.isArray(edits) ? edits : [edits]);150},151};152const input: ICodeMapperExistingDocument = { createNew: false, codeBlock: codeMap, uri: document.uri, markdownBeforeBlock: undefined, existingDocument: documentContext.document };153const result = await codeMapper.mapCode(input, response, undefined, CancellationToken.None);154155if (!result) {156codeBlockApplyErrorDetails = {157message: `Code block changes failed to apply to ${document.uri.toString()}`,158};159break;160}161162if (result.errorDetails) {163result.errorDetails.message = `Code block changes failed to apply to ${document.uri.toString()}:\n${result.errorDetails.message}`;164codeBlockApplyErrorDetails = result.errorDetails;165break;166}167168const postEditTextContent = editor.document.getText();169if (previousTextContent !== postEditTextContent) {170const previousChange = changedDocs.get(workspacePath);171if (previousChange) {172previousChange.postContent = postEditTextContent;173changedDocs.set(workspacePath, previousChange);174} else {175changedDocs.set(workspacePath, { document, originalContent: previousTextContent, postContent: postEditTextContent });176}177}178179if (prevDocument) {180simulationWorkspace.setCurrentDocument(prevDocument.uri);181}182}183184// Log the changed files185const changedFilePaths: IWorkspaceStateFile[] = [];186if (!codeBlockApplyErrorDetails && changedDocs.size > 0) {187const seenDoc = new Set<string>();188for (const [workspacePath, changes] of changedDocs.entries()) {189if (seenDoc.has(workspacePath)) {190continue;191}192seenDoc.add(workspacePath);193194if (isNotebook(changes.document.uri)) {195await testRuntime.writeFile(workspacePath + '.txt', changes.originalContent, INLINE_INITIAL_DOC_TAG); // using .txt instead of real file extension to avoid breaking automation scripts196197changedFilePaths.push({198workspacePath,199relativeDiskPath: await testRuntime.writeFile(workspacePath, changes.postContent, INLINE_CHANGED_DOC_TAG),200languageId: changes.document.languageId201});202} else {203await testRuntime.writeFile(workspacePath + '.txt', changes.originalContent, INLINE_INITIAL_DOC_TAG); // using .txt instead of real file extension to avoid breaking automation scripts204205changedFilePaths.push({206workspacePath,207relativeDiskPath: await testRuntime.writeFile(workspacePath, changes.postContent, INLINE_CHANGED_DOC_TAG),208languageId: changes.document.languageId209});210}211}212213testRuntime.setOutcome({214kind: 'edit',215files: changedFilePaths.map(f => ({ srcUri: f.workspacePath, post: f.relativeDiskPath }))216});217} else if (codeBlockApplyErrorDetails) {218testRuntime.setOutcome({219kind: 'failed',220error: codeBlockApplyErrorDetails.message,221hitContentFilter: codeBlockApplyErrorDetails.responseIsFiltered ?? false,222critical: false223});224}225}226}227228const evaluatedResponse = await evaluator(229accessor,230testCase.question,231mockProgressReporter.currentProgress,232fullResponse,233turn,234i,235commands,236mockProgressReporter.confirmations,237mockProgressReporter.fileTrees,238);239assert.ok(evaluatedResponse.success, evaluatedResponse.errorMessage);240}241} finally {242disposables.dispose();243}244};245}246247export function shouldSkip(scenario: Scenario): boolean {248const workspaceFolderPath = scenario[0].getState?.().workspaceFolderPath;249try {250return !workspaceFolderPath || fs.readdirSync(workspaceFolderPath).length === 0;251} catch (e) {252return true;253}254}255256257