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