Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotcliSession.spec.ts
13406 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 { Session, SessionOptions } from '@github/copilot/sdk';
7
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
import type { ChatParticipantToolToken } from 'vscode';
9
import { ConfigKey, IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
10
import { ILogService } from '../../../../../platform/log/common/logService';
11
import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index';
12
import { IRequestLogger } from '../../../../../platform/requestLogger/common/requestLogger';
13
import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger';
14
import { TestWorkspaceService } from '../../../../../platform/test/node/testWorkspaceService';
15
import { IWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
16
import { CancellationToken } from '../../../../../util/vs/base/common/cancellation';
17
import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle';
18
import * as path from '../../../../../util/vs/base/common/path';
19
import { URI } from '../../../../../util/vs/base/common/uri';
20
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
21
import { ChatSessionStatus, ChatToolInvocationPart, LanguageModelTextPart, Uri } from '../../../../../vscodeTypes';
22
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
23
import { MockChatResponseStream } from '../../../../test/node/testHelpers';
24
import { ExternalEditTracker } from '../../../common/externalEditTracker';
25
import { MockChatSessionMetadataStore } from '../../../common/test/mockChatSessionMetadataStore';
26
import { IWorkspaceInfo } from '../../../common/workspaceInfo';
27
import { FakeToolsService, ToolCall } from '../../common/copilotCLITools';
28
import { CopilotCLISession } from '../copilotcliSession';
29
import { PermissionRequest } from '../permissionHelpers';
30
import { IQuestion, IQuestionAnswer, IUserQuestionHandler, UserInputResponse } from '../userInputHelpers';
31
import { NullICopilotCLIImageSupport } from './testHelpers';
32
import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';
33
34
vi.mock('../cliHelpers', async (importOriginal) => ({
35
...(await importOriginal<typeof import('../cliHelpers')>()),
36
getCopilotCLISessionStateDir: () => '/mock-session-state',
37
}));
38
39
// Minimal shapes for types coming from the Copilot SDK we interact with
40
interface MockSdkEventHandler { (payload: unknown): void }
41
type MockSdkEventMap = Map<string, Set<MockSdkEventHandler>>;
42
43
class MockSdkSession {
44
onHandlers: MockSdkEventMap = new Map();
45
public sessionId = 'mock-session-id';
46
public _selectedModel: string | undefined = 'modelA';
47
public authInfo: unknown;
48
private _pendingPermissions = new Map<string, { resolve: (result: unknown) => void }>();
49
private _permissionCounter = 0;
50
private _pendingUserInputs = new Map<string, { resolve: (result: unknown) => void }>();
51
private _userInputCounter = 0;
52
private _pendingExitPlanMode = new Map<string, { resolve: (result: unknown) => void }>();
53
private _exitPlanModeCounter = 0;
54
public aborted = false;
55
56
on(event: string, handler: MockSdkEventHandler) {
57
if (!this.onHandlers.has(event)) {
58
this.onHandlers.set(event, new Set());
59
}
60
this.onHandlers.get(event)!.add(handler);
61
return () => this.onHandlers.get(event)!.delete(handler);
62
}
63
64
emit(event: string, data: unknown) {
65
this.onHandlers.get(event)?.forEach(h => h({ data }));
66
}
67
68
/**
69
* Simulate the SDK emitting a permission.requested event and await the response.
70
* The session's event handler will call respondToPermission() which resolves the returned promise.
71
*/
72
async emitPermissionRequest(permissionRequest: PermissionRequest): Promise<unknown> {
73
const requestId = `perm-${++this._permissionCounter}`;
74
return new Promise(resolve => {
75
this._pendingPermissions.set(requestId, { resolve });
76
this.emit('permission.requested', { requestId, permissionRequest });
77
});
78
}
79
80
respondToPermission(requestId: string, result: unknown) {
81
const pending = this._pendingPermissions.get(requestId);
82
if (pending) {
83
pending.resolve(result);
84
this._pendingPermissions.delete(requestId);
85
}
86
}
87
88
async emitUserInputRequest(request: { question: string; choices?: string[]; allowFreeform?: boolean; toolCallId?: string }): Promise<unknown> {
89
const requestId = `user-input-${++this._userInputCounter}`;
90
return new Promise(resolve => {
91
this._pendingUserInputs.set(requestId, { resolve });
92
this.emit('user_input.requested', { requestId, ...request });
93
});
94
}
95
96
/**
97
* Simulate the SDK emitting an exit_plan_mode.requested event and await the response.
98
* The session's event handler will call respondToExitPlanMode() which resolves the returned promise.
99
*/
100
async emitExitPlanModeRequest(data: { summary: string; actions?: string[] }): Promise<unknown> {
101
const requestId = `exit-plan-${++this._exitPlanModeCounter}`;
102
return new Promise(resolve => {
103
this._pendingExitPlanMode.set(requestId, { resolve });
104
this.emit('exit_plan_mode.requested', { requestId, ...data });
105
});
106
}
107
108
respondToExitPlanMode(requestId: string, result: unknown) {
109
const pending = this._pendingExitPlanMode.get(requestId);
110
if (pending) {
111
pending.resolve(result);
112
this._pendingExitPlanMode.delete(requestId);
113
}
114
}
115
116
respondToUserInput(requestId: string, response: unknown) {
117
const pending = this._pendingUserInputs.get(requestId);
118
if (pending) {
119
pending.resolve(response);
120
this._pendingUserInputs.delete(requestId);
121
}
122
}
123
124
public lastSendOptions: { prompt: string; mode?: string; source?: string } | undefined;
125
public currentMode: string | undefined;
126
127
async send(options: { prompt: string; mode?: string }) {
128
this.lastSendOptions = options;
129
// Simulate a normal successful turn with a message
130
this.emit('user.message', { content: options.prompt });
131
this.emit('assistant.turn_start', {});
132
this.emit('assistant.message', { messageId: `msg_${Date.now()}`, content: `Echo: ${options.prompt}` });
133
this.emit('assistant.turn_end', {});
134
}
135
136
async compactHistory() { return { success: true }; }
137
138
async abort() {
139
this.aborted = true;
140
}
141
142
isAbortable(): boolean { return true; }
143
144
async initializeAndValidateTools() { }
145
getCurrentToolMetadata(): unknown[] | undefined { return this._toolMetadata; }
146
private _toolMetadata: unknown[] | undefined;
147
set toolMetadata(value: unknown[] | undefined) { this._toolMetadata = value; }
148
149
setAuthInfo(info: any) { this.authInfo = info; }
150
async getSelectedModel() { return this._selectedModel; }
151
async setSelectedModel(model: string, _reasoningEffort?: string) { this._selectedModel = model; }
152
async getEvents() { return []; }
153
getPlanPath(): string | null { return null; }
154
155
usage = {
156
getMetrics: async () => ({
157
lastCallInputTokens: 100,
158
lastCallOutputTokens: 50,
159
totalPremiumRequestCost: 0,
160
totalUserRequests: 1,
161
totalApiDurationMs: 1000,
162
sessionStartTime: Date.now(),
163
codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0 },
164
modelMetrics: {},
165
currentModel: this._selectedModel,
166
}),
167
};
168
}
169
170
function createWorkspaceService(root: string): IWorkspaceService {
171
const rootUri = Uri.file(root);
172
return new class extends TestWorkspaceService {
173
override getWorkspaceFolders() {
174
return [
175
rootUri
176
];
177
}
178
override getWorkspaceFolder(uri: Uri) {
179
return uri.fsPath.startsWith(rootUri.fsPath) ? rootUri : undefined;
180
}
181
};
182
}
183
184
function workspaceInfoFor(workingDirectory: Uri | undefined): IWorkspaceInfo {
185
return {
186
folder: workingDirectory,
187
repository: undefined,
188
worktree: undefined,
189
worktreeProperties: undefined,
190
};
191
}
192
193
class UsageCapturingStream extends MockChatResponseStream {
194
public readonly usages: import('vscode').ChatResultUsage[] = [];
195
constructor() {
196
super();
197
}
198
override usage(u: import('vscode').ChatResultUsage): void {
199
this.usages.push(u);
200
}
201
}
202
203
describe('CopilotCLISession', () => {
204
const disposables = new DisposableStore();
205
let sdkSession: MockSdkSession;
206
let workspaceService: IWorkspaceService;
207
let logger: ILogService;
208
let sessionWorkspaceInfo: IWorkspaceInfo;
209
let sessionAgentName: string | undefined;
210
let instaService: IInstantiationService;
211
let requestLogger: IRequestLogger;
212
let toolsService: FakeToolsService;
213
let configurationService: IConfigurationService;
214
let chatSessionMetadataStore: MockChatSessionMetadataStore;
215
let authInfo: NonNullable<SessionOptions['authInfo']>;
216
let userQuestionAnswer: IQuestionAnswer | undefined;
217
beforeEach(async () => {
218
const services = disposables.add(createExtensionUnitTestingServices());
219
const accessor = services.createTestingAccessor();
220
logger = accessor.get(ILogService);
221
requestLogger = new NullRequestLogger();
222
authInfo = {
223
type: 'token',
224
token: '',
225
host: 'https://github.com'
226
};
227
chatSessionMetadataStore = new MockChatSessionMetadataStore();
228
sdkSession = new MockSdkSession();
229
workspaceService = createWorkspaceService('/workspace');
230
sessionWorkspaceInfo = workspaceInfoFor(workspaceService.getWorkspaceFolders()![0]);
231
sessionAgentName = undefined;
232
configurationService = accessor.get(IConfigurationService);
233
await configurationService.setConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled, true);
234
instaService = services.seal();
235
toolsService = new FakeToolsService();
236
userQuestionAnswer = undefined;
237
});
238
239
afterEach(() => {
240
vi.restoreAllMocks();
241
disposables.clear();
242
});
243
244
245
async function createSession(): Promise<CopilotCLISession> {
246
class FakeUserQuestionHandler implements IUserQuestionHandler {
247
_serviceBrand: undefined;
248
async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, toolCallId?: string): Promise<IQuestionAnswer | undefined> {
249
return userQuestionAnswer;
250
}
251
}
252
return disposables.add(new CopilotCLISession(
253
sessionWorkspaceInfo,
254
sessionAgentName,
255
sdkSession as unknown as Session,
256
[],
257
logger,
258
workspaceService,
259
chatSessionMetadataStore,
260
instaService,
261
requestLogger,
262
new NullICopilotCLIImageSupport(),
263
toolsService,
264
new FakeUserQuestionHandler(),
265
configurationService,
266
new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })),
267
new MockGitService(),
268
{ _serviceBrand: undefined } as any
269
));
270
}
271
272
it('handles a successful request and streams assistant output', async () => {
273
const session = await createSession();
274
const stream = new MockChatResponseStream();
275
276
// Attach stream first, then invoke with new signature (no stream param)
277
session.attachStream(stream);
278
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
279
280
expect(session.status).toBe(ChatSessionStatus.Completed);
281
expect(stream.output.join('\n')).toContain('Echo: Hello');
282
// Listeners are disposed after completion, so we only assert original streamed content.
283
});
284
285
it('switches model when different modelId provided', async () => {
286
const session = await createSession();
287
const stream = new MockChatResponseStream();
288
session.attachStream(stream);
289
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Hi' }, [], { model: 'modelB' }, authInfo, CancellationToken.None);
290
291
expect(sdkSession._selectedModel).toBe('modelB');
292
});
293
294
it('fails request when underlying send throws', async () => {
295
// Force send to throw
296
sdkSession.send = async () => { throw new Error('network'); };
297
const session = await createSession();
298
const stream = new MockChatResponseStream();
299
session.attachStream(stream);
300
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Boom' }, [], undefined, authInfo, CancellationToken.None);
301
302
expect(session.status).toBe(ChatSessionStatus.Failed);
303
expect(stream.output.join('\n')).toContain('Error: network');
304
});
305
306
it('emits status events on successful request', async () => {
307
const session = await createSession();
308
const statuses: (ChatSessionStatus | undefined)[] = [];
309
const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s)));
310
const stream = new MockChatResponseStream();
311
session.attachStream(stream);
312
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Status OK' }, [], { model: 'modelA' }, authInfo, CancellationToken.None);
313
listener.dispose?.();
314
315
expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Completed]);
316
expect(session.status).toBe(ChatSessionStatus.Completed);
317
});
318
319
it('emits status events on failed request', async () => {
320
// Force failure
321
sdkSession.send = async () => { throw new Error('boom'); };
322
const session = await createSession();
323
const statuses: (ChatSessionStatus | undefined)[] = [];
324
const listener = disposables.add(session.onDidChangeStatus(s => statuses.push(s)));
325
const stream = new MockChatResponseStream();
326
session.attachStream(stream);
327
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Will Fail' }, [], undefined, authInfo, CancellationToken.None);
328
listener.dispose?.();
329
expect(stream.output.join('\n')).toContain('Error: boom');
330
});
331
332
it('auto-approves read permission inside workspace without external handler', async () => {
333
let result: unknown;
334
sdkSession.send = async ({ prompt }: any) => {
335
sdkSession.emit('assistant.turn_start', {});
336
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
337
// Mid way through, make it look like the sdk requested permission while emitting other messages.
338
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/workspace', 'file.ts'), intention: 'Read file' });
339
sdkSession.emit('assistant.turn_end', {});
340
};
341
const session = await createSession();
342
const stream = new MockChatResponseStream();
343
session.attachStream(stream);
344
345
// Path must be absolute within workspace, should auto-approve
346
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
347
expect(result).toEqual({ kind: 'approve-once' });
348
});
349
350
it('auto-approves read permission for files in session state directory', async () => {
351
let result: unknown;
352
const sessionFilePath = path.join('/mock-session-state', 'mock-session-id', 'plan.md');
353
sdkSession.send = async ({ prompt }: any) => {
354
sdkSession.emit('assistant.turn_start', {});
355
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
356
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: sessionFilePath, intention: 'Read plan' });
357
sdkSession.emit('assistant.turn_end', {});
358
};
359
const session = await createSession();
360
const stream = new MockChatResponseStream();
361
session.attachStream(stream);
362
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
363
expect(result).toEqual({ kind: 'approve-once' });
364
});
365
366
it('auto-approves write permission for files in session state directory', async () => {
367
let result: unknown;
368
const sessionFilePath = path.join('/mock-session-state', 'mock-session-id', 'plan.md');
369
sdkSession.send = async ({ prompt }: any) => {
370
sdkSession.emit('assistant.turn_start', {});
371
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
372
result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: sessionFilePath, intention: 'Write plan', diff: '', canOfferSessionApproval: false });
373
sdkSession.emit('assistant.turn_end', {});
374
};
375
const session = await createSession();
376
const stream = new MockChatResponseStream();
377
session.attachStream(stream);
378
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
379
expect(result).toEqual({ kind: 'approve-once' });
380
});
381
382
it('auto-approves read permission for attached files outside workspace', async () => {
383
let result: unknown;
384
const attachedFilePath = '/outside-workspace/attached-file.ts';
385
sdkSession.send = async ({ prompt }: any) => {
386
sdkSession.emit('assistant.turn_start', {});
387
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
388
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: attachedFilePath, intention: 'Read file' });
389
sdkSession.emit('assistant.turn_end', {});
390
};
391
const session = await createSession();
392
const stream = new MockChatResponseStream();
393
session.attachStream(stream);
394
395
const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }];
396
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None);
397
expect(result).toEqual({ kind: 'approve-once' });
398
});
399
400
it('does not auto-approve read permission for non-attached files outside workspace', async () => {
401
let result: unknown;
402
const nonAttachedFilePath = '/outside-workspace/other-file.ts';
403
const attachedFilePath = '/outside-workspace/attached-file.ts';
404
toolsService.setConfirmationResult('no');
405
sdkSession.send = async ({ prompt }: any) => {
406
sdkSession.emit('assistant.turn_start', {});
407
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
408
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: nonAttachedFilePath, intention: 'Read file' });
409
sdkSession.emit('assistant.turn_end', {});
410
};
411
const session = await createSession();
412
const stream = new MockChatResponseStream();
413
session.attachStream(stream);
414
415
const attachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'attached-file.ts' }];
416
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, attachments as any, undefined, authInfo, CancellationToken.None);
417
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
418
expect(toolsService.invokeToolCalls).toHaveLength(2);
419
});
420
421
it('auto-approves read permission inside working directory without external handler', async () => {
422
let result: unknown;
423
sessionWorkspaceInfo = workspaceInfoFor(URI.file('/workingDirectory'));
424
sdkSession.send = async ({ prompt }: any) => {
425
sdkSession.emit('assistant.turn_start', {});
426
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
427
// Mid way through, make it look like the sdk requested permission while emitting other messages.
428
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/workingDirectory', 'file.ts'), intention: 'Read file' });
429
sdkSession.emit('assistant.turn_end', {});
430
};
431
const session = await createSession();
432
const stream = new MockChatResponseStream();
433
session.attachStream(stream);
434
435
// Path must be absolute within workspace, should auto-approve
436
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
437
expect(result).toEqual({ kind: 'approve-once' });
438
});
439
440
it('auto-approves read permission for files in workspace folder when worktree is the working directory', async () => {
441
let result: unknown;
442
const worktreeUri = URI.file('/worktrees/session1');
443
const folderUri = URI.file('/original-repo');
444
sessionWorkspaceInfo = {
445
folder: folderUri,
446
repository: folderUri,
447
worktree: worktreeUri,
448
worktreeProperties: { version: 1, autoCommit: false, baseCommit: 'abc', branchName: 'main', repositoryPath: '/original-repo', worktreePath: '/worktrees/session1' },
449
};
450
sdkSession.send = async ({ prompt }: any) => {
451
sdkSession.emit('assistant.turn_start', {});
452
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
453
// File is in workspace.folder (/original-repo), not in the worktree which is the working directory
454
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/original-repo', 'src/main.ts'), intention: 'Read file' });
455
sdkSession.emit('assistant.turn_end', {});
456
};
457
const session = await createSession();
458
const stream = new MockChatResponseStream();
459
session.attachStream(stream);
460
461
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
462
expect(result).toEqual({ kind: 'approve-once' });
463
});
464
465
it('auto-approves read permission for files in the worktree when workspace has both worktree and repository', async () => {
466
let result: unknown;
467
const worktreeUri = URI.file('/worktrees/session1');
468
const folderUri = URI.file('/original-repo');
469
sessionWorkspaceInfo = {
470
folder: folderUri,
471
repository: folderUri,
472
worktree: worktreeUri,
473
worktreeProperties: { version: 1, autoCommit: false, baseCommit: 'abc', branchName: 'main', repositoryPath: '/original-repo', worktreePath: '/worktrees/session1' },
474
};
475
sdkSession.send = async ({ prompt }: any) => {
476
sdkSession.emit('assistant.turn_start', {});
477
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
478
// File is in the worktree which is also the working directory
479
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/worktrees/session1', 'src/main.ts'), intention: 'Read file' });
480
sdkSession.emit('assistant.turn_end', {});
481
};
482
const session = await createSession();
483
const stream = new MockChatResponseStream();
484
session.attachStream(stream);
485
486
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
487
expect(result).toEqual({ kind: 'approve-once' });
488
});
489
490
it('requires read permission outside workspace and working directory', async () => {
491
let result: unknown;
492
toolsService.setConfirmationResult('no');
493
sdkSession.send = async ({ prompt }: any) => {
494
sdkSession.emit('assistant.turn_start', {});
495
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
496
// Mid way through, make it look like the sdk requested permission while emitting other messages.
497
result = await sdkSession.emitPermissionRequest({ kind: 'read', path: path.join('/workingDirectory', 'file.ts'), intention: 'Read file' });
498
499
sdkSession.emit('assistant.turn_end', {});
500
};
501
const session = await createSession();
502
const stream = new MockChatResponseStream();
503
session.attachStream(stream);
504
505
// Path must be absolute within workspace, should auto-approve
506
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test' }, [], undefined, authInfo, CancellationToken.None);
507
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
508
expect(toolsService.invokeToolCalls).toHaveLength(2);
509
expect(toolsService.invokeToolCalls[1].input).toMatchObject({
510
title: 'Read file(s)',
511
message: 'Read file'
512
});
513
});
514
515
it('approves write permission when handler returns true', async () => {
516
let result: unknown;
517
const session = await createSession();
518
toolsService.setConfirmationResult('yes');
519
sdkSession.send = async ({ prompt }: any) => {
520
sdkSession.emit('assistant.turn_start', {});
521
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
522
// Mid way through, make it look like the sdk requested permission while emitting other messages.
523
result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: 'a.ts', intention: 'Update file', diff: '', canOfferSessionApproval: false });
524
sdkSession.emit('assistant.turn_end', {});
525
};
526
const stream = new MockChatResponseStream();
527
session.attachStream(stream);
528
529
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None);
530
531
expect(result).toEqual({ kind: 'approve-once' });
532
});
533
534
it('denies write permission when handler returns false', async () => {
535
let result: unknown;
536
const session = await createSession();
537
toolsService.setConfirmationResult('no');
538
sdkSession.send = async ({ prompt }: any) => {
539
sdkSession.emit('assistant.turn_start', {});
540
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
541
// Mid way through, make it look like the sdk requested permission while emitting other messages.
542
result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: 'b.ts', intention: 'Update file', diff: '', canOfferSessionApproval: false });
543
sdkSession.emit('assistant.turn_end', {});
544
};
545
const stream = new MockChatResponseStream();
546
session.attachStream(stream);
547
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None);
548
549
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
550
});
551
552
it('denies write permission when handler throws', async () => {
553
let result: unknown;
554
const session = await createSession();
555
toolsService.invokeTool = vi.fn(async () => {
556
throw new Error('oops');
557
});
558
sdkSession.send = async ({ prompt }: any) => {
559
sdkSession.emit('assistant.turn_start', {});
560
sdkSession.emit('assistant.message', { content: `Echo: ${prompt}` });
561
// Mid way through, make it look like the sdk requested permission while emitting other messages.
562
result = await sdkSession.emitPermissionRequest({ kind: 'write', fileName: 'err.ts', intention: 'Update file', diff: '', canOfferSessionApproval: false });
563
sdkSession.emit('assistant.turn_end', {});
564
};
565
const stream = new MockChatResponseStream();
566
session.attachStream(stream);
567
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Write' }, [], undefined, authInfo, CancellationToken.None);
568
569
expect(result).toEqual({ kind: 'denied-interactively-by-user' });
570
});
571
572
it('preserves order of edit toolCallIds and permissions for multiple pending edits', async () => {
573
// Arrange a deferred send so we can emit tool events before request finishes
574
let resolveSend: () => void;
575
sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });
576
const session = await createSession();
577
toolsService.setConfirmationResult('yes');
578
const stream = new MockChatResponseStream();
579
session.attachStream(stream);
580
// Spy on trackEdit to capture ordering (we don't want to depend on externalEdit mechanics here)
581
const trackedOrder: string[] = [];
582
const trackSpy = vi.spyOn(ExternalEditTracker.prototype, 'trackEdit').mockImplementation(async function (this: any, editKey: string) {
583
trackedOrder.push(editKey);
584
// Immediately resolve to avoid hanging on externalEdit lifecycle
585
return Promise.resolve();
586
});
587
588
// Act: start handling request (do not await yet)
589
const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Edits' }, [], undefined, authInfo, CancellationToken.None);
590
591
// Wait a tick to ensure event listeners are registered inside handleRequest
592
await new Promise(r => setTimeout(r, 0));
593
594
// Emit 10 edit tool start events in rapid succession for the same file
595
const filePath = '/workspace/abc.py';
596
for (let i = 1; i <= 10; i++) {
597
const editToolCall: ToolCall = {
598
toolName: 'edit',
599
toolCallId: String(i),
600
arguments: { path: filePath, new_str: 'new content' },
601
};
602
sdkSession.emit('tool.execution_start', editToolCall);
603
}
604
605
// Now request permissions sequentially AFTER all tool calls have been emitted
606
const permissionResults: any[] = [];
607
for (let i = 1; i <= 10; i++) {
608
// Each permission request should dequeue the next toolCallId for the file
609
const result = await sdkSession.emitPermissionRequest({
610
kind: 'write',
611
fileName: filePath,
612
intention: 'Apply edit',
613
diff: '',
614
toolCallId: String(i),
615
canOfferSessionApproval: false
616
});
617
permissionResults.push(result);
618
// Complete the edit so the tracker (if it were real) would finish; emit completion event
619
sdkSession.emit('tool.execution_complete', {
620
toolCallId: String(i),
621
toolName: 'str_replace_editor',
622
arguments: { command: 'str_replace', path: filePath },
623
success: true,
624
result: { content: '' }
625
});
626
}
627
628
// Allow the request to finish
629
resolveSend!();
630
await requestPromise;
631
632
// Assert ordering of trackEdit invocations exactly matches toolCallIds 1..10
633
expect(trackedOrder).toEqual(Array.from({ length: 10 }, (_, i) => String(i + 1)));
634
expect(permissionResults.every(r => r.kind === 'approve-once')).toBe(true);
635
expect(trackSpy).toHaveBeenCalledTimes(10);
636
637
trackSpy.mockRestore();
638
});
639
640
it('delays tool invocation messages for permission-requiring tools until permission is resolved', async () => {
641
let resolveSend: () => void;
642
sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });
643
const session = await createSession();
644
const pushedParts: unknown[] = [];
645
const stream = new MockChatResponseStream(part => pushedParts.push(part));
646
session.attachStream(stream);
647
toolsService.setConfirmationResult('yes');
648
649
const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run bash' }, [], undefined, authInfo, CancellationToken.None);
650
await new Promise(r => setTimeout(r, 0));
651
652
// Emit a bash tool start - this should be delayed
653
const bashToolCall: ToolCall = { toolName: 'bash', toolCallId: 'bash-delay-1', arguments: { command: 'echo hi', description: 'Echo test' } };
654
sdkSession.emit('tool.execution_start', bashToolCall);
655
await new Promise(r => setTimeout(r, 0));
656
657
// No ChatToolInvocationPart should be pushed yet for the bash tool
658
const toolPartsBeforePermission = pushedParts.filter(p => p instanceof ChatToolInvocationPart);
659
expect(toolPartsBeforePermission).toHaveLength(0);
660
661
// When permission is requested, the pending messages should be flushed
662
await sdkSession.emitPermissionRequest({
663
kind: 'shell',
664
commands: [{ identifier: 'echo hi', readOnly: false }],
665
intention: 'Run command',
666
fullCommandText: 'echo hi',
667
possiblePaths: [],
668
possibleUrls: [],
669
hasWriteFileRedirection: false,
670
canOfferSessionApproval: false
671
});
672
await new Promise(r => setTimeout(r, 0));
673
674
const toolPartsAfterPermission = pushedParts.filter(p => p instanceof ChatToolInvocationPart);
675
expect(toolPartsAfterPermission.length).toBeGreaterThanOrEqual(1);
676
677
sdkSession.emit('tool.execution_complete', { toolCallId: 'bash-delay-1', toolName: 'bash', success: true, result: { content: 'hi' } });
678
resolveSend!();
679
await requestPromise;
680
});
681
682
it('uses remote permission responses when Mission Control is active', async () => {
683
let permissionResult: unknown;
684
sdkSession.send = async () => {
685
permissionResult = await sdkSession.emitPermissionRequest({
686
kind: 'shell',
687
toolCallId: 'remote-permission-tool',
688
commands: [{ identifier: 'echo "Hello world"', readOnly: false }],
689
intention: 'Run command',
690
fullCommandText: 'echo "Hello world"',
691
possiblePaths: [],
692
possibleUrls: [],
693
hasWriteFileRedirection: false,
694
canOfferSessionApproval: false
695
});
696
};
697
const session = await createSession();
698
let localPromptToken: CancellationToken | undefined;
699
const invokeToolSpy = vi.spyOn(toolsService, 'invokeTool').mockImplementation((async (name: string, options: unknown, token?: CancellationToken) => {
700
if (name === 'vscode_get_confirmation' || name === 'vscode_get_terminal_confirmation') {
701
localPromptToken = token;
702
return await new Promise(resolve => {
703
token?.onCancellationRequested(() => resolve({ content: [new LanguageModelTextPart('no')] }));
704
});
705
}
706
return { content: [] };
707
}) as typeof toolsService.invokeTool);
708
const remoteState = {
709
mcSessionId: 'mc-session',
710
mcEventBuffer: [],
711
mcCompletedCommandIds: [],
712
mcPendingPermissionRequests: new Map(),
713
mcFlushInterval: undefined,
714
mcPollInterval: undefined,
715
mcLastEventId: null,
716
mcLastSubmitAttemptTimeMs: Date.now(),
717
mcProcessedCommandIds: new Set<string>(),
718
mcSdkSession: sdkSession as unknown as Session,
719
mcEventListenerDispose: undefined,
720
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
721
};
722
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
723
724
const requestPromise = session.handleRequest(
725
{ id: '', toolInvocationToken: undefined as never },
726
{ prompt: 'Run bash' },
727
[],
728
undefined,
729
authInfo,
730
CancellationToken.None
731
);
732
await new Promise(r => setTimeout(r, 0));
733
734
await (CopilotCLISession as any)._pollMcCommandsStatic(
735
session.sessionId,
736
remoteState,
737
{
738
getPendingCommands: async () => [{
739
id: 'mc-command-1',
740
content: JSON.stringify({ promptId: 'remote-permission-tool', approved: true, scope: 'once' }),
741
state: 'in_progress',
742
type: 'permission_response',
743
}],
744
},
745
logger,
746
);
747
748
await requestPromise;
749
750
expect(permissionResult).toEqual({ kind: 'approve-once' });
751
const confirmationToolCalls = invokeToolSpy.mock.calls.filter(call =>
752
call[0] === 'vscode_get_confirmation' || call[0] === 'vscode_get_terminal_confirmation'
753
);
754
expect(confirmationToolCalls).toHaveLength(1);
755
expect(localPromptToken?.isCancellationRequested).toBe(true);
756
expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-1']);
757
});
758
759
it('uses local permission responses when Mission Control is active', async () => {
760
let permissionResult: unknown;
761
sdkSession.send = async () => {
762
permissionResult = await sdkSession.emitPermissionRequest({
763
kind: 'shell',
764
toolCallId: 'local-permission-tool',
765
commands: [{ identifier: 'echo "Hello world"', readOnly: false }],
766
intention: 'Run command',
767
fullCommandText: 'echo "Hello world"',
768
possiblePaths: [],
769
possibleUrls: [],
770
hasWriteFileRedirection: false,
771
canOfferSessionApproval: false
772
});
773
};
774
toolsService.setConfirmationResult('yes');
775
const session = await createSession();
776
const invokeToolSpy = vi.spyOn(toolsService, 'invokeTool');
777
const remoteState = {
778
mcSessionId: 'mc-session',
779
mcEventBuffer: [],
780
mcCompletedCommandIds: [],
781
mcPendingPermissionRequests: new Map(),
782
mcFlushInterval: undefined,
783
mcPollInterval: undefined,
784
mcLastEventId: null,
785
mcLastSubmitAttemptTimeMs: Date.now(),
786
mcProcessedCommandIds: new Set<string>(),
787
mcSdkSession: sdkSession as unknown as Session,
788
mcEventListenerDispose: undefined,
789
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
790
};
791
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
792
793
await session.handleRequest(
794
{ id: '', toolInvocationToken: undefined as never },
795
{ prompt: 'Run bash' },
796
[],
797
undefined,
798
authInfo,
799
CancellationToken.None
800
);
801
802
expect(permissionResult).toEqual({ kind: 'approve-once' });
803
const confirmationToolCalls = invokeToolSpy.mock.calls.filter(call =>
804
call[0] === 'vscode_get_confirmation' || call[0] === 'vscode_get_terminal_confirmation'
805
);
806
expect(confirmationToolCalls).toHaveLength(1);
807
expect(remoteState.mcPendingPermissionRequests.size).toBe(0);
808
});
809
810
it('uses remote ask user responses when Mission Control is active', async () => {
811
let userInputResult: unknown;
812
const notifiedAnswers: Array<{ toolCallId: string; question: IQuestion; response: UserInputResponse }> = [];
813
sdkSession.send = async () => {
814
userInputResult = await sdkSession.emitUserInputRequest({
815
question: 'What is your favorite VS Code feature or extension?',
816
allowFreeform: true,
817
toolCallId: 'ask-user-tool',
818
});
819
};
820
const session = await createSession();
821
let localPromptToken: CancellationToken | undefined;
822
Object.defineProperty(session, '_userQuestionHandler', {
823
value: {
824
_serviceBrand: undefined,
825
async askUserQuestion(_question: IQuestion, _toolInvocationToken: ChatParticipantToolToken, token: CancellationToken, _toolCallId?: string): Promise<IQuestionAnswer | undefined> {
826
localPromptToken = token;
827
return await new Promise<IQuestionAnswer | undefined>(resolve => {
828
token.onCancellationRequested(() => resolve(undefined));
829
});
830
},
831
async notifyQuestionCarouselAnswer(toolCallId: string, question: IQuestion, response: UserInputResponse): Promise<void> {
832
notifiedAnswers.push({ toolCallId, question, response });
833
},
834
} satisfies IUserQuestionHandler,
835
configurable: true,
836
});
837
const remoteState = {
838
mcSessionId: 'mc-session',
839
mcEventBuffer: [],
840
mcCompletedCommandIds: [],
841
mcPendingPermissionRequests: new Map(),
842
mcFlushInterval: undefined,
843
mcPollInterval: undefined,
844
mcLastEventId: null,
845
mcLastSubmitAttemptTimeMs: Date.now(),
846
mcProcessedCommandIds: new Set<string>(),
847
mcSdkSession: sdkSession as unknown as Session,
848
mcEventListenerDispose: undefined,
849
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
850
};
851
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
852
853
const requestPromise = session.handleRequest(
854
{ id: '', toolInvocationToken: {} as never },
855
{ prompt: 'Ask me about VS Code' },
856
[],
857
undefined,
858
authInfo,
859
CancellationToken.None
860
);
861
await new Promise(r => setTimeout(r, 0));
862
863
await (CopilotCLISession as any)._pollMcCommandsStatic(
864
session.sessionId,
865
remoteState,
866
{
867
getPendingCommands: async () => [{
868
id: 'mc-command-ask-user',
869
content: JSON.stringify({ requestId: 'user-input-1', answer: 'none', wasFreeform: true }),
870
state: 'in_progress',
871
type: 'ask_user_response',
872
}],
873
},
874
logger,
875
);
876
877
await requestPromise;
878
879
expect(userInputResult).toEqual({ answer: 'none', wasFreeform: true });
880
expect(notifiedAnswers).toEqual([{
881
toolCallId: 'ask-user-tool',
882
question: {
883
question: 'What is your favorite VS Code feature or extension?',
884
options: [],
885
allowFreeformInput: true,
886
header: 'What is your favorite VS Code feature or extension?',
887
},
888
response: { answer: 'none', wasFreeform: true },
889
}]);
890
expect(localPromptToken?.isCancellationRequested).toBe(true);
891
expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-ask-user']);
892
});
893
894
it('aborts pending remote ask user requests when Mission Control stop is requested', async () => {
895
let userInputResult: unknown;
896
sdkSession.send = async () => {
897
userInputResult = await sdkSession.emitUserInputRequest({
898
question: 'What is your favorite VS Code feature or extension?',
899
allowFreeform: true,
900
toolCallId: 'ask-user-tool',
901
});
902
if (sdkSession.aborted) {
903
return;
904
}
905
sdkSession.emit('assistant.turn_start', {});
906
sdkSession.emit('assistant.turn_end', {});
907
};
908
const session = await createSession();
909
let localPromptToken: CancellationToken | undefined;
910
Object.defineProperty(session, '_userQuestionHandler', {
911
value: {
912
_serviceBrand: undefined,
913
async askUserQuestion(_question: IQuestion, _toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise<IQuestionAnswer | undefined> {
914
localPromptToken = token;
915
return await new Promise<IQuestionAnswer | undefined>(resolve => {
916
token.onCancellationRequested(() => resolve(undefined));
917
});
918
},
919
} satisfies IUserQuestionHandler,
920
configurable: true,
921
});
922
const remoteState = {
923
mcSessionId: 'mc-session',
924
mcEventBuffer: [],
925
mcCompletedCommandIds: [],
926
mcPendingPermissionRequests: new Map(),
927
mcFlushInterval: undefined,
928
mcPollInterval: undefined,
929
mcLastEventId: null,
930
mcLastSubmitAttemptTimeMs: Date.now(),
931
mcProcessedCommandIds: new Set<string>(),
932
mcSdkSession: sdkSession as unknown as Session,
933
mcEventListenerDispose: undefined,
934
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
935
};
936
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
937
938
const requestPromise = session.handleRequest(
939
{ id: '', toolInvocationToken: {} as never },
940
{ prompt: 'Ask me about VS Code' },
941
[],
942
undefined,
943
authInfo,
944
CancellationToken.None
945
);
946
await new Promise(r => setTimeout(r, 0));
947
948
await (CopilotCLISession as any)._pollMcCommandsStatic(
949
session.sessionId,
950
remoteState,
951
{
952
getPendingCommands: async () => [{
953
id: 'mc-command-abort',
954
content: '',
955
state: 'in_progress',
956
type: 'abort',
957
}],
958
},
959
logger,
960
);
961
962
await requestPromise;
963
964
expect(sdkSession.aborted).toBe(true);
965
expect(userInputResult).toEqual({ answer: '', wasFreeform: false });
966
expect(localPromptToken?.isCancellationRequested).toBe(true);
967
expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-abort']);
968
});
969
970
it('reports remote control status when /remote is invoked without arguments', async () => {
971
await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true);
972
const session = await createSession();
973
const stream = new MockChatResponseStream();
974
session.attachStream(stream);
975
976
await session.handleRequest(
977
{ id: '', toolInvocationToken: undefined as never },
978
{ command: 'remote', prompt: '' },
979
[],
980
undefined,
981
authInfo,
982
CancellationToken.None
983
);
984
985
expect(stream.output.join('\n')).toContain('Remote control is disabled. Use /remote on to enable it.');
986
});
987
988
it('reports enabled remote control status when /remote is invoked without arguments', async () => {
989
await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true);
990
const session = await createSession();
991
const stream = new MockChatResponseStream();
992
session.attachStream(stream);
993
const remoteState = {
994
mcSessionId: 'mc-session',
995
mcFrontendUrl: 'https://github.com/microsoft/vscode/tasks/123',
996
mcEventBuffer: [],
997
mcCompletedCommandIds: [],
998
mcPendingPermissionRequests: new Map(),
999
mcFlushInterval: undefined,
1000
mcPollInterval: undefined,
1001
mcLastEventId: null,
1002
mcLastSubmitAttemptTimeMs: Date.now(),
1003
mcProcessedCommandIds: new Set<string>(),
1004
mcSdkSession: sdkSession as unknown as Session,
1005
mcEventListenerDispose: undefined,
1006
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1007
};
1008
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1009
1010
await session.handleRequest(
1011
{ id: '', toolInvocationToken: undefined as never },
1012
{ command: 'remote', prompt: '' },
1013
[],
1014
undefined,
1015
authInfo,
1016
CancellationToken.None
1017
);
1018
1019
expect(stream.output.join('\n')).toContain('Remote control is enabled. Use /remote off to disable it. Session URL: https://github.com/microsoft/vscode/tasks/123');
1020
});
1021
1022
it('shows /remote usage for unsupported arguments', async () => {
1023
await configurationService.setConfig(ConfigKey.Advanced.CLIRemoteEnabled, true);
1024
const session = await createSession();
1025
const stream = new MockChatResponseStream();
1026
session.attachStream(stream);
1027
1028
await session.handleRequest(
1029
{ id: '', toolInvocationToken: undefined as never },
1030
{ command: 'remote', prompt: 'wat' },
1031
[],
1032
undefined,
1033
authInfo,
1034
CancellationToken.None
1035
);
1036
1037
expect(stream.output.join('\n')).toContain('Usage: /remote, /remote on, /remote off');
1038
});
1039
1040
it('forwards session.idle to Mission Control so remote running state clears', async () => {
1041
const session = await createSession();
1042
const remoteState = {
1043
mcSessionId: 'mc-session',
1044
mcEventBuffer: [],
1045
mcCompletedCommandIds: [],
1046
mcPendingPermissionRequests: new Map(),
1047
mcFlushInterval: undefined,
1048
mcPollInterval: undefined,
1049
mcLastEventId: null,
1050
mcLastSubmitAttemptTimeMs: Date.now(),
1051
mcProcessedCommandIds: new Set<string>(),
1052
mcSdkSession: sdkSession as unknown as Session,
1053
mcEventListenerDispose: undefined,
1054
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1055
};
1056
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1057
1058
(session as any)._bufferMcEvent({ type: 'session.idle', data: {} });
1059
1060
expect(remoteState.mcEventBuffer).toHaveLength(1);
1061
expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('session.idle');
1062
});
1063
1064
it('forwards session.title_changed to Mission Control as an ephemeral event', async () => {
1065
const session = await createSession();
1066
const remoteState = {
1067
mcSessionId: 'mc-session',
1068
mcEventBuffer: [],
1069
mcCompletedCommandIds: [],
1070
mcPendingPermissionRequests: new Map(),
1071
mcFlushInterval: undefined,
1072
mcPollInterval: undefined,
1073
mcLastEventId: null,
1074
mcLastSubmitAttemptTimeMs: Date.now(),
1075
mcProcessedCommandIds: new Set<string>(),
1076
mcSdkSession: sdkSession as unknown as Session,
1077
mcEventListenerDispose: undefined,
1078
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1079
};
1080
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1081
1082
(session as any)._bufferMcEvent({
1083
type: 'session.title_changed',
1084
id: 'title-change-1',
1085
timestamp: '2026-01-01T00:00:00.000Z',
1086
parentId: 'visible-root-message',
1087
ephemeral: true,
1088
data: { title: 'Remote Session Title' },
1089
});
1090
1091
expect(remoteState.mcEventBuffer).toHaveLength(1);
1092
expect((remoteState.mcEventBuffer[0] as { type: string; ephemeral?: true }).type).toBe('session.title_changed');
1093
expect((remoteState.mcEventBuffer[0] as { ephemeral?: true }).ephemeral).toBe(true);
1094
expect((remoteState.mcEventBuffer[0] as { data: { title: string } }).data.title).toBe('Remote Session Title');
1095
});
1096
1097
it('prefers existing session history over the current /remote prompt when deriving the Mission Control title', async () => {
1098
const session = await createSession();
1099
vi.spyOn(sdkSession, 'getEvents').mockReturnValue([
1100
{ type: 'user.message', data: { content: 'hey' } },
1101
] as any);
1102
(session as any)._pendingPrompt = '/remote';
1103
1104
await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('hey');
1105
});
1106
1107
it('sanitizes hidden prompt markup when deriving the Mission Control title', async () => {
1108
const session = await createSession();
1109
vi.spyOn(sdkSession, 'getEvents').mockReturnValue([
1110
{
1111
type: 'user.message',
1112
data: {
1113
content: '/remote <reminder>IMPORTANT: hidden context</reminder><attachments><attachment id="microsoft/vscode-tools">repo</attachment></attachments><userRequest></userRequest>',
1114
}
1115
},
1116
] as any);
1117
1118
await expect((session as any)._getMissionControlSessionTitle()).resolves.toBe('/remote');
1119
});
1120
1121
it('sanitizes hidden prompt markup before forwarding user messages to Mission Control', async () => {
1122
const session = await createSession();
1123
const remoteState = {
1124
mcSessionId: 'mc-session',
1125
mcEventBuffer: [],
1126
mcCompletedCommandIds: [],
1127
mcPendingPermissionRequests: new Map(),
1128
mcFlushInterval: undefined,
1129
mcPollInterval: undefined,
1130
mcLastEventId: null,
1131
mcLastSubmitAttemptTimeMs: Date.now(),
1132
mcProcessedCommandIds: new Set<string>(),
1133
mcSdkSession: sdkSession as unknown as Session,
1134
mcEventListenerDispose: undefined,
1135
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1136
};
1137
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1138
1139
(session as any)._bufferMcEvent({
1140
type: 'user.message',
1141
id: 'remote-command-message',
1142
timestamp: '2026-01-01T00:00:00.000Z',
1143
data: {
1144
content: '/remote <reminder>IMPORTANT: hidden context</reminder><attachments><attachment id="microsoft/vscode-tools">repo</attachment></attachments><userRequest></userRequest>',
1145
},
1146
});
1147
1148
expect(remoteState.mcEventBuffer).toHaveLength(1);
1149
expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('/remote');
1150
});
1151
1152
it('strips shell tool descriptions before forwarding tool starts to Mission Control', async () => {
1153
const session = await createSession();
1154
const remoteState = {
1155
mcSessionId: 'mc-session',
1156
mcEventBuffer: [],
1157
mcCompletedCommandIds: [],
1158
mcPendingPermissionRequests: new Map(),
1159
mcFlushInterval: undefined,
1160
mcPollInterval: undefined,
1161
mcLastEventId: null,
1162
mcLastSubmitAttemptTimeMs: Date.now(),
1163
mcProcessedCommandIds: new Set<string>(),
1164
mcSdkSession: sdkSession as unknown as Session,
1165
mcEventListenerDispose: undefined,
1166
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1167
};
1168
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1169
1170
(session as any)._bufferMcEvent({
1171
type: 'tool.execution_start',
1172
data: {
1173
toolCallId: 'bash-1',
1174
toolName: 'bash',
1175
arguments: { command: 'echo hello', description: 'Simple echo command.' },
1176
},
1177
});
1178
1179
expect(remoteState.mcEventBuffer).toHaveLength(1);
1180
expect((remoteState.mcEventBuffer[0] as {
1181
data: { arguments: { command: string; description?: string } };
1182
}).data.arguments).toEqual({ command: 'echo hello' });
1183
});
1184
1185
it('strips task descriptions before forwarding tool starts to Mission Control', async () => {
1186
const session = await createSession();
1187
const remoteState = {
1188
mcSessionId: 'mc-session',
1189
mcEventBuffer: [],
1190
mcCompletedCommandIds: [],
1191
mcPendingPermissionRequests: new Map(),
1192
mcFlushInterval: undefined,
1193
mcPollInterval: undefined,
1194
mcLastEventId: null,
1195
mcLastSubmitAttemptTimeMs: Date.now(),
1196
mcProcessedCommandIds: new Set<string>(),
1197
mcSdkSession: sdkSession as unknown as Session,
1198
mcEventListenerDispose: undefined,
1199
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1200
};
1201
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1202
1203
(session as any)._bufferMcEvent({
1204
type: 'tool.execution_start',
1205
data: {
1206
toolCallId: 'task-1',
1207
toolName: 'task',
1208
arguments: { description: 'Simple task.', prompt: 'Run echo', agent_type: 'task' },
1209
},
1210
});
1211
1212
expect(remoteState.mcEventBuffer).toHaveLength(1);
1213
expect((remoteState.mcEventBuffer[0] as {
1214
data: { arguments: { prompt: string; agent_type: string; description?: string } };
1215
}).data.arguments).toEqual({ prompt: 'Run echo', agent_type: 'task' });
1216
});
1217
1218
it('does not forward report_intent tool events to Mission Control', async () => {
1219
const session = await createSession();
1220
const remoteState = {
1221
mcSessionId: 'mc-session',
1222
mcEventBuffer: [],
1223
mcCompletedCommandIds: [],
1224
mcPendingPermissionRequests: new Map(),
1225
mcFlushInterval: undefined,
1226
mcPollInterval: undefined,
1227
mcLastEventId: null,
1228
mcLastSubmitAttemptTimeMs: Date.now(),
1229
mcProcessedCommandIds: new Set<string>(),
1230
mcSdkSession: sdkSession as unknown as Session,
1231
mcEventListenerDispose: undefined,
1232
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1233
};
1234
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1235
1236
(session as any)._bufferMcEvent({
1237
type: 'tool.execution_start',
1238
data: { toolCallId: 'ri-1', toolName: 'report_intent', arguments: { intent: 'Running echo command' } },
1239
});
1240
(session as any)._bufferMcEvent({
1241
type: 'tool.execution_complete',
1242
data: { toolCallId: 'ri-1', toolName: 'report_intent', success: true },
1243
});
1244
(session as any)._bufferMcEvent({
1245
type: 'tool.execution_start',
1246
data: { toolCallId: 'bash-1', toolName: 'bash', arguments: { command: 'echo hello' } },
1247
});
1248
1249
expect(remoteState.mcEventBuffer).toHaveLength(1);
1250
expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('tool.execution_start');
1251
expect((remoteState.mcEventBuffer[0] as { data: { toolName: string } }).data.toolName).toBe('bash');
1252
});
1253
1254
it('forwards command-sourced user messages and acknowledges the command with the echoed turn', async () => {
1255
const session = await createSession();
1256
const remoteState = {
1257
mcSessionId: 'mc-session',
1258
mcEventBuffer: [],
1259
mcCompletedCommandIds: [],
1260
mcPendingPermissionRequests: new Map(),
1261
mcFlushInterval: undefined,
1262
mcPollInterval: undefined,
1263
mcLastEventId: null,
1264
mcLastSubmitAttemptTimeMs: Date.now(),
1265
mcProcessedCommandIds: new Set<string>(),
1266
mcPendingCommandCompletionIds: new Set<string>(['mc-command-1']),
1267
mcSdkSession: sdkSession as unknown as Session,
1268
mcEventListenerDispose: undefined,
1269
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1270
};
1271
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1272
1273
(session as any)._bufferMcEvent({
1274
type: 'user.message',
1275
id: 'remote-command-message',
1276
timestamp: '2026-01-01T00:00:00.000Z',
1277
parentId: 'visible-root-message',
1278
data: { content: 'hey', source: 'command-mc-command-1' },
1279
});
1280
expect(remoteState.mcCompletedCommandIds).toEqual(['mc-command-1']);
1281
1282
(session as any)._bufferMcEvent({
1283
type: 'assistant.message',
1284
id: 'assistant-reply',
1285
timestamp: '2026-01-01T00:00:01.000Z',
1286
parentId: 'remote-command-message',
1287
data: { content: 'Hello! How can I help you today?' },
1288
});
1289
1290
expect(remoteState.mcEventBuffer).toHaveLength(2);
1291
expect((remoteState.mcEventBuffer[0] as { type: string }).type).toBe('user.message');
1292
expect((remoteState.mcEventBuffer[0] as { data: { content: string } }).data.content).toBe('hey');
1293
expect((remoteState.mcEventBuffer[1] as { type: string; parentId: string | null }).type).toBe('assistant.message');
1294
expect((remoteState.mcEventBuffer[1] as { parentId: string | null }).parentId).toBe('remote-command-message');
1295
});
1296
1297
it('forwards remote command source to the SDK send options', async () => {
1298
const session = await createSession();
1299
const stream = new MockChatResponseStream();
1300
session.attachStream(stream);
1301
1302
await session.handleRequest(
1303
{ id: '', toolInvocationToken: undefined as never },
1304
{ prompt: 'hey', source: 'command-mc-command-1' },
1305
[],
1306
undefined,
1307
authInfo,
1308
CancellationToken.None
1309
);
1310
1311
expect(sdkSession.lastSendOptions?.source).toBe('command-mc-command-1');
1312
});
1313
1314
it('flushes completed Mission Control command ids even when there are no buffered events', async () => {
1315
const session = await createSession();
1316
const submitEvents = vi.fn(async () => true);
1317
const remoteState = {
1318
mcSessionId: 'mc-session',
1319
mcEventBuffer: [],
1320
mcCompletedCommandIds: ['mc-command-1'],
1321
mcPendingPermissionRequests: new Map(),
1322
mcFlushInterval: undefined,
1323
mcPollInterval: undefined,
1324
mcLastEventId: null,
1325
mcLastSubmitAttemptTimeMs: Date.now(),
1326
mcProcessedCommandIds: new Set<string>(),
1327
mcSdkSession: sdkSession as unknown as Session,
1328
mcEventListenerDispose: undefined,
1329
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1330
};
1331
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1332
Object.defineProperty(session, '_missionControlApiClient', {
1333
value: { submitEvents },
1334
configurable: true,
1335
});
1336
1337
await (session as any)._flushMcEvents();
1338
1339
expect(submitEvents).toHaveBeenCalledWith('mc-session', [], ['mc-command-1']);
1340
expect(remoteState.mcCompletedCommandIds).toEqual([]);
1341
});
1342
1343
it('announces remote control disabled to Mission Control before detaching locally', async () => {
1344
const session = await createSession();
1345
const submitEvents = vi.fn(async () => true);
1346
const deleteSession = vi.fn(async () => undefined);
1347
const pendingRequest = vi.fn();
1348
const mcEventListenerDispose = vi.fn();
1349
const remoteState = {
1350
mcSessionId: 'mc-session',
1351
mcEventBuffer: [],
1352
mcCompletedCommandIds: [],
1353
mcPendingPermissionRequests: new Map([['prompt-1', { resolve: pendingRequest }]]),
1354
mcFlushInterval: undefined,
1355
mcPollInterval: undefined,
1356
mcLastEventId: null,
1357
mcLastSubmitAttemptTimeMs: Date.now(),
1358
mcProcessedCommandIds: new Set<string>(),
1359
mcSdkSession: sdkSession as unknown as Session,
1360
mcEventListenerDispose,
1361
mcSessionResource: Uri.file('/workspace') as unknown as import('vscode').Uri,
1362
};
1363
Object.defineProperty(session, '_mcState', { value: remoteState, configurable: true });
1364
Object.defineProperty(session, '_missionControlApiClient', {
1365
value: { submitEvents, deleteSession },
1366
configurable: true,
1367
});
1368
1369
await (session as any)._teardownRemoteControl();
1370
1371
expect(pendingRequest).toHaveBeenCalledWith({ kind: 'denied-interactively-by-user' });
1372
expect(mcEventListenerDispose).toHaveBeenCalledTimes(1);
1373
expect(submitEvents).toHaveBeenCalledWith(
1374
'mc-session',
1375
expect.arrayContaining([
1376
expect.objectContaining({ type: 'session.remote_steerable_changed', data: { remoteSteerable: false } }),
1377
expect.objectContaining({ type: 'session.idle', data: {} }),
1378
]),
1379
[],
1380
);
1381
expect(deleteSession).not.toHaveBeenCalled();
1382
});
1383
1384
it('immediately pushes invocation messages for non-permission-requiring tools like MCP', async () => {
1385
let resolveSend: () => void;
1386
sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });
1387
const session = await createSession();
1388
const pushedParts: unknown[] = [];
1389
const stream = new MockChatResponseStream(part => pushedParts.push(part));
1390
session.attachStream(stream);
1391
1392
const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Run MCP tool' }, [], undefined, authInfo, CancellationToken.None);
1393
await new Promise(r => setTimeout(r, 0));
1394
1395
// Emit an MCP tool start - this should NOT be delayed
1396
sdkSession.emit('tool.execution_start', { toolName: 'my_mcp_tool', toolCallId: 'mcp-nodelay-1', mcpServerName: 'test-server', mcpToolName: 'my-tool', arguments: { foo: 'bar' } });
1397
await new Promise(r => setTimeout(r, 0));
1398
1399
const toolParts = pushedParts.filter(p => p instanceof ChatToolInvocationPart);
1400
expect(toolParts.length).toBeGreaterThanOrEqual(1);
1401
1402
sdkSession.emit('tool.execution_complete', { toolCallId: 'mcp-nodelay-1', toolName: 'my_mcp_tool', mcpServerName: 'test-server', mcpToolName: 'my-tool', success: true, result: { contents: [] } });
1403
resolveSend!();
1404
await requestPromise;
1405
});
1406
1407
it('flushes delayed invocation messages when assistant message arrives', async () => {
1408
let resolveSend: () => void;
1409
sdkSession.send = async () => new Promise<void>(r => { resolveSend = r; });
1410
const session = await createSession();
1411
const pushedParts: unknown[] = [];
1412
const stream = new MockChatResponseStream(part => pushedParts.push(part));
1413
session.attachStream(stream);
1414
1415
const requestPromise = session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Test flush' }, [], undefined, authInfo, CancellationToken.None);
1416
await new Promise(r => setTimeout(r, 0));
1417
1418
// Emit a bash tool start (delayed)
1419
sdkSession.emit('tool.execution_start', { toolName: 'bash', toolCallId: 'bash-flush-1', arguments: { command: 'ls', description: 'List' } });
1420
await new Promise(r => setTimeout(r, 0));
1421
1422
expect(pushedParts.filter(p => p instanceof ChatToolInvocationPart)).toHaveLength(0);
1423
1424
// Emit an assistant message delta - should flush
1425
sdkSession.emit('assistant.message_delta', { deltaContent: 'Hello', messageId: 'msg-1' });
1426
await new Promise(r => setTimeout(r, 0));
1427
1428
expect(pushedParts.filter(p => p instanceof ChatToolInvocationPart).length).toBeGreaterThanOrEqual(1);
1429
1430
sdkSession.emit('tool.execution_complete', { toolCallId: 'bash-flush-1', toolName: 'bash', success: true, result: { content: '' } });
1431
resolveSend!();
1432
await requestPromise;
1433
});
1434
1435
describe('/compact command', () => {
1436
it('compacts the conversation and reports success', async () => {
1437
const session = await createSession();
1438
const stream = new MockChatResponseStream();
1439
session.attachStream(stream);
1440
1441
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { command: 'compact', prompt: '' }, [], undefined, authInfo, CancellationToken.None);
1442
1443
expect(sdkSession.currentMode).toBe('interactive');
1444
expect(stream.output.join('\n')).toContain('Compacted conversation.');
1445
});
1446
});
1447
1448
describe('steering (sending messages to a busy session)', () => {
1449
it('allows steering after an earlier failed request', async () => {
1450
sdkSession.send = async () => {
1451
throw new Error('boom');
1452
};
1453
1454
const session = await createSession();
1455
const stream = new MockChatResponseStream();
1456
session.attachStream(stream);
1457
1458
await session.handleRequest(
1459
{ id: 'req-1', toolInvocationToken: undefined as never },
1460
{ prompt: 'Initial failure' }, [], undefined, authInfo, CancellationToken.None
1461
);
1462
expect(session.status).toBe(ChatSessionStatus.Failed);
1463
1464
let resolveSecondSend!: () => void;
1465
let sendCallCount = 0;
1466
sdkSession.send = async (options: any) => {
1467
sendCallCount++;
1468
sdkSession.lastSendOptions = options;
1469
if (sendCallCount === 1) {
1470
await new Promise<void>(r => { resolveSecondSend = r; });
1471
}
1472
sdkSession.emit('assistant.turn_start', {});
1473
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1474
sdkSession.emit('assistant.turn_end', {});
1475
};
1476
1477
const secondRequest = session.handleRequest(
1478
{ id: 'req-2', toolInvocationToken: undefined as never },
1479
{ prompt: 'Second request' }, [], undefined, authInfo, CancellationToken.None
1480
);
1481
await new Promise(r => setTimeout(r, 10));
1482
1483
const steeringRequest = session.handleRequest(
1484
{ id: 'req-3', toolInvocationToken: undefined as never },
1485
{ prompt: 'Steer after failure' }, [], undefined, authInfo, CancellationToken.None
1486
);
1487
await new Promise(r => setTimeout(r, 10));
1488
1489
expect(sdkSession.lastSendOptions?.mode).toBe('immediate');
1490
expect(sdkSession.lastSendOptions?.prompt).toBe('Steer after failure');
1491
1492
resolveSecondSend();
1493
await Promise.all([secondRequest, steeringRequest]);
1494
expect(session.status).toBe(ChatSessionStatus.Completed);
1495
});
1496
1497
it('routes through steering when session is already InProgress', async () => {
1498
// Arrange: make `send` block so the first request stays in progress
1499
let resolveFirstSend: () => void = () => { };
1500
let sendCallCount = 0;
1501
sdkSession.send = async (options: any) => {
1502
sendCallCount++;
1503
sdkSession.lastSendOptions = options;
1504
if (sendCallCount === 1) {
1505
// First request blocks until we resolve
1506
await new Promise<void>(r => { resolveFirstSend = r; });
1507
}
1508
sdkSession.emit('assistant.turn_start', {});
1509
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1510
sdkSession.emit('assistant.turn_end', {});
1511
};
1512
1513
const session = await createSession();
1514
const stream = new MockChatResponseStream();
1515
session.attachStream(stream);
1516
1517
// Act: start first request (will block in send)
1518
const firstRequest = session.handleRequest(
1519
{ id: 'req-1', toolInvocationToken: undefined as never },
1520
{ prompt: 'First prompt' }, [], undefined, authInfo, CancellationToken.None
1521
);
1522
await new Promise(r => setTimeout(r, 10));
1523
1524
// Session should be InProgress
1525
expect(session.status).toBe(ChatSessionStatus.InProgress);
1526
1527
// Send a steering request while first is still running
1528
const steeringRequest = session.handleRequest(
1529
{ id: 'req-2', toolInvocationToken: undefined as never },
1530
{ prompt: 'Steer this' }, [], undefined, authInfo, CancellationToken.None
1531
);
1532
await new Promise(r => setTimeout(r, 10));
1533
1534
// The steering send should have been called with mode: 'immediate'
1535
expect(sdkSession.lastSendOptions?.mode).toBe('immediate');
1536
expect(sdkSession.lastSendOptions?.prompt).toBe('Steer this');
1537
1538
// Unblock the first request
1539
resolveFirstSend();
1540
await Promise.all([firstRequest, steeringRequest]);
1541
1542
expect(session.status).toBe(ChatSessionStatus.Completed);
1543
});
1544
1545
it('does not set mode to immediate for the first (non-steering) request', async () => {
1546
const session = await createSession();
1547
const stream = new MockChatResponseStream();
1548
session.attachStream(stream);
1549
1550
await session.handleRequest(
1551
{ id: 'req-1', toolInvocationToken: undefined as never },
1552
{ prompt: 'Normal prompt' }, [], undefined, authInfo, CancellationToken.None
1553
);
1554
1555
expect(sdkSession.lastSendOptions?.mode).toBeUndefined();
1556
expect(sdkSession.lastSendOptions?.prompt).toBe('Normal prompt');
1557
});
1558
1559
it('accumulates attachments across steering requests for permission auto-approval', async () => {
1560
let resolveFirstSend!: () => void;
1561
let sendCallCount = 0;
1562
let permissionResult: unknown;
1563
1564
// The attached file path is outside workspace
1565
const attachedFilePath = '/outside-workspace/steering-file.ts';
1566
1567
sdkSession.send = async (options: any) => {
1568
sendCallCount++;
1569
const thisCallNumber = sendCallCount;
1570
sdkSession.lastSendOptions = options;
1571
if (thisCallNumber === 1) {
1572
await new Promise<void>(r => { resolveFirstSend = r; });
1573
}
1574
sdkSession.emit('assistant.turn_start', {});
1575
// On the first (original) request, try to read the file that was
1576
// attached in the second (steering) request.
1577
if (thisCallNumber === 1) {
1578
permissionResult = await sdkSession.emitPermissionRequest({
1579
kind: 'read', path: attachedFilePath, intention: 'Read file'
1580
});
1581
}
1582
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1583
sdkSession.emit('assistant.turn_end', {});
1584
};
1585
1586
const session = await createSession();
1587
const stream = new MockChatResponseStream();
1588
session.attachStream(stream);
1589
1590
// Start first request with no attachments
1591
const firstRequest = session.handleRequest(
1592
{ id: 'req-1', toolInvocationToken: undefined as never },
1593
{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None
1594
);
1595
await new Promise(r => setTimeout(r, 10));
1596
1597
// Send steering request WITH the file attachment
1598
const steeringAttachments = [{ type: 'file' as const, path: attachedFilePath, displayName: 'steering-file.ts' }];
1599
const steeringRequest = session.handleRequest(
1600
{ id: 'req-2', toolInvocationToken: undefined as never },
1601
{ prompt: 'Use that file' }, steeringAttachments as any, undefined, authInfo, CancellationToken.None
1602
);
1603
await new Promise(r => setTimeout(r, 10));
1604
1605
// Now unblock the first send - it will try to read the steering-attached file
1606
resolveFirstSend();
1607
await Promise.all([firstRequest, steeringRequest]);
1608
1609
// The file was attached in the steering request, so it should be auto-approved
1610
expect(permissionResult).toEqual({ kind: 'approve-once' });
1611
});
1612
1613
it('updates the pending prompt to the latest steering message', async () => {
1614
let resolveFirstSend!: () => void;
1615
let sendCallCount = 0;
1616
sdkSession.send = async (options: any) => {
1617
sendCallCount++;
1618
sdkSession.lastSendOptions = options;
1619
if (sendCallCount === 1) {
1620
await new Promise<void>(r => { resolveFirstSend = r; });
1621
}
1622
sdkSession.emit('assistant.turn_start', {});
1623
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1624
sdkSession.emit('assistant.turn_end', {});
1625
};
1626
1627
const session = await createSession();
1628
const stream = new MockChatResponseStream();
1629
session.attachStream(stream);
1630
1631
// Start first request
1632
const firstRequest = session.handleRequest(
1633
{ id: 'req-1', toolInvocationToken: undefined as never },
1634
{ prompt: 'Original prompt' }, [], undefined, authInfo, CancellationToken.None
1635
);
1636
await new Promise(r => setTimeout(r, 10));
1637
expect(session.pendingPrompt).toBe('Original prompt');
1638
1639
// Steer
1640
const steeringRequest = session.handleRequest(
1641
{ id: 'req-2', toolInvocationToken: undefined as never },
1642
{ prompt: 'New direction' }, [], undefined, authInfo, CancellationToken.None
1643
);
1644
await new Promise(r => setTimeout(r, 10));
1645
expect(session.pendingPrompt).toBe('New direction');
1646
1647
resolveFirstSend();
1648
await Promise.all([firstRequest, steeringRequest]);
1649
});
1650
1651
it('steering request does not change session status to InProgress again', async () => {
1652
let resolveFirstSend!: () => void;
1653
let sendCallCount = 0;
1654
sdkSession.send = async (options: any) => {
1655
sendCallCount++;
1656
sdkSession.lastSendOptions = options;
1657
if (sendCallCount === 1) {
1658
await new Promise<void>(r => { resolveFirstSend = r; });
1659
}
1660
sdkSession.emit('assistant.turn_start', {});
1661
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1662
sdkSession.emit('assistant.turn_end', {});
1663
};
1664
1665
const session = await createSession();
1666
const statuses: (ChatSessionStatus | undefined)[] = [];
1667
disposables.add(session.onDidChangeStatus(s => statuses.push(s)));
1668
const stream = new MockChatResponseStream();
1669
session.attachStream(stream);
1670
1671
// Start first request
1672
const firstRequest = session.handleRequest(
1673
{ id: 'req-1', toolInvocationToken: undefined as never },
1674
{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None
1675
);
1676
await new Promise(r => setTimeout(r, 10));
1677
// Should have fired InProgress once
1678
expect(statuses).toEqual([ChatSessionStatus.InProgress]);
1679
1680
// Send steering request
1681
const steeringRequest = session.handleRequest(
1682
{ id: 'req-2', toolInvocationToken: undefined as never },
1683
{ prompt: 'Steer' }, [], undefined, authInfo, CancellationToken.None
1684
);
1685
await new Promise(r => setTimeout(r, 10));
1686
1687
// InProgress should NOT fire again from the steering path
1688
expect(statuses).toEqual([ChatSessionStatus.InProgress]);
1689
1690
resolveFirstSend();
1691
await Promise.all([firstRequest, steeringRequest]);
1692
1693
// Final status should be Completed
1694
expect(statuses).toEqual([ChatSessionStatus.InProgress, ChatSessionStatus.Completed]);
1695
});
1696
1697
it('throws on disposed session', async () => {
1698
const session = await createSession();
1699
session.dispose();
1700
1701
await expect(
1702
session.handleRequest(
1703
{ id: 'req-1', toolInvocationToken: undefined as never },
1704
{ prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None
1705
)
1706
).rejects.toThrow('Session disposed');
1707
});
1708
1709
it('updates the toolInvocationToken on each request including steering', async () => {
1710
let resolveFirstSend!: () => void;
1711
let sendCallCount = 0;
1712
sdkSession.send = async (options: any) => {
1713
sendCallCount++;
1714
sdkSession.lastSendOptions = options;
1715
if (sendCallCount === 1) {
1716
await new Promise<void>(r => { resolveFirstSend = r; });
1717
}
1718
sdkSession.emit('assistant.turn_start', {});
1719
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1720
sdkSession.emit('assistant.turn_end', {});
1721
};
1722
1723
const session = await createSession();
1724
const stream = new MockChatResponseStream();
1725
session.attachStream(stream);
1726
1727
const token1 = { toString: () => 'token-1' } as unknown as ChatParticipantToolToken;
1728
const token2 = { toString: () => 'token-2' } as unknown as ChatParticipantToolToken;
1729
1730
const firstRequest = session.handleRequest(
1731
{ id: 'req-1', toolInvocationToken: token1 },
1732
{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None
1733
);
1734
await new Promise(r => setTimeout(r, 10));
1735
1736
// Steering replaces the token
1737
const steeringRequest = session.handleRequest(
1738
{ id: 'req-2', toolInvocationToken: token2 },
1739
{ prompt: 'Steer' }, [], undefined, authInfo, CancellationToken.None
1740
);
1741
await new Promise(r => setTimeout(r, 10));
1742
1743
// Can't directly access private _toolInvocationToken, but we verify
1744
// indirectly that the session accepted both tokens without error.
1745
// The key assertion is that handleRequest didn't throw.
1746
resolveFirstSend();
1747
await Promise.all([firstRequest, steeringRequest]);
1748
expect(session.status).toBe(ChatSessionStatus.Completed);
1749
});
1750
1751
it('steering request resolves only after the original request completes', async () => {
1752
let resolveFirstSend!: () => void;
1753
let sendCallCount = 0;
1754
let firstRequestDone = false;
1755
sdkSession.send = async (options: any) => {
1756
sendCallCount++;
1757
sdkSession.lastSendOptions = options;
1758
if (sendCallCount === 1) {
1759
await new Promise<void>(r => { resolveFirstSend = r; });
1760
firstRequestDone = true;
1761
}
1762
sdkSession.emit('assistant.turn_start', {});
1763
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1764
sdkSession.emit('assistant.turn_end', {});
1765
};
1766
1767
const session = await createSession();
1768
const stream = new MockChatResponseStream();
1769
session.attachStream(stream);
1770
1771
const firstRequest = session.handleRequest(
1772
{ id: 'req-1', toolInvocationToken: undefined as never },
1773
{ prompt: 'First' }, [], undefined, authInfo, CancellationToken.None
1774
);
1775
await new Promise(r => setTimeout(r, 10));
1776
1777
let steeringDone = false;
1778
const steeringRequest = session.handleRequest(
1779
{ id: 'req-2', toolInvocationToken: undefined as never },
1780
{ prompt: 'Steer' }, [], undefined, authInfo, CancellationToken.None
1781
).then(() => { steeringDone = true; });
1782
await new Promise(r => setTimeout(r, 10));
1783
1784
// Steering should not have resolved yet because first request is blocked
1785
expect(steeringDone).toBe(false);
1786
expect(firstRequestDone).toBe(false);
1787
1788
// Unblock first request
1789
resolveFirstSend();
1790
await Promise.all([firstRequest, steeringRequest]);
1791
1792
// Both should be done now
1793
expect(steeringDone).toBe(true);
1794
expect(firstRequestDone).toBe(true);
1795
});
1796
});
1797
1798
describe('exit_plan_mode.requested', () => {
1799
it('does not attach the exit_plan_mode.requested handler when plan exit mode is disabled', async () => {
1800
await configurationService.setConfig(ConfigKey.Advanced.CLIPlanExitModeEnabled, false);
1801
sdkSession.send = async (options: any) => {
1802
sdkSession.lastSendOptions = options;
1803
expect(sdkSession.onHandlers.get('exit_plan_mode.requested')?.size ?? 0).toBe(0);
1804
sdkSession.emit('assistant.turn_start', {});
1805
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1806
sdkSession.emit('assistant.turn_end', {});
1807
};
1808
1809
const session = await createSession();
1810
const stream = new MockChatResponseStream();
1811
session.attachStream(stream);
1812
1813
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1814
});
1815
1816
function setupSendWithExitPlanMode(data: { summary: string; actions?: string[] }, resultHolder: { value: unknown }) {
1817
sdkSession.send = async (options: any) => {
1818
sdkSession.emit('assistant.turn_start', {});
1819
sdkSession.emit('assistant.message', { content: `Echo: ${options.prompt}` });
1820
resultHolder.value = await sdkSession.emitExitPlanModeRequest(data);
1821
sdkSession.emit('assistant.turn_end', {});
1822
};
1823
}
1824
1825
it('auto-approves with autopilot action when choices include "autopilot"', async () => {
1826
const result = { value: undefined as unknown };
1827
setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['autopilot', 'interactive', 'exit_only'] }, result);
1828
const session = await createSession();
1829
session.setPermissionLevel('autopilot');
1830
const stream = new MockChatResponseStream();
1831
session.attachStream(stream);
1832
1833
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1834
1835
expect(result.value).toEqual({ approved: true, selectedAction: 'autopilot', autoApproveEdits: true });
1836
});
1837
1838
it('auto-approves with interactive action when choices include "interactive" but not "autopilot"', async () => {
1839
const result = { value: undefined as unknown };
1840
setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['interactive', 'exit_only'] }, result);
1841
const session = await createSession();
1842
session.setPermissionLevel('autopilot');
1843
const stream = new MockChatResponseStream();
1844
session.attachStream(stream);
1845
1846
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1847
1848
expect(result.value).toEqual({ approved: true, selectedAction: 'interactive' });
1849
});
1850
1851
it('auto-approves with exit_only action when choices include "exit_only" but not "autopilot" or "interactive"', async () => {
1852
const result = { value: undefined as unknown };
1853
setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['exit_only'] }, result);
1854
const session = await createSession();
1855
session.setPermissionLevel('autopilot');
1856
const stream = new MockChatResponseStream();
1857
session.attachStream(stream);
1858
1859
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1860
1861
expect(result.value).toEqual({ approved: true, selectedAction: 'exit_only' });
1862
});
1863
1864
it('auto-approves with fallback response when no recognized actions are available', async () => {
1865
const result = { value: undefined as unknown };
1866
setupSendWithExitPlanMode({ summary: 'Plan ready', actions: [] }, result);
1867
const session = await createSession();
1868
session.setPermissionLevel('autopilot');
1869
const stream = new MockChatResponseStream();
1870
session.attachStream(stream);
1871
1872
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1873
1874
expect(result.value).toEqual({ approved: true, autoApproveEdits: true });
1875
});
1876
1877
it('auto-approves with fallback response when actions is undefined', async () => {
1878
const result = { value: undefined as unknown };
1879
setupSendWithExitPlanMode({ summary: 'Plan ready' }, result);
1880
const session = await createSession();
1881
session.setPermissionLevel('autopilot');
1882
const stream = new MockChatResponseStream();
1883
session.attachStream(stream);
1884
1885
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1886
1887
expect(result.value).toEqual({ approved: true, autoApproveEdits: true });
1888
});
1889
1890
it('denies when no toolInvocationToken is present in non-autopilot mode', async () => {
1891
const result = { value: undefined as unknown };
1892
setupSendWithExitPlanMode({ summary: 'Plan ready', actions: ['autopilot'] }, result);
1893
const session = await createSession();
1894
// No autopilot, no token
1895
const stream = new MockChatResponseStream();
1896
session.attachStream(stream);
1897
1898
await session.handleRequest({ id: '', toolInvocationToken: undefined as never }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1899
1900
expect(result.value).toEqual({ approved: false });
1901
});
1902
1903
it('approves when user confirms via user question handler in non-autopilot mode', async () => {
1904
const result = { value: undefined as unknown };
1905
const summary = 'Here is the plan';
1906
setupSendWithExitPlanMode({ summary, actions: ['exit_only'] }, result);
1907
userQuestionAnswer = { selected: ['exit_only'], freeText: null, skipped: false };
1908
const session = await createSession();
1909
const stream = new MockChatResponseStream();
1910
session.attachStream(stream);
1911
const mockToken = {} as ChatParticipantToolToken;
1912
1913
await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1914
1915
expect(result.value).toEqual({ approved: true, selectedAction: 'exit_only' });
1916
});
1917
1918
it('sets autoApproveEdits when user confirms with autoApprove permission level', async () => {
1919
const result = { value: undefined as unknown };
1920
setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);
1921
userQuestionAnswer = { selected: ['exit_only'], freeText: null, skipped: false };
1922
const session = await createSession();
1923
session.setPermissionLevel('autoApprove');
1924
const stream = new MockChatResponseStream();
1925
session.attachStream(stream);
1926
const mockToken = {} as ChatParticipantToolToken;
1927
1928
await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1929
1930
expect(result.value).toEqual({ approved: true, selectedAction: 'exit_only', autoApproveEdits: true });
1931
});
1932
1933
it('does not set autoApproveEdits when user rejects with autoApprove permission level', async () => {
1934
const result = { value: undefined as unknown };
1935
setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);
1936
toolsService.setConfirmationResult('no');
1937
const session = await createSession();
1938
session.setPermissionLevel('autoApprove');
1939
const stream = new MockChatResponseStream();
1940
session.attachStream(stream);
1941
const mockToken = {} as ChatParticipantToolToken;
1942
1943
await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1944
1945
expect(result.value).toEqual({ approved: false });
1946
});
1947
1948
it('denies when user rejects via confirmation tool in non-autopilot mode', async () => {
1949
const result = { value: undefined as unknown };
1950
setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);
1951
toolsService.setConfirmationResult('no');
1952
const session = await createSession();
1953
const stream = new MockChatResponseStream();
1954
session.attachStream(stream);
1955
const mockToken = {} as ChatParticipantToolToken;
1956
1957
await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1958
1959
expect(result.value).toEqual({ approved: false });
1960
});
1961
1962
it('denies when confirmation tool throws in non-autopilot mode', async () => {
1963
const result = { value: undefined as unknown };
1964
setupSendWithExitPlanMode({ summary: 'Here is the plan', actions: ['exit_only'] }, result);
1965
toolsService.invokeTool = vi.fn(async () => { throw new Error('tool error'); });
1966
const session = await createSession();
1967
const stream = new MockChatResponseStream();
1968
session.attachStream(stream);
1969
const mockToken = {} as ChatParticipantToolToken;
1970
1971
await session.handleRequest({ id: '', toolInvocationToken: mockToken }, { prompt: 'Plan' }, [], undefined, authInfo, CancellationToken.None);
1972
1973
expect(result.value).toEqual({ approved: false });
1974
});
1975
});
1976
1977
describe('usage reporting', () => {
1978
it('reports usage from assistant.usage event with per-call tokens', async () => {
1979
sdkSession.send = async (options: any) => {
1980
sdkSession.emit('user.message', { content: options.prompt });
1981
sdkSession.emit('assistant.usage', { inputTokens: 200, outputTokens: 80 });
1982
sdkSession.emit('assistant.turn_end', {});
1983
};
1984
1985
const session = await createSession();
1986
const stream = new UsageCapturingStream();
1987
session.attachStream(stream);
1988
1989
await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
1990
1991
const usageFromEvent = stream.usages.find(u => u.promptTokens === 200 && u.completionTokens === 80);
1992
expect(usageFromEvent).toBeDefined();
1993
});
1994
1995
it('reports usage from session.usage_info event immediately', async () => {
1996
sdkSession.send = async (options: any) => {
1997
sdkSession.emit('user.message', { content: options.prompt });
1998
sdkSession.emit('session.usage_info', {
1999
currentTokens: 500,
2000
tokenLimit: 8000,
2001
messagesLength: 5,
2002
systemTokens: 100,
2003
conversationTokens: 350,
2004
toolDefinitionsTokens: 50,
2005
});
2006
sdkSession.emit('assistant.turn_end', {});
2007
};
2008
2009
const session = await createSession();
2010
const stream = new UsageCapturingStream();
2011
session.attachStream(stream);
2012
2013
await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
2014
2015
const usageFromInfo = stream.usages.find(u => u.promptTokens === 500);
2016
expect(usageFromInfo).toBeDefined();
2017
expect(usageFromInfo!.completionTokens).toBe(0);
2018
});
2019
2020
it('includes promptTokenDetails breakdown in usage from session.usage_info', async () => {
2021
sdkSession.send = async (options: any) => {
2022
sdkSession.emit('user.message', { content: options.prompt });
2023
sdkSession.emit('session.usage_info', {
2024
currentTokens: 500,
2025
tokenLimit: 8000,
2026
messagesLength: 5,
2027
systemTokens: 100,
2028
conversationTokens: 350,
2029
toolDefinitionsTokens: 50,
2030
});
2031
sdkSession.emit('assistant.turn_end', {});
2032
};
2033
2034
const session = await createSession();
2035
const stream = new UsageCapturingStream();
2036
session.attachStream(stream);
2037
2038
await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
2039
2040
const usageFromInfo = stream.usages.find(u => u.promptTokens === 500);
2041
expect(usageFromInfo?.promptTokenDetails).toBeDefined();
2042
expect(usageFromInfo!.promptTokenDetails).toEqual([
2043
{ category: 'System', label: 'System Instructions', percentageOfPrompt: 20 },
2044
{ category: 'System', label: 'Tool Definitions', percentageOfPrompt: 10 },
2045
{ category: 'User Context', label: 'Messages', percentageOfPrompt: 70 },
2046
]);
2047
});
2048
2049
it('populates promptTokenDetails in assistant.usage event when usage_info was previously received', async () => {
2050
sdkSession.send = async (options: any) => {
2051
sdkSession.emit('user.message', { content: options.prompt });
2052
sdkSession.emit('session.usage_info', {
2053
currentTokens: 400,
2054
tokenLimit: 8000,
2055
messagesLength: 4,
2056
systemTokens: 80,
2057
conversationTokens: 280,
2058
toolDefinitionsTokens: 40,
2059
});
2060
sdkSession.emit('assistant.usage', { inputTokens: 400, outputTokens: 60 });
2061
sdkSession.emit('assistant.turn_end', {});
2062
};
2063
2064
const session = await createSession();
2065
const stream = new UsageCapturingStream();
2066
session.attachStream(stream);
2067
2068
await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
2069
2070
const assistantUsage = stream.usages.find(u => u.promptTokens === 400 && u.completionTokens === 60);
2071
expect(assistantUsage).toBeDefined();
2072
expect(assistantUsage!.promptTokenDetails).toBeDefined();
2073
expect(assistantUsage!.promptTokenDetails!.length).toBeGreaterThan(0);
2074
});
2075
2076
it('reports final usage from getMetrics() after session completes', async () => {
2077
sdkSession.usage.getMetrics = async () => ({
2078
lastCallInputTokens: 350,
2079
lastCallOutputTokens: 90,
2080
totalPremiumRequestCost: 0,
2081
totalUserRequests: 1,
2082
totalApiDurationMs: 500,
2083
sessionStartTime: Date.now(),
2084
codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0 },
2085
modelMetrics: {},
2086
currentModel: 'modelA',
2087
});
2088
2089
const session = await createSession();
2090
const stream = new UsageCapturingStream();
2091
session.attachStream(stream);
2092
2093
await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
2094
2095
const finalUsage = stream.usages.at(-1);
2096
expect(finalUsage).toBeDefined();
2097
expect(finalUsage!.completionTokens).toBe(90);
2098
});
2099
2100
it('uses currentTokens from session.usage_info as promptTokens in final usage report (non-zero after compaction)', async () => {
2101
sdkSession.send = async (options: any) => {
2102
sdkSession.emit('user.message', { content: options.prompt });
2103
// Simulate post-compaction: usage_info fires with reduced token count, no assistant.usage follows
2104
sdkSession.emit('session.usage_info', {
2105
currentTokens: 120,
2106
tokenLimit: 8000,
2107
messagesLength: 2,
2108
systemTokens: 80,
2109
conversationTokens: 40,
2110
toolDefinitionsTokens: 0,
2111
});
2112
sdkSession.emit('assistant.turn_end', {});
2113
};
2114
sdkSession.usage.getMetrics = async () => ({
2115
lastCallInputTokens: 0, // stale / no new call made
2116
lastCallOutputTokens: 0,
2117
totalPremiumRequestCost: 0,
2118
totalUserRequests: 1,
2119
totalApiDurationMs: 0,
2120
sessionStartTime: Date.now(),
2121
codeChanges: { linesAdded: 0, linesRemoved: 0, filesModifiedCount: 0 },
2122
modelMetrics: {},
2123
currentModel: 'modelA',
2124
});
2125
2126
const session = await createSession();
2127
const stream = new UsageCapturingStream();
2128
session.attachStream(stream);
2129
2130
await session.handleRequest({ id: 'req-1', toolInvocationToken: undefined as never }, { prompt: 'Hello' }, [], undefined, authInfo, CancellationToken.None);
2131
2132
// Final usage should use currentTokens (120) not the stale lastCallInputTokens (0)
2133
const finalUsage = stream.usages.at(-1);
2134
expect(finalUsage!.promptTokens).toBe(120);
2135
});
2136
});
2137
});
2138
2139