Path: blob/main/extensions/copilot/test/e2e/cli.stest.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 type { SessionOptions } from '@github/copilot/sdk';6import assert from 'assert';7import * as fs from 'fs/promises';8import { platform, tmpdir } from 'os';9import * as path from 'path';10import type { ChatParticipantToolToken, ChatPromptReference } from 'vscode';11import { IAgentSessionsWorkspace } from '../../src/extension/chatSessions/common/agentSessionsWorkspace';12import { IChatSessionMetadataStore } from '../../src/extension/chatSessions/common/chatSessionMetadataStore';13import { IChatSessionWorkspaceFolderService } from '../../src/extension/chatSessions/common/chatSessionWorkspaceFolderService';14import { IChatSessionWorktreeService } from '../../src/extension/chatSessions/common/chatSessionWorktreeService';15import { MockChatSessionMetadataStore } from '../../src/extension/chatSessions/common/test/mockChatSessionMetadataStore';16import { emptyWorkspaceInfo, IWorkspaceInfo } from '../../src/extension/chatSessions/common/workspaceInfo';17import { ICustomSessionTitleService } from '../../src/extension/chatSessions/copilotcli/common/customSessionTitleService';18import { ChatDelegationSummaryService, IChatDelegationSummaryService } from '../../src/extension/chatSessions/copilotcli/common/delegationSummaryService';19import { CopilotCLIAgents, CopilotCLIModels, CopilotCLISDK, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK } from '../../src/extension/chatSessions/copilotcli/node/copilotCli';20import { CopilotCLIImageSupport, ICopilotCLIImageSupport } from '../../src/extension/chatSessions/copilotcli/node/copilotCLIImageSupport';21import { CopilotCLIPromptResolver } from '../../src/extension/chatSessions/copilotcli/node/copilotcliPromptResolver';22import { ICopilotCLISession } from '../../src/extension/chatSessions/copilotcli/node/copilotcliSession';23import { CopilotCLISessionService, ICopilotCLISessionService, ICreateSessionOptions } from '../../src/extension/chatSessions/copilotcli/node/copilotcliSessionService';24import { CopilotCLISkills, ICopilotCLISkills } from '../../src/extension/chatSessions/copilotcli/node/copilotCLISkills';25import { CopilotCLIMCPHandler, ICopilotCLIMCPHandler } from '../../src/extension/chatSessions/copilotcli/node/mcpHandler';26import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../src/extension/chatSessions/copilotcli/node/userInputHelpers';27import { IPromptVariablesService, NullPromptVariablesService } from '../../src/extension/prompt/node/promptVariablesService';28import { ChatSummarizerProvider } from '../../src/extension/prompt/node/summarizer';29import { MockChatResponseStream, TestChatRequest } from '../../src/extension/test/node/testHelpers';30import { IToolsService } from '../../src/extension/tools/common/toolsService';31import { TestToolsService } from '../../src/extension/tools/node/test/testToolsService';32import { IChatDebugFileLoggerService, NullChatDebugFileLoggerService } from '../../src/platform/chat/common/chatDebugFileLoggerService';33import { IFileSystemService } from '../../src/platform/filesystem/common/fileSystemService';34import { NodeFileSystemService } from '../../src/platform/filesystem/node/fileSystemServiceImpl';35import { IMcpService, NullMcpService } from '../../src/platform/mcp/common/mcpService';36import { IPromptsService } from '../../src/platform/promptFiles/common/promptsService';37import { MockPromptsService } from '../../src/platform/promptFiles/test/common/mockPromptsService';38import { TestingServiceCollection } from '../../src/platform/test/node/services';39import { IQualifiedFile, SimulationWorkspace } from '../../src/platform/test/node/simulationWorkspace';40import { ChatReferenceDiagnostic } from '../../src/util/common/test/shims/chatTypes';41import { disposableTimeout, IntervalTimer } from '../../src/util/vs/base/common/async';42import { CancellationToken } from '../../src/util/vs/base/common/cancellation';43import { DisposableStore, IReference } from '../../src/util/vs/base/common/lifecycle';44import { URI } from '../../src/util/vs/base/common/uri';45import { SyncDescriptor } from '../../src/util/vs/platform/instantiation/common/descriptors';46import { IInstantiationService } from '../../src/util/vs/platform/instantiation/common/instantiation';47import { ChatRequest, ChatSessionStatus, ChatToolInvocationPart, Diagnostic, DiagnosticSeverity, LanguageModelTextPart, LanguageModelToolResult2, Location, Range, Uri } from '../../src/vscodeTypes';48import { ssuite, stest } from '../base/stest';4950const permissionConfirmationInvocations: Array<{ name: string; input: unknown }> = [];5152class TestCopilotCLIToolsService extends TestToolsService {53override async invokeTool(name: string, options: any, token: CancellationToken): Promise<LanguageModelToolResult2> {54if (name === 'vscode_get_confirmation' || name === 'vscode_get_terminal_confirmation') {55permissionConfirmationInvocations.push({ name, input: options.input });56return new LanguageModelToolResult2([new LanguageModelTextPart('yes')]);57}5859// `manage_todo_list` is invoked by CopilotCLISession at session start to clear any60// previous todo list, but the underlying tool does not implement `invoke` in the61// test toolsService. Return a no-op success result so session startup does not fail.62if (name === 'manage_todo_list') {63return new LanguageModelToolResult2([new LanguageModelTextPart('ok')]);64}65return super.invokeTool(name, options, token);66}67}6869/**70* Reads the GitHub OAuth token from the environment.71*72* The token is loaded automatically by `dotenv.config()` in `test/simulationMain.ts`73* from the `.env` file at the workspace root. We only ever read `process.env` so the74* token value never appears in any tool call output, log line, or LM request emitted75* by this test file.76*/77function getGitHubTokenFromEnv(): string {78const token = process.env.GITHUB_OAUTH_TOKEN;79if (!token) {80throw new Error('GITHUB_OAUTH_TOKEN is not set. Add it to the .env file at the repo root (it is loaded by dotenv in test/simulationMain.ts).');81}82return token;83}8485// Force the Copilot CLI runtime to use the public CAPI endpoint regardless of86// the AuthInfo we hand it. The runtime's `getCopilotApiUrl()` checks87// `process.env.COPILOT_API_URL` first (highest precedence), so setting it here88// guarantees the model list is fetched against an endpoint we know works with89// the GITHUB_OAUTH_TOKEN, instead of getting an empty list and cascading into90// "No model available."91if (!process.env.COPILOT_API_URL) {92process.env.COPILOT_API_URL = 'https://api.githubcopilot.com';93}9495// Force the SDK to route Anthropic models to `/v1/messages` instead of96// `/responses`. The default routing sends Claude models to `/responses`,97// which CAPI rejects with `400 model_not_supported`. The runtime reads ExP98// flag overrides from `process.env.COPILOT_EXP_<UPPER_SNAKE_CASE_FLAG>`,99// which works without setting up an ExP service in tests.100// if (!process.env.COPILOT_EXP_COPILOT_CLI_ANTHROPIC_MESSAGES_API) {101// process.env.COPILOT_EXP_COPILOT_CLI_ANTHROPIC_MESSAGES_API = 'true';102// }103104function sessionOptionsFor(workingDirectory: Uri | undefined): ICreateSessionOptions {105return {106// workingDirectory,107model: 'claude-opus-4.7',108workspace: {109folder: workingDirectory,110repository: undefined,111worktree: undefined,112worktreeProperties: undefined,113} satisfies IWorkspaceInfo114};115}116117async function registerChatServices(testingServiceCollection: TestingServiceCollection) {118class TestCustomSessionTitleService implements ICustomSessionTitleService {119readonly _serviceBrand: undefined;120private readonly titles = new Map<string, string>();121async getCustomSessionTitle(sessionId: string) {122return this.titles.get(sessionId);123}124async setCustomSessionTitle(sessionId: string, title: string): Promise<void> {125this.titles.set(sessionId, title);126}127async generateSessionTitle(_sessionId: string, _request: { prompt?: string; command?: string }, _token: CancellationToken): Promise<string | undefined> {128return undefined;129}130}131132class TestCopilotCLISessionService extends CopilotCLISessionService {133override async monitorSessionFiles() {134// Override to do nothing in tests135}136protected override async createSessionsOptions(options: { model?: string; workingDirectory?: Uri; workspace: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; sessionId?: string; debugTargetSessionIds?: readonly string[] }) {137const sessionOptions = await super.createSessionsOptions({ ...options, agent: undefined });138const mutableOptions = sessionOptions as SessionOptions;139mutableOptions.enableStreaming = true;140mutableOptions.skipCustomInstructions = true;141return sessionOptions;142}143}144145class TestCopilotCLISDK extends CopilotCLISDK {146protected override async ensureShims(): Promise<void> {147// Override to do nothing in tests148}149override async getAuthInfo(): Promise<NonNullable<SessionOptions['authInfo']>> {150return {151type: 'token',152token: getGitHubTokenFromEnv(),153host: 'https://github.com',154// Without `copilotUser.endpoints.api` the runtime's `getCopilotApiUrl()`155// returns undefined, `retrieveAvailableModels()` short-circuits to an156// empty list, and every model check below fails. Pointing it at the157// public Copilot API endpoint makes model resolution actually contact158// CAPI for the user's enabled models.159copilotUser: {160endpoints: {161api: 'https://api.githubcopilot.com',162},163},164};165}166}167168class UserQuestionHandler implements IUserQuestionHandler {169declare _serviceBrand: undefined;170constructor(171) {172}173async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise<IQuestionAnswer | undefined> {174return undefined;175}176}177178let accessor = testingServiceCollection.clone().createTestingAccessor();179let instaService = accessor.get(IInstantiationService);180const summarizer = instaService.createInstance(ChatSummarizerProvider);181const delegatingSummarizerProvider = instaService.createInstance(ChatDelegationSummaryService, summarizer);182testingServiceCollection.define(ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills));183testingServiceCollection.define(ICopilotCLISessionService, new SyncDescriptor(TestCopilotCLISessionService));184testingServiceCollection.define(ICopilotCLIModels, new SyncDescriptor(CopilotCLIModels));185testingServiceCollection.define(ICopilotCLISDK, new SyncDescriptor(TestCopilotCLISDK));186testingServiceCollection.define(ICopilotCLIAgents, new SyncDescriptor(CopilotCLIAgents));187testingServiceCollection.define(ICustomSessionTitleService, new SyncDescriptor(TestCustomSessionTitleService));188testingServiceCollection.define(ICopilotCLIMCPHandler, new SyncDescriptor(CopilotCLIMCPHandler));189testingServiceCollection.define(IMcpService, new SyncDescriptor(NullMcpService));190testingServiceCollection.define(IFileSystemService, new SyncDescriptor(NodeFileSystemService));191testingServiceCollection.define(ICopilotCLIImageSupport, new SyncDescriptor(CopilotCLIImageSupport));192testingServiceCollection.define(IToolsService, new SyncDescriptor(TestCopilotCLIToolsService, [new Set()]));193testingServiceCollection.define(IUserQuestionHandler, new SyncDescriptor(UserQuestionHandler));194testingServiceCollection.define(IChatDelegationSummaryService, delegatingSummarizerProvider);195testingServiceCollection.define(IChatSessionMetadataStore, new SyncDescriptor(MockChatSessionMetadataStore));196testingServiceCollection.define(IAgentSessionsWorkspace, { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace);197testingServiceCollection.define(IChatSessionWorkspaceFolderService, {198_serviceBrand: undefined,199async deleteTrackedWorkspaceFolder() { },200async trackSessionWorkspaceFolder() { },201async getSessionWorkspaceFolder() { return undefined; },202async getSessionWorkspaceFolderEntry() { return undefined; },203async getRepositoryProperties() { return undefined; },204async handleRequestCompleted() { },205async getWorkspaceChanges() { return undefined; },206async hasCachedChanges() { return false; },207clearWorkspaceChanges() { return []; },208onDidChangeWorkspaceFolderChanges: () => ({ dispose() { } }),209} as IChatSessionWorkspaceFolderService);210testingServiceCollection.define(IChatSessionWorktreeService, {211_serviceBrand: undefined,212async createWorktree() { return undefined; },213async getWorktreeProperties() { return undefined; },214async setWorktreeProperties() { },215async getWorktreeRepository() { return undefined; },216async getWorktreePath() { return undefined; },217async applyWorktreeChanges() { },218async getSessionIdForWorktree() { return undefined; },219async getWorktreeChanges() { return undefined; },220async handleRequestCompleted() { },221async getAdditionalWorktreeProperties() { return []; },222async setAdditionalWorktreeProperties() { },223async handleRequestCompletedForWorktree() { },224async cleanupWorktreeOnArchive() { return { cleaned: false }; },225async recreateWorktreeOnUnarchive() { return { recreated: false }; },226async hasCachedChanges() { return false; },227onDidChangeWorktreeChanges: () => ({ dispose() { } }),228} as IChatSessionWorktreeService);229testingServiceCollection.define(IPromptVariablesService, new SyncDescriptor(NullPromptVariablesService));230testingServiceCollection.define(IPromptsService, new SyncDescriptor(MockPromptsService));231testingServiceCollection.define(IChatDebugFileLoggerService, new NullChatDebugFileLoggerService());232const simulationWorkspace = new SimulationWorkspace();233simulationWorkspace.setupServices(testingServiceCollection);234235accessor = testingServiceCollection.createTestingAccessor();236const copilotCLISessionService = accessor.get(ICopilotCLISessionService);237const sdk = accessor.get(ICopilotCLISDK);238instaService = accessor.get(IInstantiationService);239const promptResolver = instaService.createInstance(CopilotCLIPromptResolver);240241async function populateWorkspaceFiles(workingDirectory: string) {242const fileLanguages = new Map<string, string>([243['.js', 'javascript'],244['.ts', 'typescript'],245['.py', 'python'],246]);247const workspaceUri = Uri.file(workingDirectory);248// Enumerate all files and folders under workingDirectory249250const files: Uri[] = [];251const folders: Uri[] = [];252await fs.readdir(workingDirectory, { withFileTypes: true }).then((dirents) => {253for (const dirent of dirents) {254const fullPath = path.join(workingDirectory, dirent.name);255if (dirent.isFile()) {256files.push(Uri.file(fullPath));257} else if (dirent.isDirectory()) {258folders.push(Uri.file(fullPath));259}260}261});262263const fileList = await Promise.all(files.map(async (fileUri) => {264const content = await fs.readFile(fileUri.fsPath, 'utf-8');265return {266uri: fileUri,267fileContents: content,268kind: 'qualifiedFile',269languageId: fileLanguages.get(path.extname(fileUri.fsPath)),270} satisfies IQualifiedFile;271}));272simulationWorkspace.resetFromFiles(fileList, [workspaceUri]);273}274275return {276sessionService: copilotCLISessionService, promptResolver, init: async (workingDirectory: URI) => {277await populateWorkspaceFiles(workingDirectory.fsPath);278await sdk.getPackage();279},280authInfo: await sdk.getAuthInfo()281};282}283284// NOTE: Ensure all files/folders/workingDirectories are under test/scenarios/test-cli for path replacements to work correctly.285const sourcePath = path.join(__dirname, '..', 'test', 'scenarios', 'test-cli');286let tmpDirCounter = 0;287function testRunner(cb: (services: { sessionService: ICopilotCLISessionService; promptResolver: CopilotCLIPromptResolver; init: (workingDirectory: URI) => Promise<void>; authInfo: NonNullable<SessionOptions['authInfo']> }, scenariosPath: string, toolInvocations: ChatToolInvocationPart[], stream: MockChatResponseStream, disposables: DisposableStore) => Promise<void>) {288return async (testingServiceCollection: TestingServiceCollection) => {289const disposables = new DisposableStore();290// Temp folder can be `/var/folders/....` in our code we use `realpath` to resolve any symlinks.291// That results in these temp folders being resolved as `/private/var/folders/...` on macOS.292const scenariosPath = path.join(tmpdir() + tmpDirCounter++, 'vscode-copilot-chat', 'test-cli');293await fs.rm(scenariosPath, { recursive: true, force: true }).catch(() => { /* Ignore */ });294await fs.mkdir(scenariosPath, { recursive: true });295await fs.cp(sourcePath, scenariosPath, { recursive: true, force: true, errorOnExist: false });296const toolInvocations: ChatToolInvocationPart[] = [];297permissionConfirmationInvocations.length = 0;298try {299const services = await registerChatServices(testingServiceCollection);300const stream = new MockChatResponseStream((part) => {301if (part instanceof ChatToolInvocationPart) {302toolInvocations.push(part);303}304});305await cb(services, await fs.realpath(scenariosPath), toolInvocations, stream, disposables);306} finally {307await fs.rm(scenariosPath, { recursive: true }).catch(() => { /* Ignore */ });308disposables.dispose();309}310};311}312313function assertStreamContains(stream: MockChatResponseStream, expectedContent: string, message?: string) {314const output = stream.output.join('');315assert.ok(output.includes(expectedContent), message ?? `Expected response to include "${expectedContent}", actual output: ${output}`);316}317318function assertNoErrorsInStream(stream: MockChatResponseStream) {319const output = stream.output.join('');320assert.ok(!output.includes('❌'), `Expected no errors in stream, actual output: ${output}`);321assert.ok(!output.includes('Error'), `Expected no errors in stream, actual output: ${output}`);322}323324async function assertFileContains(filePath: string, expectedContent: string, exactCount?: number) {325const fileContent = await fs.readFile(filePath, 'utf-8');326assert.ok(fileContent.includes(expectedContent), `Expected to contain "${expectedContent}", contents = ${fileContent}`);327if (typeof exactCount === 'number') {328const actualCount = Array.from(fileContent.matchAll(new RegExp(expectedContent, 'g'))).length;329assert.strictEqual(actualCount, exactCount, `Expected to find "${expectedContent}" exactly ${exactCount} times, but found ${actualCount} times in contents = ${fileContent}`);330}331}332333async function assertFileNotContains(filePath: string, expectedContent: string) {334const fileContent = await fs.readFile(filePath, 'utf-8');335assert.ok(!fileContent.includes(expectedContent), `Expected not to contain "${expectedContent}", contents = ${fileContent}`);336}337338ssuite.skip({ title: '@cli', location: 'external' }, async (_) => {339stest({ description: 'can start a session' },340testRunner(async ({ sessionService, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {341const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));342await init(workingDirectory);343const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);344disposables.add(session);345disposables.add(session.object.attachStream(stream));346347await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'What is 1+8?' }, [], undefined, authInfo, CancellationToken.None);348349// Verify we have a response of 9.350assert.strictEqual(session.object.status, ChatSessionStatus.Completed);351assertNoErrorsInStream(stream);352assertStreamContains(stream, '9');353354// Can send a subsequent request.355await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'What is 11+25?' }, [], undefined, authInfo, CancellationToken.None);356// Verify we have a response of 36.357assertStreamContains(stream, '36');358})359);360361stest({ description: 'can resume a session' },362testRunner(async ({ sessionService, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {363const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));364await init(workingDirectory);365366let sessionId = '';367// Start session.368{369const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);370sessionId = session.object.sessionId;371372await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'What is 1+8?' }, [], undefined, authInfo, CancellationToken.None);373session.dispose();374}375376// Resume the session.377{378const session = await new Promise<IReference<ICopilotCLISession>>((resolve, reject) => {379const interval = disposables.add(new IntervalTimer());380interval.cancelAndSet(async () => {381const session = await sessionService.getSession({ sessionId, ...sessionOptionsFor(workingDirectory) }, CancellationToken.None);382if (session) {383interval.dispose();384resolve(session);385}386}, 50);387disposables.add(disposableTimeout(() => reject(new Error('Timed out waiting for session')), 5_000));388});389disposables.add(session);390disposables.add(session.object.attachStream(stream));391392await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'What was my previous question?' }, [], undefined, authInfo, CancellationToken.None);393394// Verify we have a response of 9.395assert.strictEqual(session.object.status, ChatSessionStatus.Completed);396assertNoErrorsInStream(stream);397assertStreamContains(stream, '8');398}399})400);401stest({ description: 'can read file without permission' },402testRunner(async ({ sessionService, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {403const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));404await init(workingDirectory);405const file = URI.joinPath(workingDirectory, 'sample.js');406const prompt = `Explain the contents of the file '${path.basename(file.fsPath)}'. There is no need to check for contents in the directory. This file exists on disc.`;407const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);408disposables.add(session);409disposables.add(session.object.attachStream(stream));410411await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, [], undefined, authInfo, CancellationToken.None);412413assert.strictEqual(session.object.status, ChatSessionStatus.Completed);414assertNoErrorsInStream(stream);415assertStreamContains(stream, 'add');416})417);418stest({ description: 'request permission when reading file outside workspace' },419testRunner(async ({ sessionService, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {420const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));421await init(workingDirectory);422423const externalFile = path.join(scenariosPath, 'wkspc2', 'foobar.js');424const prompt = `Explain the contents of the file '${externalFile}'. This file exists on disc but not in the current working directory. There's no need to search the directory, just read this file and explain its contents.`;425const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);426disposables.add(session);427disposables.add(session.object.attachStream(stream));428429await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, [], undefined, authInfo, CancellationToken.None);430431assert.strictEqual(session.object.status, ChatSessionStatus.Completed);432assertNoErrorsInStream(stream);433const streamOutput = stream.output.join('');434assert.ok(permissionConfirmationInvocations.length > 0, 'Expected permission to be requested for external file, output:' + streamOutput);435})436);437stest({ description: 'can read attachment without permission' },438testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {439const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));440await init(workingDirectory);441const file = URI.joinPath(workingDirectory, 'sample.js').fsPath;442const { prompt, attachments } = await resolvePromptWithFileReferences(443`Explain the contents of the attached file. There is no need to check for contents in the directory. This file exists on disc.`,444[file],445promptResolver446);447448const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);449disposables.add(session);450disposables.add(session.object.attachStream(stream));451452await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);453454assert.strictEqual(session.object.status, ChatSessionStatus.Completed);455assertNoErrorsInStream(stream);456assertStreamContains(stream, 'add');457})458);459stest({ description: 'can edit file' },460testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {461const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));462await init(workingDirectory);463const file = URI.joinPath(workingDirectory, 'sample.js').fsPath;464let { prompt, attachments } = await resolvePromptWithFileReferences(465`Remove comments form add function and add a subtract function to #file:sample.js.`,466[file],467promptResolver468);469470const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);471disposables.add(session);472disposables.add(session.object.attachStream(stream));473474await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);475476assert.strictEqual(session.object.status, ChatSessionStatus.Completed);477assertNoErrorsInStream(stream);478await assertFileNotContains(file, 'Sample function to add two values');479await assertFileContains(file, 'function subtract', 1);480await assertFileContains(file, 'function add', 1);481482// Multi-turn edit483({ prompt, attachments } = await resolvePromptWithFileReferences(484`Now add a divide function.`,485[],486promptResolver487));488await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);489490assert.strictEqual(session.object.status, ChatSessionStatus.Completed);491assertNoErrorsInStream(stream);492// Ensure previous edits are preserved (in past there have been cases where SDK applies edits again)493await assertFileNotContains(file, 'Sample function to add two values');494await assertFileContains(file, 'function subtract', 1);495await assertFileContains(file, 'function add', 1);496})497);498stest({ description: 'explain selection' },499testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {500const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));501await init(workingDirectory);502const file = URI.joinPath(workingDirectory, 'utils.js').fsPath;503504const { prompt, attachments } = await resolvePromptWithFileReferences(505`explain what the selected statement does`,506[createFileSelectionReference(file, new Range(10, 0, 10, 10))],507promptResolver508);509510const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);511disposables.add(session);512disposables.add(session.object.attachStream(stream));513514await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);515516assert.strictEqual(session.object.status, ChatSessionStatus.Completed);517assertStreamContains(stream, 'throw');518})519);520stest({ description: 'can create a file' },521testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {522const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));523await init(workingDirectory);524const { prompt, attachments } = await resolvePromptWithFileReferences(525`Create a file named math.js that contains a function to compute square of a number.`,526[],527promptResolver528);529530const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);531disposables.add(session);532disposables.add(session.object.attachStream(stream));533534await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);535536assert.strictEqual(session.object.status, ChatSessionStatus.Completed);537assertNoErrorsInStream(stream);538await assertFileContains(URI.joinPath(workingDirectory, 'math.js').fsPath, 'function', 1);539})540);541stest({ description: 'can list files in directory' },542testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {543const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));544await init(workingDirectory);545const { prompt, attachments } = await resolvePromptWithFileReferences(546`What files are in the current directory.`,547[],548promptResolver549);550551const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);552disposables.add(session);553disposables.add(session.object.attachStream(stream));554555await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);556557assert.strictEqual(session.object.status, ChatSessionStatus.Completed);558assertNoErrorsInStream(stream);559assertStreamContains(stream, 'sample.js');560assertStreamContains(stream, 'utils.js');561assertStreamContains(stream, 'stringUtils.js');562assertStreamContains(stream, 'demo.py');563})564);565stest({ description: 'can fix problems' },566testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {567const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));568await init(workingDirectory);569const file = URI.joinPath(workingDirectory, 'stringUtils.js').fsPath;570const diag = new Diagnostic(new Range(7, 0, 7, 1), '} expected', DiagnosticSeverity.Error);571const { prompt, attachments } = await resolvePromptWithFileReferences(572`Fix the problem`,573[createDiagnosticReference(file, [diag])],574promptResolver575);576let contents = await fs.readFile(file, 'utf-8');577assert.ok(!contents.trim().endsWith('}'), '} is missing');578const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);579disposables.add(session);580disposables.add(session.object.attachStream(stream));581582await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);583584assert.strictEqual(session.object.status, ChatSessionStatus.Completed);585assertNoErrorsInStream(stream);586contents = await fs.readFile(file, 'utf-8');587assert.ok(contents.trim().endsWith('}'), `} has not been added, contents = ${contents}`);588})589);590591stest({ description: 'can fix multiple problems in multiple files' },592testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {593const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));594await init(workingDirectory);595const tsFile = URI.joinPath(workingDirectory, 'stringUtils.js').fsPath;596const tsDiag = new Diagnostic(new Range(7, 0, 7, 1), '} expected', DiagnosticSeverity.Error);597const pyFile = URI.joinPath(workingDirectory, 'demo.py').fsPath;598const pyDiag1 = new Diagnostic(new Range(3, 21, 3, 21), 'Expected \':\', found new line', DiagnosticSeverity.Error);599const pyDiag2 = new Diagnostic(new Range(19, 13, 19, 13), 'Statement ends with an unnecessary semicolon', DiagnosticSeverity.Warning);600601const { prompt, attachments } = await resolvePromptWithFileReferences(602`Fix the problem`,603[createDiagnosticReference(tsFile, [tsDiag]), createDiagnosticReference(pyFile, [pyDiag1, pyDiag2])],604promptResolver605);606const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);607disposables.add(session);608disposables.add(session.object.attachStream(stream));609610await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);611612assert.strictEqual(session.object.status, ChatSessionStatus.Completed);613const tsContents = await fs.readFile(tsFile, 'utf-8');614assert.ok(tsContents.trim().endsWith('}'), `} has not been added, contents = ${tsContents}`);615assertFileContains(pyFile, 'def printFibb(nterms):');616assertFileNotContains(pyFile, 'printFibb(34);');617})618);619620stest({ description: 'can run terminal commands' },621testRunner(async ({ sessionService, promptResolver, init, authInfo }, scenariosPath, toolInvocations, stream, disposables) => {622const workingDirectory = URI.file(path.join(scenariosPath, 'wkspc1'));623await init(workingDirectory);624625const command = platform() === 'win32' ? 'Get-Location' : 'pwd';626const { prompt, attachments } = await resolvePromptWithFileReferences(627`Use terminal command '${command}' to determine my current directory`,628[],629promptResolver630);631const session = await sessionService.createSession(sessionOptionsFor(workingDirectory), CancellationToken.None);632disposables.add(session);633disposables.add(session.object.attachStream(stream));634635await session.object.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt }, attachments, undefined, authInfo, CancellationToken.None);636637assertNoErrorsInStream(stream);638assert.strictEqual(session.object.status, ChatSessionStatus.Completed);639assertStreamContains(stream, 'wkspc1');640assert.ok(permissionConfirmationInvocations.some(invocation => invocation.name === 'vscode_get_terminal_confirmation'));641})642);643});644645function createWithRequestWithFileReference(prompt: string, filesOrReferences: (string | ChatPromptReference)[]): ChatRequest {646const request = new TestChatRequest(prompt);647request.references = filesOrReferences.map(file => {648if (typeof file !== 'string') {649return file;650}651return createFileReference(file);652});653return request;654}655656function createFileReference(file: string): ChatPromptReference {657return {658id: `file-${file}`,659name: `file:${path.basename(file)}`,660value: Uri.file(file),661} satisfies ChatPromptReference;662}663664function createFileSelectionReference(file: string, range: Range): ChatPromptReference {665const uri = Uri.file(file);666return {667id: `file-${file}`,668name: `file:${path.basename(file)}`,669value: new Location(uri, range),670} satisfies ChatPromptReference;671}672673function createDiagnosticReference(file: string, diag: Diagnostic[]): ChatPromptReference {674const uri = Uri.file(file);675return {676id: `file-${file}`,677name: `file:${path.basename(file)}`,678value: new ChatReferenceDiagnostic([[uri, diag]]),679} satisfies ChatPromptReference;680}681682683function resolvePromptWithFileReferences(prompt: string, filesOrReferences: (string | ChatPromptReference)[], promptResolver: CopilotCLIPromptResolver): Promise<{ prompt: string; attachments: any[] }> {684return promptResolver.resolvePrompt(createWithRequestWithFileReference(prompt, filesOrReferences), undefined, [], emptyWorkspaceInfo(), [], CancellationToken.None);685}686687688