Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts
13405 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 { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
7
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
8
import * as vscode from 'vscode';
9
import { Uri } from 'vscode';
10
import { NullChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
11
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
12
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
13
import { NullNativeEnvService } from '../../../../platform/env/common/nullEnvService';
14
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
15
import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';
16
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
17
import { IOctoKitService } from '../../../../platform/github/common/githubService';
18
import { ILogService } from '../../../../platform/log/common/logService';
19
import { NoopOTelService, resolveOTelConfig } from '../../../../platform/otel/common/index';
20
import { NullRequestLogger } from '../../../../platform/requestLogger/node/nullRequestLogger';
21
import { NullTelemetryService } from '../../../../platform/telemetry/common/nullTelemetryService';
22
import type { ITelemetryService } from '../../../../platform/telemetry/common/telemetry';
23
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
24
import { IWorkspaceService, NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
25
import { mock } from '../../../../util/common/test/simpleMock';
26
import { CancellationTokenSource } from '../../../../util/vs/base/common/cancellation';
27
import { Event } from '../../../../util/vs/base/common/event';
28
import { Disposable, DisposableStore } from '../../../../util/vs/base/common/lifecycle';
29
import { sep } from '../../../../util/vs/base/common/path';
30
import { URI } from '../../../../util/vs/base/common/uri';
31
import { IInstantiationService, ServicesAccessor } from '../../../../util/vs/platform/instantiation/common/instantiation';
32
import { LanguageModelTextPart, LanguageModelToolResult2 } from '../../../../vscodeTypes';
33
import { NullPromptVariablesService } from '../../../prompt/node/promptVariablesService';
34
import { ChatSummarizerProvider } from '../../../prompt/node/summarizer';
35
import { createExtensionUnitTestingServices } from '../../../test/node/services';
36
import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';
37
import { type IToolsService } from '../../../tools/common/toolsService';
38
import { mockLanguageModelChat } from '../../../tools/node/test/searchToolTestUtils';
39
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
40
import { RepositoryProperties } from '../../common/chatSessionMetadataStore';
41
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
42
import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService';
43
import { IChatSessionWorktreeService, type ChatSessionWorktreeFile, type ChatSessionWorktreeProperties, type ChatSessionWorktreePropertiesV2 } from '../../common/chatSessionWorktreeService';
44
import { IChatFolderMruService } from '../../common/folderRepositoryManager';
45
import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore';
46
import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo';
47
import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService';
48
import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli';
49
import { CopilotCLIPromptResolver } from '../../copilotcli/node/copilotcliPromptResolver';
50
import { CopilotCLISession, CopilotCLISessionInput, ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';
51
import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
52
import { ICopilotCLIMCPHandler } from '../../copilotcli/node/mcpHandler';
53
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from '../../copilotcli/node/test/testHelpers';
54
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../copilotcli/node/userInputHelpers';
55
import { CustomSessionTitleService } from '../../copilotcli/vscode-node/customSessionTitleServiceImpl';
56
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant } from '../copilotCLIChatSessionsContribution';
57
import { CopilotCloudSessionsProvider } from '../copilotCloudSessionsProvider';
58
import { CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';
59
import { MockPromptsService } from '../../../../platform/promptFiles/test/common/mockPromptsService';
60
61
// Mock terminal integration to avoid importing PowerShell asset (.ps1) which Vite cannot parse during tests
62
vi.mock('../copilotCLITerminalIntegration', () => {
63
// Minimal stand-in for createServiceIdentifier
64
const createServiceIdentifier = (name: string) => {
65
const fn: any = () => { /* decorator no-op */ };
66
fn.toString = () => name;
67
return fn;
68
};
69
class CopilotCLITerminalIntegration {
70
dispose() { }
71
openTerminal = vi.fn(async () => { });
72
}
73
return {
74
ICopilotCLITerminalIntegration: createServiceIdentifier('ICopilotCLITerminalIntegration'),
75
CopilotCLITerminalIntegration
76
};
77
});
78
79
// Mock vscode.commands.executeCommand so we can control delegation behavior in tests.
80
// By default it throws (simulating commands API not being available), which causes
81
// createCLISessionAndSubmitRequest to fall into its catch block and call handleRequest directly.
82
// The workaround tests override this to simulate the full VS Code core round-trip.
83
const { mockExecuteCommand } = vi.hoisted(() => ({
84
mockExecuteCommand: vi.fn()
85
}));
86
87
vi.mock('vscode', async (importOriginal) => {
88
const actual = await import('../../../../vscodeTypes');
89
return {
90
...actual,
91
env: {
92
appName: 'VS Code'
93
},
94
version: 'test-vscode-version',
95
extensions: {
96
getExtension: vi.fn(() => ({ packageJSON: { version: 'test-version' } }))
97
},
98
commands: {
99
executeCommand: mockExecuteCommand
100
},
101
workspace: {
102
isAgentSessionsWorkspace: false
103
}
104
};
105
});
106
107
class FakeToolsService extends mock<IToolsService>() {
108
nextConfirmationButton: string | undefined = undefined;
109
override getTool(name: string) {
110
if (name === 'vscode_get_modified_files_confirmation') {
111
return { name } as any;
112
}
113
return undefined;
114
}
115
override invokeTool = vi.fn(async (name: string, _options: unknown, _token: unknown) => {
116
if (name === 'vscode_get_modified_files_confirmation') {
117
const button = this.nextConfirmationButton;
118
if (button !== undefined) {
119
return new LanguageModelToolResult2([new LanguageModelTextPart(button)]);
120
}
121
return new LanguageModelToolResult2([]);
122
}
123
return new LanguageModelToolResult2([]);
124
});
125
}
126
127
class FakeChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
128
private _sessionWorkspaceFolders = new Map<string, vscode.Uri>();
129
private _sessionWorkspaceFolderRepositories = new Map<string, vscode.Uri | undefined>();
130
private _workspaceChanges = new Map<string, readonly ChatSessionWorktreeFile[] | undefined>();
131
override trackSessionWorkspaceFolder = vi.fn(async (sessionId: string, workspaceFolderUri: string, repositoryProperties?: RepositoryProperties) => {
132
this._sessionWorkspaceFolders.set(sessionId, vscode.Uri.file(workspaceFolderUri));
133
this._sessionWorkspaceFolderRepositories.set(sessionId, repositoryProperties?.repositoryPath ? vscode.Uri.file(repositoryProperties.repositoryPath) : undefined);
134
});
135
override deleteTrackedWorkspaceFolder = vi.fn(async (sessionId: string) => {
136
this._sessionWorkspaceFolders.delete(sessionId);
137
this._sessionWorkspaceFolderRepositories.delete(sessionId);
138
});
139
override getSessionWorkspaceFolder = vi.fn(async (sessionId: string): Promise<vscode.Uri | undefined> => {
140
return this._sessionWorkspaceFolders.get(sessionId);
141
});
142
override getSessionWorkspaceFolderEntry = vi.fn(async (sessionId: string) => {
143
const folder = this._sessionWorkspaceFolders.get(sessionId);
144
if (!folder) {
145
return undefined;
146
}
147
148
return {
149
folderPath: folder.fsPath,
150
timestamp: Date.now()
151
};
152
});
153
override getRepositoryProperties = vi.fn(async (_sessionId: string): Promise<RepositoryProperties | undefined> => {
154
return undefined;
155
});
156
override handleRequestCompleted = vi.fn(async (_sessionId: string): Promise<void> => { });
157
override getWorkspaceChanges = vi.fn(async (sessionId: string): Promise<readonly ChatSessionWorktreeFile[] | undefined> => {
158
return this._workspaceChanges.get(sessionId);
159
});
160
override clearWorkspaceChanges(sessionIdOrFolderUri: string | vscode.Uri): string[] {
161
if (typeof sessionIdOrFolderUri === 'string') {
162
this._workspaceChanges.delete(sessionIdOrFolderUri);
163
}
164
return [];
165
}
166
}
167
168
class FakeChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {
169
constructor() {
170
super();
171
}
172
override createWorktree = vi.fn(async () => undefined) as unknown as IChatSessionWorktreeService['createWorktree'];
173
override getWorktreeProperties: any = vi.fn(async (_id: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
174
override setWorktreeProperties = vi.fn(async () => { });
175
override getWorktreePath: any = vi.fn(async (_id: string): Promise<vscode.Uri | undefined> => undefined);
176
override handleRequestCompleted = vi.fn(async () => { });
177
override getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined> {
178
return Promise.resolve(undefined);
179
}
180
}
181
182
class FakeChatSessionWorktreeCheckpointService extends mock<IChatSessionWorktreeCheckpointService>() {
183
constructor() {
184
super();
185
}
186
override handleRequest = vi.fn(async () => { });
187
override handleRequestCompleted = vi.fn(async () => { });
188
}
189
190
191
class FakeModels {
192
_serviceBrand: undefined;
193
resolveModel = vi.fn(async (modelId: string) => modelId);
194
getDefaultModel = vi.fn(async () => 'base');
195
getModels = vi.fn(async () => [{ id: 'base', name: 'Base', maxContextWindowTokens: 128000, supportsVision: false }] as CopilotCLIModelInfo[]);
196
setDefaultModel = vi.fn(async () => { });
197
registerLanguageModelChatProvider = vi.fn();
198
toModelProvider = vi.fn((id: string) => id); // passthrough
199
}
200
201
class FakeGitService extends mock<IGitService>() {
202
override activeRepository = { get: () => undefined } as unknown as IGitService['activeRepository'];
203
override onDidFinishInitialization = Event.None;
204
override onDidOpenRepository = Event.None;
205
override repositories: RepoContext[] = [];
206
private _recentRepositories: { rootUri: vscode.Uri; lastAccessTime: number }[] = [];
207
setRepo(repos: RepoContext) {
208
this.repositories = [repos];
209
}
210
override async getRepository(uri: URI, forceOpen?: boolean): Promise<RepoContext | undefined> {
211
if (this.repositories.length === 1) {
212
return Promise.resolve(this.repositories[0]);
213
}
214
return undefined;
215
}
216
override getRecentRepositories = vi.fn((): { rootUri: vscode.Uri; lastAccessTime: number }[] => {
217
return this._recentRepositories;
218
});
219
setTestRecentRepositories(repos: { rootUri: vscode.Uri; lastAccessTime: number }[]): void {
220
this._recentRepositories = repos;
221
}
222
}
223
224
// Cloud provider fake for delegate scenario
225
class FakeCloudProvider extends mock<CopilotCloudSessionsProvider>() {
226
override delegate = vi.fn(async () => ({
227
uri: vscode.Uri.parse('pr://1'),
228
title: 'PR Title',
229
description: 'PR Description',
230
author: 'Test Author',
231
linkTag: '#1'
232
})) as unknown as CopilotCloudSessionsProvider['delegate'];
233
}
234
235
236
function createChatContext(sessionId: string, isUntitled: boolean, ...requests: TestChatRequest[]): vscode.ChatContext {
237
const resource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}` });
238
for (const request of requests) {
239
request.sessionResource = resource;
240
}
241
return {
242
history: [],
243
yieldRequested: false,
244
chatSessionContext: {
245
chatSessionItem: { resource, label: 'temp' } as vscode.ChatSessionItem,
246
isUntitled
247
} as vscode.ChatSessionContext,
248
} as vscode.ChatContext;
249
}
250
251
class TestCopilotCLISession extends CopilotCLISession {
252
public requests: Array<{ input: CopilotCLISessionInput; attachments: Attachment[]; model: { model: string; reasoningEffort?: string } | undefined; authInfo: NonNullable<SessionOptions['authInfo']>; token: vscode.CancellationToken }> = [];
253
public permissionLevel: string | undefined;
254
public static nextHandleRequestResult: Promise<void> | undefined;
255
public static handleRequestHook: ((request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput) => Promise<void>) | undefined;
256
public static statusOverride?: vscode.ChatSessionStatus;
257
override get status(): vscode.ChatSessionStatus | undefined {
258
return TestCopilotCLISession.statusOverride;
259
}
260
override handleRequest(request: { id: string; toolInvocationToken: vscode.ChatParticipantToolToken; sessionResource?: vscode.Uri }, input: CopilotCLISessionInput, attachments: Attachment[], model: { model: string; reasoningEffort?: string } | undefined, authInfo: NonNullable<SessionOptions['authInfo']>, token: vscode.CancellationToken): Promise<void> {
261
this.requests.push({ input, attachments, model, authInfo, token });
262
if (TestCopilotCLISession.handleRequestHook) {
263
return TestCopilotCLISession.handleRequestHook(request, input);
264
}
265
return TestCopilotCLISession.nextHandleRequestResult ?? Promise.resolve();
266
}
267
override setPermissionLevel(level: string | undefined): void {
268
this.permissionLevel = level;
269
super.setPermissionLevel(level);
270
}
271
}
272
273
274
class FakeCopilotCLISessionService extends mock<ICopilotCLISessionService>() {
275
private _sessionWorkingDirs = new Map<string, vscode.Uri>();
276
override tryGetPartialSessionHistory: ICopilotCLISessionService['tryGetPartialSessionHistory'] = vi.fn(async () => undefined);
277
278
override getSessionWorkingDirectory = vi.fn((sessionId: string): vscode.Uri | undefined => {
279
return this._sessionWorkingDirs.get(sessionId);
280
});
281
282
setTestSessionWorkingDirectory(sessionId: string, uri: vscode.Uri): void {
283
this._sessionWorkingDirs.set(sessionId, uri);
284
}
285
}
286
287
describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
288
const disposables = new DisposableStore();
289
let promptResolver: CopilotCLIPromptResolver;
290
let itemProvider: CopilotCLIChatSessionItemProvider;
291
let cloudProvider: FakeCloudProvider;
292
let summarizer: ChatSummarizerProvider;
293
let worktree: FakeChatSessionWorktreeService;
294
let worktreeCheckpointService: FakeChatSessionWorktreeCheckpointService;
295
let workspaceFolderService: FakeChatSessionWorkspaceFolderService;
296
let git: FakeGitService;
297
let models: FakeModels;
298
let sessionService: CopilotCLISessionService;
299
let telemetry: ITelemetryService;
300
let tools: FakeToolsService;
301
let participant: CopilotCLIChatSessionParticipant;
302
let workspaceService: IWorkspaceService;
303
let instantiationService: IInstantiationService;
304
let logService: ILogService;
305
let configurationService: InMemoryConfigurationService;
306
let manager: MockCliSdkSessionManager;
307
let mcpHandler: ICopilotCLIMCPHandler;
308
let folderRepositoryManager: CopilotCLIFolderRepositoryManager;
309
let cliSessionServiceForFolderManager: FakeCopilotCLISessionService;
310
let contentProvider: CopilotCLIChatSessionContentProvider;
311
let sdk: ICopilotCLISDK;
312
let customSessionTitleService: CustomSessionTitleService;
313
const cliSessions: TestCopilotCLISession[] = [];
314
315
beforeEach(async () => {
316
cliSessions.length = 0;
317
TestCopilotCLISession.nextHandleRequestResult = undefined;
318
TestCopilotCLISession.handleRequestHook = undefined;
319
TestCopilotCLISession.statusOverride = undefined;
320
// By default, simulate VS Code core opening the delegated session and
321
// re-invoking handleRequest with the copilotcli:// resource. This matches
322
// the production flow where executeCommand opens the session.
323
// The chatSessionContext lost workaround tests override this.
324
mockExecuteCommand.mockImplementation(async (command: string, args: any) => {
325
if (command === 'workbench.action.chat.openSessionWithPrompt.copilotcli') {
326
const callbackRequest = new TestChatRequest(args.prompt);
327
callbackRequest.sessionResource = args.resource;
328
const callbackContext = createChatContext(args.resource.path.slice(1), false, callbackRequest);
329
const callbackStream = new MockChatResponseStream();
330
const callbackToken = disposables.add(new CancellationTokenSource()).token;
331
await participant.createHandler()(callbackRequest, callbackContext, callbackStream, callbackToken);
332
}
333
});
334
sdk = {
335
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
336
getAuthInfo: vi.fn(async () => ({ type: 'token' as const, token: 'valid-token', host: 'https://github.com' })),
337
} as unknown as ICopilotCLISDK;
338
const services = disposables.add(createExtensionUnitTestingServices());
339
const accessor = services.createTestingAccessor();
340
disposables.add(accessor);
341
promptResolver = new class extends mock<CopilotCLIPromptResolver>() {
342
override resolvePrompt = vi.fn(async (request: vscode.ChatRequest, prompt: string | undefined, _additionalReferences: vscode.ChatPromptReference[], _workspaceInfo: IWorkspaceInfo, _additionalWorkspaces: IWorkspaceInfo[], _token: vscode.CancellationToken) => {
343
return { prompt: prompt ?? request.prompt, attachments: [], references: [] };
344
});
345
}();
346
itemProvider = new class extends mock<CopilotCLIChatSessionItemProvider>() {
347
override swap = vi.fn();
348
override notifySessionsChange = vi.fn();
349
override untitledSessionIdMapping = new Map<string, string>();
350
override sdkToUntitledUriMapping = new Map<string, Uri>();
351
override isNewSession = vi.fn((_session: string) => false);
352
override detectPullRequestOnSessionOpen = vi.fn(async () => { });
353
}();
354
cloudProvider = new FakeCloudProvider();
355
summarizer = new class extends mock<ChatSummarizerProvider>() {
356
override provideChatSummary(_context: vscode.ChatContext) { return Promise.resolve('summary text'); }
357
}();
358
worktree = new FakeChatSessionWorktreeService();
359
worktreeCheckpointService = new FakeChatSessionWorktreeCheckpointService();
360
workspaceFolderService = new FakeChatSessionWorkspaceFolderService();
361
git = new FakeGitService();
362
models = new FakeModels();
363
cliSessionServiceForFolderManager = new FakeCopilotCLISessionService();
364
telemetry = new NullTelemetryService();
365
tools = new FakeToolsService();
366
workspaceService = new NullWorkspaceService([URI.file('/workspace')]);
367
const logger = accessor.get(ILogService);
368
logService = accessor.get(ILogService);
369
mcpHandler = new class extends mock<ICopilotCLIMCPHandler>() {
370
override loadMcpConfig = vi.fn(async () => {
371
return { mcpConfig: undefined, disposable: Disposable.None };
372
});
373
}();
374
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
375
override async summarize(context: vscode.ChatContext, token: vscode.CancellationToken): Promise<string | undefined> {
376
return undefined;
377
}
378
}();
379
const fileSystem = new MockFileSystemService();
380
class FakeUserQuestionHandler implements IUserQuestionHandler {
381
_serviceBrand: undefined;
382
async askUserQuestion(question: IQuestion, toolInvocationToken: vscode.ChatParticipantToolToken, token: vscode.CancellationToken): Promise<IQuestionAnswer | undefined> {
383
return undefined;
384
}
385
}
386
387
instantiationService = {
388
invokeFunction<R, TS extends any[] = []>(fn: (accessor: ServicesAccessor, ...args: TS) => R, ...args: TS): R {
389
return fn(accessor, ...args);
390
},
391
createInstance: (ctor: unknown, workspaceInfo: any, agentName: any, sdkSession: any) => {
392
if (ctor === CopilotCLISessionWorkspaceTracker) {
393
return new class extends mock<CopilotCLISessionWorkspaceTracker>() {
394
override async initialize(): Promise<void> { return; }
395
override shouldShowSession(_sessionId: string): { isOldGlobalSession?: boolean; isWorkspaceSession?: boolean } {
396
return { isOldGlobalSession: false, isWorkspaceSession: true };
397
}
398
}();
399
}
400
const session = new TestCopilotCLISession(workspaceInfo, agentName, sdkSession, [], logService, workspaceService, new MockChatSessionMetadataStore(), instantiationService, new NullRequestLogger(), new NullICopilotCLIImageSupport(), new FakeToolsService(), new FakeUserQuestionHandler(), accessor.get(IConfigurationService), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new FakeGitService(), { _serviceBrand: undefined } as any);
401
cliSessions.push(session);
402
return disposables.add(session);
403
}
404
} as unknown as IInstantiationService;
405
customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext, accessor.get(IInstantiationService), logService, new MockChatSessionMetadataStore());
406
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree, new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), models as unknown as ICopilotCLIModels));
407
408
manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager;
409
contentProvider = new class extends mock<CopilotCLIChatSessionContentProvider>() {
410
override notifySessionOptionsChange = vi.fn((_resource: vscode.Uri, _updates: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }>): void => {
411
// tracked by vi.fn
412
});
413
override trackActiveSession = vi.fn();
414
override untrackActiveSession = vi.fn();
415
}();
416
folderRepositoryManager = new CopilotCLIFolderRepositoryManager(
417
worktree,
418
workspaceFolderService,
419
cliSessionServiceForFolderManager as unknown as ICopilotCLISessionService,
420
git,
421
workspaceService,
422
logService,
423
tools,
424
fileSystem,
425
new MockChatSessionMetadataStore()
426
);
427
428
instantiationService = accessor.get(IInstantiationService);
429
configurationService = accessor.get(IConfigurationService) as InMemoryConfigurationService;
430
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
431
432
participant = new CopilotCLIChatSessionParticipant(
433
contentProvider,
434
promptResolver,
435
itemProvider,
436
cloudProvider,
437
undefined,
438
git,
439
models as unknown as ICopilotCLIModels,
440
new NullCopilotCLIAgents(),
441
sessionService,
442
worktree,
443
worktreeCheckpointService,
444
workspaceFolderService,
445
telemetry,
446
logger,
447
disposables.add(new MockPromptsService()),
448
delegationService,
449
folderRepositoryManager,
450
configurationService,
451
sdk,
452
new MockChatSessionMetadataStore(),
453
customSessionTitleService,
454
new (mock<IOctoKitService>())(),
455
);
456
});
457
458
afterEach(() => {
459
vi.restoreAllMocks();
460
disposables.clear();
461
});
462
463
it('creates new session for untitled context and invokes request', async () => {
464
const request = new TestChatRequest('Say hi');
465
const context = createChatContext('temp-new', true, request);
466
const stream = new MockChatResponseStream();
467
const token = disposables.add(new CancellationTokenSource()).token;
468
const authInfo = await sdk.getAuthInfo();
469
expect(cliSessions.length).toBe(0);
470
471
await participant.createHandler()(request, context, stream, token);
472
473
expect(cliSessions.length).toBe(1);
474
expect(cliSessions[0].requests.length).toBe(1);
475
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Say hi' }, attachments: [], model: { model: 'base' }, authInfo, token });
476
});
477
478
it('uses permissionLevel from initial session options', async () => {
479
const request = new TestChatRequest('Say hi');
480
const context = createChatContext('temp-new', true, request);
481
(context.chatSessionContext as { initialSessionOptions?: ReadonlyArray<{ optionId: string; value: string }> }).initialSessionOptions = [{ optionId: 'permissionLevel', value: 'autopilot' }];
482
const stream = new MockChatResponseStream();
483
const token = disposables.add(new CancellationTokenSource()).token;
484
485
await participant.createHandler()(request, context, stream, token);
486
487
expect(cliSessions.length).toBe(1);
488
expect(cliSessions[0].permissionLevel).toBe('autopilot');
489
});
490
491
it('applies live permissionLevel option changes to an active session', async () => {
492
const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
493
(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
494
(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();
495
const activeSession = {
496
sessionId: 'sdk-session',
497
setPermissionLevel: vi.fn(),
498
} as unknown as ICopilotCLISession;
499
itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);
500
provider.trackActiveSession('untitled-session', activeSession);
501
502
await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [
503
{ optionId: 'permissionLevel', value: 'autopilot' }
504
], disposables.add(new CancellationTokenSource()).token);
505
506
expect(activeSession.setPermissionLevel).toHaveBeenCalledWith('autopilot');
507
});
508
509
it('scopes live permissionLevel changes to the targeted session', async () => {
510
const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
511
(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
512
(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();
513
const sessionA = { sessionId: 'sdk-a', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
514
const sessionB = { sessionId: 'sdk-b', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
515
itemProvider.untitledSessionIdMapping.set('resource-a', sessionA.sessionId);
516
itemProvider.untitledSessionIdMapping.set('resource-b', sessionB.sessionId);
517
provider.trackActiveSession('resource-a', sessionA);
518
provider.trackActiveSession('resource-b', sessionB);
519
520
await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/resource-b'), [
521
{ optionId: 'permissionLevel', value: 'autopilot' }
522
], disposables.add(new CancellationTokenSource()).token);
523
524
expect(sessionB.setPermissionLevel).toHaveBeenCalledWith('autopilot');
525
expect(sessionA.setPermissionLevel).not.toHaveBeenCalled();
526
});
527
528
it('clears permissionLevel on an active session when option value is undefined', async () => {
529
const provider = Object.create(CopilotCLIChatSessionContentProvider.prototype) as CopilotCLIChatSessionContentProvider;
530
(provider as unknown as { sessionItemProvider: CopilotCLIChatSessionItemProvider }).sessionItemProvider = itemProvider;
531
(provider as unknown as { _activeSessionsById: Map<string, ICopilotCLISession> })._activeSessionsById = new Map<string, ICopilotCLISession>();
532
const activeSession = { sessionId: 'sdk-session', setPermissionLevel: vi.fn() } as unknown as ICopilotCLISession;
533
itemProvider.untitledSessionIdMapping.set('untitled-session', activeSession.sessionId);
534
provider.trackActiveSession('untitled-session', activeSession);
535
536
await provider.provideHandleOptionsChange(Uri.parse('copilotcli:/untitled-session'), [
537
{ optionId: 'permissionLevel', value: undefined }
538
], disposables.add(new CancellationTokenSource()).token);
539
540
expect(activeSession.setPermissionLevel).toHaveBeenCalledWith(undefined);
541
});
542
543
it('uses worktree workingDirectory when isolation is enabled for a new untitled session', async () => {
544
const worktreeProperties = {
545
autoCommit: true,
546
baseCommit: 'deadbeef',
547
branchName: 'test',
548
repositoryPath: `${sep}repo`,
549
worktreePath: `${sep}worktree`,
550
version: 1
551
} satisfies ChatSessionWorktreeProperties;
552
// Set up untitled session folder
553
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));
554
// Configure git to return repository for the folder
555
git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], kind: 'repository' } as unknown as RepoContext);
556
// Configure worktree service to return worktree properties when createWorktree is called
557
(worktree.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(worktreeProperties);
558
559
const request = new TestChatRequest('Say hi');
560
const context = createChatContext('untitled:temp-new', true, request);
561
const stream = new MockChatResponseStream();
562
const token = disposables.add(new CancellationTokenSource()).token;
563
564
await participant.createHandler()(request, context, stream, token);
565
566
expect(cliSessions.length).toBe(1);
567
expect(cliSessions[0].workspace.worktreeProperties).toBeDefined();
568
expect(getWorkingDirectory(cliSessions[0].workspace)?.fsPath).toBe(`${sep}worktree`);
569
expect(mcpHandler.loadMcpConfig).toHaveBeenCalled();
570
// Prompt resolver should receive the effective workingDirectory.
571
expect(promptResolver.resolvePrompt).toHaveBeenCalled();
572
expect(getWorkingDirectory((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][3])?.fsPath).toBe(`${sep}worktree`);
573
});
574
575
it('falls back to workspace workingDirectory when isolation is enabled but worktree creation fails', async () => {
576
// Set up untitled session folder (no git repo)
577
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}workspace`));
578
// Git returns no repository for this folder (default FakeGitService behavior)
579
const request = new TestChatRequest('Say hi');
580
const context = createChatContext('untitled:temp-new', true, request);
581
const stream = new MockChatResponseStream();
582
const token = disposables.add(new CancellationTokenSource()).token;
583
584
await participant.createHandler()(request, context, stream, token);
585
586
expect(cliSessions.length).toBe(1);
587
expect(cliSessions[0].workspace.worktreeProperties).toBeUndefined();
588
expect(getWorkingDirectory(cliSessions[0].workspace)?.fsPath).toBe(`${sep}workspace`);
589
expect(mcpHandler.loadMcpConfig).toHaveBeenCalled();
590
// Prompt resolver should receive the effective workingDirectory.
591
expect(promptResolver.resolvePrompt).toHaveBeenCalled();
592
expect(getWorkingDirectory((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][3])?.fsPath).toBe(`${sep}workspace`);
593
});
594
595
it('reuses existing session (non-untitled) and does not create new one', async () => {
596
const sessionId = 'existing-123';
597
const sdkSession = new MockCliSdkSession(sessionId, new Date());
598
manager.sessions.set(sessionId, sdkSession);
599
const authInfo = await sdk.getAuthInfo();
600
const request = new TestChatRequest('Continue');
601
const context = createChatContext(sessionId, false, request);
602
const stream = new MockChatResponseStream();
603
const token = disposables.add(new CancellationTokenSource()).token;
604
605
expect(cliSessions.length).toBe(0);
606
607
await participant.createHandler()(request, context, stream, token);
608
609
expect(cliSessions.length).toBe(1);
610
expect(cliSessions[0].sessionId).toBe(sessionId);
611
expect(cliSessions[0].requests.length).toBe(1);
612
expect(cliSessions[0].requests[0]).toEqual({ input: { prompt: 'Continue' }, attachments: [], model: { model: 'base' }, authInfo, token });
613
614
expect(itemProvider.swap).not.toHaveBeenCalled();
615
});
616
617
it('maps known slash commands to CLI command input for existing sessions', async () => {
618
const sessionId = 'existing-compact';
619
const sdkSession = new MockCliSdkSession(sessionId, new Date());
620
manager.sessions.set(sessionId, sdkSession);
621
const request = new TestChatRequest('');
622
request.command = 'compact';
623
const context = createChatContext(sessionId, false, request);
624
const stream = new MockChatResponseStream();
625
const token = disposables.add(new CancellationTokenSource()).token;
626
627
await participant.createHandler()(request, context, stream, token);
628
629
expect(cliSessions.length).toBe(1);
630
expect(cliSessions[0].requests).toHaveLength(1);
631
expect(cliSessions[0].requests[0].input).toEqual({ command: 'compact', prompt: '' });
632
expect(promptResolver.resolvePrompt).not.toHaveBeenCalled();
633
});
634
635
it.skip('returns early when yield is requested while the session is still running', async () => {
636
const sessionId = 'existing-yield';
637
const sdkSession = new MockCliSdkSession(sessionId, new Date());
638
manager.sessions.set(sessionId, sdkSession);
639
let resolveHandleRequest!: () => void;
640
let yieldRequested = false;
641
TestCopilotCLISession.nextHandleRequestResult = new Promise<void>(resolve => {
642
resolveHandleRequest = resolve;
643
});
644
645
const request = new TestChatRequest('Continue');
646
const context = createChatContext(sessionId, false, request) as vscode.ChatContext & { history: []; readonly yieldRequested: boolean };
647
Object.defineProperty(context, 'history', {
648
value: [],
649
configurable: true,
650
});
651
Object.defineProperty(context, 'yieldRequested', {
652
get: () => yieldRequested,
653
configurable: true,
654
});
655
const stream = new MockChatResponseStream();
656
const token = disposables.add(new CancellationTokenSource()).token;
657
let resolved = false;
658
659
const handlerPromise = (async () => {
660
await participant.createHandler()(request, context, stream, token);
661
resolved = true;
662
})();
663
await new Promise(resolve => setTimeout(resolve, 50));
664
expect(resolved).toBe(false);
665
666
yieldRequested = true;
667
await new Promise(resolve => setTimeout(resolve, 600));
668
expect(resolved).toBe(true);
669
670
resolveHandleRequest();
671
await handlerPromise;
672
});
673
674
it('defers worktree handleRequestCompleted until all steering requests complete', async () => {
675
// Use an existing (non-untitled) session so both concurrent requests are guaranteed to
676
// resolve to the same SDK session and share the same pendingRequestBySession entry.
677
const sessionId = 'existing-worktree-session';
678
const sdkSession = new MockCliSdkSession(sessionId, new Date());
679
manager.sessions.set(sessionId, sdkSession);
680
681
const worktreeProperties = {
682
autoCommit: true,
683
baseCommit: 'deadbeef',
684
branchName: 'test',
685
repositoryPath: `${sep}repo`,
686
worktreePath: `${sep}worktree`,
687
version: 1
688
} satisfies ChatSessionWorktreeProperties;
689
// FolderRepositoryManagerImpl.getFolderRepository checks worktreeService.getWorktreeProperties(sessionId)
690
// when the session ID is not an untitled ID.
691
(worktree.getWorktreeProperties as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(worktreeProperties);
692
// Simulate the session completing so the worktree commit path runs
693
TestCopilotCLISession.statusOverride = vscode.ChatSessionStatus.Completed;
694
695
let resolveFirst!: () => void;
696
const firstDeferred = new Promise<void>(resolve => { resolveFirst = resolve; });
697
let resolveSecond!: () => void;
698
const secondDeferred = new Promise<void>(resolve => { resolveSecond = resolve; });
699
700
TestCopilotCLISession.handleRequestHook = vi.fn((_request, input) => {
701
if (input.prompt === 'First') { return firstDeferred; }
702
return secondDeferred;
703
});
704
705
const context = createChatContext(sessionId, false);
706
const sessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}` });
707
const stream = new MockChatResponseStream();
708
709
const firstRequest = new TestChatRequest('First');
710
firstRequest.sessionResource = sessionResource;
711
const firstToken = disposables.add(new CancellationTokenSource()).token;
712
const firstPromise = participant.createHandler()(firstRequest, context, stream, firstToken);
713
714
const secondRequest = new TestChatRequest('Second');
715
secondRequest.sessionResource = sessionResource;
716
const secondToken = disposables.add(new CancellationTokenSource()).token;
717
const secondPromise = participant.createHandler()(secondRequest, context, stream, secondToken);
718
719
// Second (steering) request completes first — commit must NOT fire while first is still pending
720
resolveSecond();
721
await secondPromise;
722
expect(worktree.handleRequestCompleted).not.toHaveBeenCalled();
723
724
// First request completes last — commit fires exactly once
725
resolveFirst();
726
await firstPromise;
727
expect(worktree.handleRequestCompleted).toHaveBeenCalledTimes(1);
728
});
729
730
it('defers untitled session swap while a steering request is still pending', async () => {
731
(itemProvider.isNewSession as ReturnType<typeof vi.fn>).mockImplementation((sessionId: string) => sessionId.startsWith('untitled:'));
732
let resolveFirstRequest!: () => void;
733
const firstRequestDeferred = new Promise<void>(resolve => {
734
resolveFirstRequest = resolve;
735
});
736
let resolveSteeringRequest1!: () => void;
737
const steeringRequestDeferred1 = new Promise<void>(resolve => {
738
resolveSteeringRequest1 = resolve;
739
});
740
let resolveSteeringRequest2!: () => void;
741
const steeringRequestDeferred2 = new Promise<void>(resolve => {
742
resolveSteeringRequest2 = resolve;
743
});
744
let resolveSteeringRequest3!: () => void;
745
const steeringRequestDeferred3 = new Promise<void>(resolve => {
746
resolveSteeringRequest3 = resolve;
747
});
748
TestCopilotCLISession.handleRequestHook = vi.fn((_request, input) => {
749
if (input.prompt === 'First request') {
750
return firstRequestDeferred;
751
}
752
if (input.prompt === 'Steering request 1') {
753
return steeringRequestDeferred1;
754
}
755
if (input.prompt === 'Steering request 2') {
756
return steeringRequestDeferred2;
757
}
758
if (input.prompt === 'Steering request 3') {
759
return steeringRequestDeferred3;
760
}
761
return Promise.resolve();
762
});
763
764
const context = createChatContext('untitled:temp-steering', true);
765
const steeringSessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: '/untitled:temp-steering' });
766
const stream = new MockChatResponseStream();
767
768
const firstRequest = new TestChatRequest('First request');
769
firstRequest.sessionResource = steeringSessionResource;
770
const firstToken = disposables.add(new CancellationTokenSource()).token;
771
const firstPromise = participant.createHandler()(firstRequest, context, stream, firstToken);
772
773
const secondRequest = new TestChatRequest('Steering request 1');
774
secondRequest.sessionResource = steeringSessionResource;
775
const secondToken = disposables.add(new CancellationTokenSource()).token;
776
const secondPromise = participant.createHandler()(secondRequest, context, stream, secondToken);
777
778
const thirdRequest = new TestChatRequest('Steering request 2');
779
thirdRequest.sessionResource = steeringSessionResource;
780
const thirdToken = disposables.add(new CancellationTokenSource()).token;
781
const thirdPromise = participant.createHandler()(thirdRequest, context, stream, thirdToken);
782
783
const fourthRequest = new TestChatRequest('Steering request 3');
784
fourthRequest.sessionResource = steeringSessionResource;
785
const fourthToken = disposables.add(new CancellationTokenSource()).token;
786
const fourthPromise = participant.createHandler()(fourthRequest, context, stream, fourthToken);
787
788
resolveFirstRequest();
789
await firstPromise;
790
791
expect(itemProvider.swap).not.toHaveBeenCalled();
792
793
resolveSteeringRequest1();
794
await secondPromise;
795
796
expect(itemProvider.swap).not.toHaveBeenCalled();
797
798
resolveSteeringRequest2();
799
await thirdPromise;
800
801
expect(itemProvider.swap).not.toHaveBeenCalled();
802
803
const otherSessionId = 'existing-unblocked-session';
804
manager.sessions.set(otherSessionId, new MockCliSdkSession(otherSessionId, new Date()));
805
const otherContext = createChatContext(otherSessionId, false);
806
const otherRequest = new TestChatRequest('Request from other session');
807
otherRequest.sessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${otherSessionId}` });
808
const otherStream = new MockChatResponseStream();
809
const otherToken = disposables.add(new CancellationTokenSource()).token;
810
const otherRequestPromise = participant.createHandler()(otherRequest, otherContext, otherStream, otherToken);
811
const otherResult = await Promise.race([
812
Promise.resolve(otherRequestPromise).then(() => 'done'),
813
new Promise<'timeout'>(resolve => setTimeout(() => resolve('timeout'), 75))
814
]);
815
expect(otherResult).toBe('done');
816
817
expect(itemProvider.swap).not.toHaveBeenCalled();
818
819
resolveSteeringRequest3();
820
await fourthPromise;
821
822
expect(itemProvider.swap).toHaveBeenCalledTimes(1);
823
});
824
825
it('hydrates invalid sessions from partial history and blocks follow-up requests', async () => {
826
const sessionId = 'invalid-session';
827
const invalidSessionService = new class extends FakeCopilotCLISessionService {
828
override getSession = vi.fn(async () => {
829
throw new Error('Failed to load session. Unknown event type: custom.unknown.');
830
});
831
override getChatHistory = vi.fn(async () => {
832
throw new Error('Failed to load session. Unknown event type: custom.unknown.');
833
}) as unknown as ICopilotCLISessionService['getChatHistory'];
834
override createSession = vi.fn(async () => {
835
throw new Error('createSession should not be called for invalid sessions');
836
});
837
override tryGetPartialSessionHistory: ICopilotCLISessionService['tryGetPartialSessionHistory'] = vi.fn(async () => ([{} as unknown as vscode.ChatRequestTurn, {} as unknown as vscode.ChatResponseTurn]));
838
}();
839
invalidSessionService.setTestSessionWorkingDirectory(sessionId, Uri.file(`${sep}workspace`));
840
const invalidContentProvider = new CopilotCLIChatSessionContentProvider(
841
itemProvider,
842
new NullCopilotCLIAgents(),
843
invalidSessionService,
844
worktree,
845
workspaceService,
846
new MockFileSystemService(),
847
git,
848
folderRepositoryManager,
849
configurationService,
850
customSessionTitleService,
851
new MockExtensionContext() as unknown as IVSCodeExtensionContext,
852
logService,
853
new (mock<IChatFolderMruService>())(),
854
);
855
const invalidParticipant = new CopilotCLIChatSessionParticipant(
856
invalidContentProvider,
857
promptResolver,
858
itemProvider,
859
cloudProvider,
860
undefined,
861
git,
862
models as unknown as ICopilotCLIModels,
863
new NullCopilotCLIAgents(),
864
invalidSessionService,
865
worktree,
866
worktreeCheckpointService,
867
workspaceFolderService,
868
telemetry,
869
logService,
870
disposables.add(new MockPromptsService()),
871
new class extends mock<IChatDelegationSummaryService>() {
872
override async summarize(_context: vscode.ChatContext, _token: vscode.CancellationToken): Promise<string | undefined> {
873
return undefined;
874
}
875
}(),
876
folderRepositoryManager,
877
configurationService,
878
sdk,
879
new MockChatSessionMetadataStore(),
880
customSessionTitleService,
881
new (mock<IOctoKitService>())(),
882
);
883
const sessionResource = vscode.Uri.from({ scheme: 'copilotcli', path: `/${sessionId}` });
884
const contentToken = disposables.add(new CancellationTokenSource()).token;
885
886
const sessionContent = await invalidContentProvider.provideChatSessionContentForExistingSession(sessionResource, contentToken);
887
888
expect(sessionContent.history).toHaveLength(2);
889
expect(invalidSessionService.tryGetPartialSessionHistory).toHaveBeenCalledWith(sessionId);
890
891
(invalidSessionService.getSession as ReturnType<typeof vi.fn>).mockClear();
892
(invalidSessionService.createSession as ReturnType<typeof vi.fn>).mockClear();
893
(invalidSessionService.tryGetPartialSessionHistory as ReturnType<typeof vi.fn>).mockClear();
894
const request = new TestChatRequest('Continue from VS Code');
895
const context = createChatContext(sessionId, false, request);
896
const stream = new MockChatResponseStream();
897
const requestToken = disposables.add(new CancellationTokenSource()).token;
898
899
await invalidParticipant.createHandler()(request, context, stream, requestToken);
900
901
const output = stream.output.join('\n');
902
expect(output).toContain('Failed loading this session');
903
expect(output).toContain('report an issue');
904
// The error message is appended via MarkdownString.appendText which encodes spaces as &nbsp;
905
expect(output).toContain('Failed&nbsp;to&nbsp;load&nbsp;session');
906
expect(invalidSessionService.getSession).not.toHaveBeenCalled();
907
expect(invalidSessionService.createSession).not.toHaveBeenCalled();
908
});
909
910
it('handles /delegate command for existing session (no session.handleRequest)', async () => {
911
const sessionId = 'existing-123';
912
const sdkSession = new MockCliSdkSession(sessionId, new Date());
913
manager.sessions.set(sessionId, sdkSession);
914
915
git.activeRepository = { get: () => ({ changes: { indexChanges: [{ path: 'file.ts' }] } }) } as unknown as IGitService['activeRepository'];
916
const request = new TestChatRequest('Build feature');
917
request.command = 'delegate';
918
const context = createChatContext(sessionId, false, request);
919
const stream = new MockChatResponseStream();
920
const token = disposables.add(new CancellationTokenSource()).token;
921
expect(cliSessions.length).toBe(0);
922
923
await participant.createHandler()(request, context, stream, token);
924
925
expect(cliSessions.length).toBe(1);
926
expect(cliSessions[0].sessionId).toBe(sessionId);
927
expect(cliSessions[0].requests.length).toBe(0);
928
expect(sdkSession.emittedEvents.length).toBe(2);
929
expect(sdkSession.emittedEvents[0].event).toBe('user.message');
930
expect(sdkSession.emittedEvents[0].content).toBe('/delegate Build feature');
931
expect(sdkSession.emittedEvents[1].event).toBe('assistant.message');
932
expect(sdkSession.emittedEvents[1].content).toContain('pr://1');
933
// Uncommitted changes warning surfaced
934
// Warning should appear (we emitted stream.warning). The mock stream only records markdown.
935
// Delegate path adds assistant PR metadata; ensure output contains PR metadata tag instead of relying on warning capture.
936
expect(sdkSession.emittedEvents[1].content).toMatch(/<pr_metadata uri="pr:\/\/1"/);
937
expect(cloudProvider.delegate).toHaveBeenCalled();
938
});
939
940
it('handles /delegate command from another chat (has uncommitted changes and user copies changes)', async () => {
941
expect(manager.sessions.size).toBe(0);
942
const repoContext = { rootUri: Uri.file(`${sep}workspace`), changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } } as unknown as RepoContext;
943
git.activeRepository = { get: () => repoContext } as unknown as IGitService['activeRepository'];
944
git.setRepo(repoContext);
945
tools.nextConfirmationButton = 'Copy Changes';
946
const request = new TestChatRequest('/delegate Build feature');
947
const context = { chatSessionContext: undefined } as vscode.ChatContext;
948
const stream = new MockChatResponseStream();
949
const token = disposables.add(new CancellationTokenSource()).token;
950
951
await participant.createHandler()(request, context, stream, token);
952
953
// With the awaitable confirmation, the session should be created in a single request
954
expect(manager.sessions.size).toBe(1);
955
const delegateCallArgs = (tools.invokeTool as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
956
expect(delegateCallArgs[0]).toBe('vscode_get_modified_files_confirmation');
957
expect(delegateCallArgs[1].input.title).toBe('Delegate to Copilot CLI');
958
expect(delegateCallArgs[1].input.modifiedFiles).toHaveLength(1);
959
expect(delegateCallArgs[1].input.modifiedFiles[0].uri.toString()).toBe(Uri.file(`${sep}workspace${sep}file.ts`).toString());
960
expect(delegateCallArgs[2]).toBe(token);
961
});
962
963
it('handles /delegate command from another chat without active repository', async () => {
964
expect(manager.sessions.size).toBe(0);
965
const request = new TestChatRequest('/delegate Build feature');
966
const context = { chatSessionContext: undefined } as vscode.ChatContext;
967
const stream = new MockChatResponseStream();
968
const token = disposables.add(new CancellationTokenSource()).token;
969
970
await participant.createHandler()(request, context, stream, token);
971
972
expect(manager.sessions.size).toBe(1);
973
// No confirmation should be invoked when there are no uncommitted changes
974
expect(tools.invokeTool).not.toHaveBeenCalled();
975
});
976
977
it('handles /delegate command for new session without uncommitted changes', async () => {
978
expect(manager.sessions.size).toBe(0);
979
git.activeRepository = { get: () => ({ changes: { indexChanges: [], workingTree: [] } }) } as unknown as IGitService['activeRepository'];
980
const request = new TestChatRequest('Build feature');
981
request.command = 'delegate';
982
const context = createChatContext('existing-delegate', true, request);
983
const stream = new MockChatResponseStream();
984
const token = disposables.add(new CancellationTokenSource()).token;
985
986
await participant.createHandler()(request, context, stream, token);
987
988
expect(manager.sessions.size).toBe(1);
989
const sdkSession = Array.from(manager.sessions.values())[0];
990
expect(cloudProvider.delegate).toHaveBeenCalled();
991
// PR metadata recorded
992
expect(sdkSession.emittedEvents.length).toBe(2);
993
expect(sdkSession.emittedEvents[0].event).toBe('user.message');
994
expect(sdkSession.emittedEvents[0].content).toBe('/delegate Build feature');
995
expect(sdkSession.emittedEvents[1].event).toBe('assistant.message');
996
expect(sdkSession.emittedEvents[1].content).toContain('pr://1');
997
// Warning should appear (we emitted stream.warning). The mock stream only records markdown.
998
// Delegate path adds assistant PR metadata; ensure output contains PR metadata tag instead of relying on warning capture.
999
expect(sdkSession.emittedEvents[1].content).toMatch(/<pr_metadata uri="pr:\/\/1"/);
1000
});
1001
1002
it('starts a new chat session and submits the request', async () => {
1003
const request = new TestChatRequest('Push this');
1004
(request as Record<string, any>).model = mockLanguageModelChat;
1005
const context = { chatSessionContext: undefined, chatSummary: undefined } as unknown as vscode.ChatContext;
1006
const stream = new MockChatResponseStream();
1007
const token = disposables.add(new CancellationTokenSource()).token;
1008
const summarySpy = vi.spyOn(summarizer, 'provideChatSummary');
1009
1010
await participant.createHandler()(request, context, stream, token);
1011
1012
expect(manager.sessions.size).toBe(1);
1013
expect(summarySpy).toHaveBeenCalledTimes(0);
1014
// Delegation creates the session and fires executeCommand (fire-and-forget).
1015
// The request is processed asynchronously when VS Code opens the session.
1016
expect(mockExecuteCommand).toHaveBeenCalledWith(
1017
'workbench.action.chat.openSessionWithPrompt.copilotcli',
1018
expect.objectContaining({
1019
prompt: 'Push this',
1020
})
1021
);
1022
});
1023
1024
it('handles existing session with acceptedConfirmationData (no longer triggers cloud delegation)', async () => {
1025
// With the new flow, acceptedConfirmationData is no longer used for uncommitted changes.
1026
// Existing sessions proceed directly to handleRequest without confirmation flow.
1027
const sessionId = 'existing-confirm';
1028
const sdkSession = new MockCliSdkSession(sessionId, new Date());
1029
manager.sessions.set(sessionId, sdkSession);
1030
const request = new TestChatRequest('my prompt');
1031
const context = createChatContext(sessionId, false, request);
1032
const stream = new MockChatResponseStream();
1033
const token = disposables.add(new CancellationTokenSource()).token;
1034
1035
await participant.createHandler()(request, context, stream, token);
1036
1037
// Should call session.handleRequest normally
1038
expect(cliSessions.length).toBe(1);
1039
expect(cliSessions[0].requests.length).toBe(1);
1040
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'my prompt' });
1041
});
1042
1043
it('handles existing session with rejectedConfirmationData (proceeds normally)', async () => {
1044
// With the new flow, rejectedConfirmationData is no longer used for uncommitted changes.
1045
const sessionId = 'existing-confirm-reject';
1046
const sdkSession = new MockCliSdkSession(sessionId, new Date());
1047
manager.sessions.set(sessionId, sdkSession);
1048
const request = new TestChatRequest('Apply');
1049
const context = createChatContext(sessionId, false, request);
1050
const stream = new MockChatResponseStream();
1051
const token = disposables.add(new CancellationTokenSource()).token;
1052
1053
await participant.createHandler()(request, context, stream, token);
1054
1055
// Should proceed normally (no cloud delegation)
1056
expect(cliSessions.length).toBe(1);
1057
expect(cliSessions[0].requests.length).toBe(1);
1058
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Apply' });
1059
});
1060
1061
it('handles existing session with unknown step acceptedConfirmationData (proceeds normally)', async () => {
1062
const sessionId = 'existing-confirm-unknown';
1063
const sdkSession = new MockCliSdkSession(sessionId, new Date());
1064
manager.sessions.set(sessionId, sdkSession);
1065
const request = new TestChatRequest('Apply');
1066
const context = createChatContext(sessionId, false, request);
1067
const stream = new MockChatResponseStream();
1068
const token = disposables.add(new CancellationTokenSource()).token;
1069
1070
await participant.createHandler()(request, context, stream, token);
1071
1072
// Should proceed normally
1073
expect(cliSessions.length).toBe(1);
1074
expect(cliSessions[0].requests.length).toBe(1);
1075
});
1076
1077
it('prompts for uncommitted changes action for untitled session with uncommitted changes', async () => {
1078
git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];
1079
git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);
1080
// Set up untitled session folder so getFolderRepository returns repository info
1081
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));
1082
// User selects Copy Changes
1083
tools.nextConfirmationButton = 'Copy Changes';
1084
const request = new TestChatRequest('Fix the bug');
1085
const context = createChatContext('untitled:temp-new', true, request);
1086
const stream = new MockChatResponseStream();
1087
const token = disposables.add(new CancellationTokenSource()).token;
1088
1089
await participant.createHandler()(request, context, stream, token);
1090
1091
// Session should be created in one request (no separate confirmation round-trip)
1092
expect(cliSessions.length).toBe(1);
1093
expect(cliSessions[0].requests.length).toBe(1);
1094
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });
1095
// Verify confirmation tool was invoked with the right title
1096
const confirmCallArgs = (tools.invokeTool as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
1097
expect(confirmCallArgs[0]).toBe('vscode_get_modified_files_confirmation');
1098
expect(confirmCallArgs[1].input.title).toBe('Uncommitted Changes');
1099
expect(confirmCallArgs[1].input.modifiedFiles).toHaveLength(1);
1100
expect(confirmCallArgs[1].input.modifiedFiles[0].uri.toString()).toBe(Uri.file(`${sep}repo${sep}file.ts`).toString());
1101
expect(confirmCallArgs[2]).toBe(token);
1102
});
1103
1104
it('uses request prompt directly when user accepts uncommitted changes confirmation', async () => {
1105
git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];
1106
git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);
1107
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));
1108
tools.nextConfirmationButton = 'Copy Changes';
1109
1110
const request = new TestChatRequest('Fix the bug');
1111
const context = createChatContext('untitled:temp-new', true, request);
1112
const stream = new MockChatResponseStream();
1113
const token = disposables.add(new CancellationTokenSource()).token;
1114
1115
await participant.createHandler()(request, context, stream, token);
1116
1117
// Should create session and use request.prompt directly
1118
expect(cliSessions.length).toBe(1);
1119
expect(cliSessions[0].requests.length).toBe(1);
1120
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });
1121
// Verify promptResolver was called without override prompt
1122
expect(promptResolver.resolvePrompt).toHaveBeenCalled();
1123
expect((promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1]).toBeUndefined();
1124
});
1125
1126
it('uses request prompt for session label when swapping untitled session', async () => {
1127
git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];
1128
git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);
1129
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));
1130
tools.nextConfirmationButton = 'Move Changes';
1131
1132
const request = new TestChatRequest('Implement new feature');
1133
const context = createChatContext('untitled:temp-new', true, request);
1134
const stream = new MockChatResponseStream();
1135
const token = disposables.add(new CancellationTokenSource()).token;
1136
1137
await participant.createHandler()(request, context, stream, token);
1138
1139
// Should swap with request.prompt as label
1140
expect(itemProvider.swap).toHaveBeenCalled();
1141
const swapCall = (itemProvider.swap as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
1142
expect(swapCall[1].label).toBe('Implement new feature');
1143
});
1144
1145
it('passes empty references array to resolvePrompt after confirmation', async () => {
1146
git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];
1147
git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);
1148
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));
1149
tools.nextConfirmationButton = 'Copy Changes';
1150
1151
const request = new TestChatRequest('Fix the bug');
1152
const context = createChatContext('untitled:temp-new', true, request);
1153
const stream = new MockChatResponseStream();
1154
const token = disposables.add(new CancellationTokenSource()).token;
1155
1156
await participant.createHandler()(request, context, stream, token);
1157
1158
// Should pass empty array to resolvePrompt (no metadata to recover from)
1159
expect(promptResolver.resolvePrompt).toHaveBeenCalled();
1160
const resolvePromptCall = (promptResolver.resolvePrompt as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
1161
expect(resolvePromptCall[2]).toEqual([]);
1162
});
1163
1164
it('returns empty when user cancels untitled session confirmation', async () => {
1165
git.activeRepository = { get: () => ({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];
1166
git.setRepo({ rootUri: Uri.file(`${sep}repo`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);
1167
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}repo`));
1168
// User clicks Cancel
1169
tools.nextConfirmationButton = 'Cancel';
1170
1171
const request = new TestChatRequest('Fix the bug');
1172
const context = createChatContext('untitled:temp-new', true, request);
1173
const stream = new MockChatResponseStream();
1174
const token = disposables.add(new CancellationTokenSource()).token;
1175
1176
await participant.createHandler()(request, context, stream, token);
1177
1178
// Should not create session
1179
expect(cliSessions.length).toBe(0);
1180
expect(itemProvider.swap).not.toHaveBeenCalled();
1181
});
1182
1183
it('does not prompt for confirmation for untitled session without uncommitted changes', async () => {
1184
git.activeRepository = { get: () => ({ changes: { indexChanges: [], workingTree: [] } }) } as unknown as IGitService['activeRepository'];
1185
1186
const request = new TestChatRequest('Fix the bug');
1187
const context = createChatContext('temp-new', true, request);
1188
const stream = new MockChatResponseStream();
1189
const token = disposables.add(new CancellationTokenSource()).token;
1190
1191
await participant.createHandler()(request, context, stream, token);
1192
1193
// Should create session directly without confirmation
1194
expect(tools.invokeTool).not.toHaveBeenCalled();
1195
expect(cliSessions.length).toBe(1);
1196
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Fix the bug' });
1197
});
1198
1199
it('does not prompt for confirmation for existing (non-untitled) session with uncommitted changes', async () => {
1200
const sessionId = 'existing-123';
1201
const sdkSession = new MockCliSdkSession(sessionId, new Date());
1202
manager.sessions.set(sessionId, sdkSession);
1203
git.activeRepository = { get: () => ({ changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] } }) } as unknown as IGitService['activeRepository'];
1204
1205
const request = new TestChatRequest('Continue work');
1206
const context = createChatContext(sessionId, false, request);
1207
const stream = new MockChatResponseStream();
1208
const token = disposables.add(new CancellationTokenSource()).token;
1209
1210
await participant.createHandler()(request, context, stream, token);
1211
1212
// Should not prompt for confirmation for existing sessions
1213
expect(tools.invokeTool).not.toHaveBeenCalled();
1214
expect(cliSessions.length).toBe(1);
1215
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'Continue work' });
1216
});
1217
1218
it('reuses untitled session without uncommitted changes instead of creating new session', async () => {
1219
git.activeRepository = { get: () => ({ changes: { indexChanges: [], workingTree: [] } }) } as unknown as IGitService['activeRepository'];
1220
1221
// First request creates the session
1222
const request1 = new TestChatRequest('First request');
1223
const context1 = createChatContext('temp-new', true, request1);
1224
const stream1 = new MockChatResponseStream();
1225
const token1 = disposables.add(new CancellationTokenSource()).token;
1226
1227
await participant.createHandler()(request1, context1, stream1, token1);
1228
expect(cliSessions.length).toBe(1);
1229
const firstSessionId = cliSessions[0].sessionId;
1230
1231
// Second request should reuse the same session (now it's not untitled anymore after first request)
1232
const request2 = new TestChatRequest('Second request');
1233
const context2 = createChatContext(firstSessionId, false, request2);
1234
const stream2 = new MockChatResponseStream();
1235
const token2 = disposables.add(new CancellationTokenSource()).token;
1236
1237
await participant.createHandler()(request2, context2, stream2, token2);
1238
1239
// Session wrapper can be recreated, but the SDK session should be reused.
1240
expect(manager.sessions.size).toBe(1);
1241
expect(new Set(cliSessions.map(s => s.sessionId))).toEqual(new Set([firstSessionId]));
1242
expect(cliSessions.reduce((count, s) => count + s.requests.length, 0)).toBe(2);
1243
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request' });
1244
expect(cliSessions.at(-1)?.requests.at(-1)?.input).toEqual({ prompt: 'Second request' });
1245
});
1246
1247
it('reuses untitled session after confirmation without creating new session', async () => {
1248
git.activeRepository = { get: () => ({ remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } }) } as unknown as IGitService['activeRepository'];
1249
git.setRepo({ rootUri: Uri.file(`${sep}workspace`), remotes: [], changes: { indexChanges: [{ path: 'file.ts' }], mergeChanges: [], workingTree: [], untrackedChanges: [] } } as unknown as RepoContext);
1250
// Set up untitled session folder so getFolderRepository returns repository info (for uncommitted changes check)
1251
folderRepositoryManager.setNewSessionFolder('untitled:temp-new', Uri.file(`${sep}workspace`));
1252
// User selects Copy Changes via the tools confirmation
1253
tools.nextConfirmationButton = 'Copy Changes';
1254
1255
// First request creates the session (with confirmation handled inline)
1256
const request1 = new TestChatRequest('First request');
1257
const context1 = createChatContext('untitled:temp-new', true, request1);
1258
const stream1 = new MockChatResponseStream();
1259
const token1 = disposables.add(new CancellationTokenSource()).token;
1260
1261
await participant.createHandler()(request1, context1, stream1, token1);
1262
1263
// Session should be created
1264
expect(cliSessions.length).toBe(1);
1265
const firstSessionId = cliSessions[0].sessionId;
1266
expect(cliSessions[0].requests.length).toBe(1);
1267
expect(cliSessions[0].requests[0].input).toEqual({ prompt: 'First request' });
1268
1269
// Second request should reuse the same session
1270
const request2 = new TestChatRequest('Second request');
1271
const context2 = createChatContext(firstSessionId, false, request2);
1272
const stream2 = new MockChatResponseStream();
1273
const token2 = disposables.add(new CancellationTokenSource()).token;
1274
1275
await participant.createHandler()(request2, context2, stream2, token2);
1276
1277
// Session wrapper can be recreated, but the SDK session should be reused.
1278
expect(manager.sessions.size).toBe(1);
1279
expect(new Set(cliSessions.map(s => s.sessionId))).toEqual(new Set([firstSessionId]));
1280
expect(cliSessions.reduce((count, s) => count + s.requests.length, 0)).toBe(2);
1281
expect(cliSessions.at(-1)?.requests.at(-1)?.input).toEqual({ prompt: 'Second request' });
1282
});
1283
1284
describe('Authorization check', () => {
1285
it('throws when auth token is empty and no proxy URL configured', async () => {
1286
(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockResolvedValue({ type: 'token', token: '', host: 'https://github.com' });
1287
1288
const request = new TestChatRequest('Say hi');
1289
const context = createChatContext('temp-new', true, request);
1290
const stream = new MockChatResponseStream();
1291
const token = disposables.add(new CancellationTokenSource()).token;
1292
1293
await expect(participant.createHandler()(request, context, stream, token)).rejects.toThrow('Authorization failed');
1294
expect(cliSessions.length).toBe(0);
1295
});
1296
1297
it('proceeds normally when auth token is valid', async () => {
1298
(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockResolvedValue({ type: 'token', token: 'valid-token', host: 'https://github.com' });
1299
1300
const request = new TestChatRequest('Say hi');
1301
const context = createChatContext('temp-new', true, request);
1302
const stream = new MockChatResponseStream();
1303
const token = disposables.add(new CancellationTokenSource()).token;
1304
1305
await participant.createHandler()(request, context, stream, token);
1306
1307
expect(cliSessions.length).toBe(1);
1308
expect(cliSessions[0].requests.length).toBe(1);
1309
});
1310
1311
it('proceeds when auth type is not token even if token is empty', async () => {
1312
(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockResolvedValue({ type: 'oauth', token: '', host: 'https://github.com' });
1313
1314
const request = new TestChatRequest('Say hi');
1315
const context = createChatContext('temp-new', true, request);
1316
const stream = new MockChatResponseStream();
1317
const token = disposables.add(new CancellationTokenSource()).token;
1318
1319
await participant.createHandler()(request, context, stream, token);
1320
1321
expect(cliSessions.length).toBe(1);
1322
expect(cliSessions[0].requests.length).toBe(1);
1323
});
1324
1325
it('throws when getAuthInfo rejects', async () => {
1326
(sdk.getAuthInfo as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('network error'));
1327
1328
const request = new TestChatRequest('Say hi');
1329
const context = createChatContext('temp-new', true, request);
1330
const stream = new MockChatResponseStream();
1331
const token = disposables.add(new CancellationTokenSource()).token;
1332
1333
await expect(participant.createHandler()(request, context, stream, token)).rejects.toThrow('Authorization failed');
1334
expect(cliSessions.length).toBe(0);
1335
});
1336
});
1337
1338
describe('Repository option locking behavior', () => {
1339
it('locks repository option on request start for untitled sessions', async () => {
1340
// Setup folder repository manager to return valid folder data
1341
const sessionId = 'untitled:temp-lock';
1342
const mockGetFolderRepository = vi.fn(async () => ({
1343
folder: Uri.file(`${sep}workspace`),
1344
trusted: true
1345
}));
1346
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1347
1348
const request = new TestChatRequest('Say hi');
1349
const context = createChatContext(sessionId, true, request);
1350
const stream = new MockChatResponseStream();
1351
const token = disposables.add(new CancellationTokenSource()).token;
1352
1353
await participant.createHandler()(request, context, stream, token);
1354
1355
// Verify lock was called with locked: true before other operations
1356
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1357
const lockCalls = allCalls.filter(
1358
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1359
);
1360
expect(lockCalls.length).toBeGreaterThan(0);
1361
});
1362
1363
it('does not lock repository option for existing (non-untitled) sessions', async () => {
1364
const sessionId = 'existing-lock-123';
1365
const sdkSession = new MockCliSdkSession(sessionId, new Date());
1366
manager.sessions.set(sessionId, sdkSession);
1367
1368
const request = new TestChatRequest('Continue work');
1369
const context = createChatContext(sessionId, false, request);
1370
const stream = new MockChatResponseStream();
1371
const token = disposables.add(new CancellationTokenSource()).token;
1372
1373
await participant.createHandler()(request, context, stream, token);
1374
1375
// Verify lock was NOT called (no calls with locked flag)
1376
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1377
const lockCalls = allCalls.filter(
1378
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1379
);
1380
expect(lockCalls.length).toBe(0);
1381
});
1382
1383
it('unlocks repository option when user rejects trust check', async () => {
1384
const sessionId = 'untitled:temp-trust-fail';
1385
// Mock folderRepositoryManager to simulate trust rejection
1386
const mockGetFolderRepository = vi.fn(async () => ({
1387
trusted: false,
1388
folder: Uri.file(`${sep}workspace`)
1389
}));
1390
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1391
// Trust rejection now happens in initializeFolderRepository (not in the removed hasUncommittedChangesToHandleInRequest)
1392
const mockInitializeFolderRepository = vi.fn(async () => ({
1393
trusted: false,
1394
folder: Uri.file(`${sep}workspace`),
1395
repository: undefined,
1396
worktree: undefined,
1397
worktreeProperties: undefined
1398
}));
1399
(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;
1400
1401
const request = new TestChatRequest('Say hi');
1402
const context = createChatContext(sessionId, true, request);
1403
const stream = new MockChatResponseStream();
1404
const token = disposables.add(new CancellationTokenSource()).token;
1405
1406
await participant.createHandler()(request, context, stream, token);
1407
1408
// Verify lock was called
1409
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1410
const lockCalls = allCalls.filter(
1411
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1412
);
1413
expect(lockCalls.length).toBeGreaterThan(0);
1414
1415
// Verify unlock was called (value is string with no locked flag)
1416
const unlockCalls = allCalls.filter(
1417
call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')
1418
);
1419
expect(unlockCalls.length).toBeGreaterThan(0);
1420
1421
// Verify no session was created due to trust rejection
1422
expect(cliSessions.length).toBe(0);
1423
});
1424
1425
it('does not unlock repository option when user cancels confirmation', async () => {
1426
const sessionId = 'untitled:temp-cancel';
1427
git.activeRepository = {
1428
get: () => ({
1429
rootUri: Uri.file(`${sep}repo`),
1430
changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] }
1431
})
1432
} as unknown as IGitService['activeRepository'];
1433
git.setRepo({
1434
rootUri: Uri.file(`${sep}repo`),
1435
changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [] }
1436
} as unknown as RepoContext);
1437
1438
const mockGetFolderRepository = vi.fn(async () => ({
1439
repository: { rootUri: Uri.file(`${sep}repo`), kind: 'repository' } as unknown as RepoContext,
1440
folder: Uri.file(`${sep}repo`),
1441
trusted: true
1442
}));
1443
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1444
1445
// User cancels the confirmation
1446
tools.nextConfirmationButton = 'Cancel';
1447
1448
const request = new TestChatRequest('Fix bug');
1449
const context = createChatContext(sessionId, true, request);
1450
const stream = new MockChatResponseStream();
1451
const token = disposables.add(new CancellationTokenSource()).token;
1452
1453
await participant.createHandler()(request, context, stream, token);
1454
1455
// Verify lock was called
1456
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1457
const lockCalls = allCalls.filter(
1458
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1459
);
1460
expect(lockCalls.length).toBeGreaterThan(0);
1461
1462
// After cancel, there should be no unlock calls (repository option remains locked)
1463
const unlockCalls = allCalls.filter(
1464
call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')
1465
);
1466
expect(unlockCalls.length).toBe(0);
1467
1468
// No session created due to cancellation
1469
expect(cliSessions.length).toBe(0);
1470
});
1471
1472
it('does not unlock repository option when session creation fails', async () => {
1473
const sessionId = 'untitled:temp-fail';
1474
const mockGetFolderRepository = vi.fn(async () => ({
1475
folder: Uri.file(`${sep}workspace`),
1476
trusted: true
1477
}));
1478
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1479
1480
const request = new TestChatRequest('Say hi');
1481
const context = createChatContext(sessionId, true, request);
1482
const stream = new MockChatResponseStream();
1483
const token = disposables.add(new CancellationTokenSource()).token;
1484
1485
// Mock sessionService.createSession to return null
1486
const originalCreateSession = sessionService.createSession;
1487
(sessionService.createSession as any) = vi.fn(async () => undefined);
1488
1489
try {
1490
await participant.createHandler()(request, context, stream, token);
1491
} finally {
1492
(sessionService.createSession as any) = originalCreateSession;
1493
}
1494
1495
// Verify lock was called
1496
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1497
const lockCalls = allCalls.filter(
1498
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1499
);
1500
expect(lockCalls.length).toBeGreaterThan(0);
1501
1502
// Verify unlock was NOT called on failure (session creation failed but workspace was trusted)
1503
const unlockCalls = allCalls.filter(
1504
call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')
1505
);
1506
expect(unlockCalls.length).toBe(0);
1507
1508
// No session created due to failure
1509
expect(cliSessions.length).toBe(0);
1510
});
1511
1512
it('keeps repository option locked throughout successful request flow', async () => {
1513
const sessionId = 'untitled:temp-success';
1514
const mockGetFolderRepository = vi.fn(async () => ({
1515
folder: Uri.file(`${sep}workspace`),
1516
trusted: true
1517
}));
1518
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1519
1520
const request = new TestChatRequest('Say hi');
1521
const context = createChatContext(sessionId, true, request);
1522
const stream = new MockChatResponseStream();
1523
const token = disposables.add(new CancellationTokenSource()).token;
1524
1525
await participant.createHandler()(request, context, stream, token);
1526
1527
// Verify lock was called
1528
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1529
const lockCalls = allCalls.filter(
1530
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1531
);
1532
expect(lockCalls.length).toBeGreaterThan(0);
1533
1534
// Verify unlock was NOT called on successful completion
1535
const unlockCalls = allCalls.filter(
1536
call => call[1].some((update: any) => update.optionId === 'repository' && typeof update.value === 'string')
1537
);
1538
expect(unlockCalls.length).toBe(0);
1539
1540
// Verify session was created
1541
expect(cliSessions.length).toBe(1);
1542
});
1543
1544
it('displays repo directory name (not parent workspace folder name) for sub-directory git repos in multi-root workspaces', async () => {
1545
// Bug scenario: multi-root workspace with folders A, B where B has sub-directories repo1, repo2.
1546
// When user selects repo2, the locked dropdown should display "repo2", not "B".
1547
const sessionId = 'untitled:temp-multiroot';
1548
const repoUri = Uri.file(`${sep}workspaces${sep}B${sep}repo2`);
1549
const mockGetFolderRepository = vi.fn(async () => ({
1550
folder: repoUri,
1551
repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,
1552
trusted: true
1553
}));
1554
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1555
1556
const request = new TestChatRequest('Say hi');
1557
const context = createChatContext(sessionId, true, request);
1558
const stream = new MockChatResponseStream();
1559
const token = disposables.add(new CancellationTokenSource()).token;
1560
1561
await participant.createHandler()(request, context, stream, token);
1562
1563
// Verify the locked option uses the repo name "repo2", not the parent workspace folder "B"
1564
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1565
const lockCalls = allCalls.filter(
1566
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1567
);
1568
expect(lockCalls.length).toBeGreaterThan(0);
1569
// When repository is available, toRepositoryOptionItem derives name from the repo URI path
1570
const repoLockUpdate = lockCalls.flatMap(call => call[1]).find(
1571
(update: any) => update.optionId === 'repository' && update.value?.locked === true
1572
);
1573
expect(repoLockUpdate.value.name).toBe('repo2');
1574
expect(repoLockUpdate.value.id).toBe(repoUri.fsPath);
1575
});
1576
1577
it('displays folder basename (not workspace folder name) when locking a non-repo sub-directory folder', async () => {
1578
// When the selected folder is NOT a git repo but is a sub-directory of a workspace folder,
1579
// the locked dropdown should display the folder's basename, not the workspace folder name.
1580
const sessionId = 'untitled:temp-subfolder';
1581
const folderUri = Uri.file(`${sep}workspaces${sep}B${sep}subfolder`);
1582
const mockGetFolderRepository = vi.fn(async () => ({
1583
folder: folderUri,
1584
repository: undefined,
1585
trusted: true
1586
}));
1587
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1588
1589
const request = new TestChatRequest('Say hi');
1590
const context = createChatContext(sessionId, true, request);
1591
const stream = new MockChatResponseStream();
1592
const token = disposables.add(new CancellationTokenSource()).token;
1593
1594
await participant.createHandler()(request, context, stream, token);
1595
1596
// Verify the locked option uses basename "subfolder", not workspace folder name "B"
1597
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1598
const lockCalls = allCalls.filter(
1599
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1600
);
1601
expect(lockCalls.length).toBeGreaterThan(0);
1602
const folderLockUpdate = lockCalls.flatMap(call => call[1]).find(
1603
(update: any) => update.optionId === 'repository' && update.value?.locked === true
1604
);
1605
expect(folderLockUpdate.value.name).toBe('subfolder');
1606
expect(folderLockUpdate.value.id).toBe(folderUri.fsPath);
1607
// Non-repo folder should use folder icon
1608
expect(folderLockUpdate.value.icon.id).toBe('folder');
1609
});
1610
1611
it('uses repo icon for repository and folder icon for plain folder when locking', async () => {
1612
// Verify icon differentiation: repo gets 'repo' icon, plain folder gets 'folder' icon
1613
const sessionId = 'untitled:temp-icon';
1614
const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);
1615
const mockGetFolderRepository = vi.fn(async () => ({
1616
folder: repoUri,
1617
repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,
1618
trusted: true
1619
}));
1620
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1621
1622
const request = new TestChatRequest('Say hi');
1623
const context = createChatContext(sessionId, true, request);
1624
const stream = new MockChatResponseStream();
1625
const token = disposables.add(new CancellationTokenSource()).token;
1626
1627
await participant.createHandler()(request, context, stream, token);
1628
1629
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1630
const repoLockUpdate = allCalls.flatMap(call => call[1]).find(
1631
(update: any) => update.optionId === 'repository' && update.value?.locked === true
1632
);
1633
// Repository should use 'repo' icon
1634
expect(repoLockUpdate.value.icon.id).toBe('repo');
1635
});
1636
1637
it('eagerly re-locks repo option with accurate info after session creation for untitled sessions', async () => {
1638
// The new code at line ~735 fires `void this.lockRepoOptionForSession(context, token)`
1639
// after session creation to update the locked dropdown with more accurate info.
1640
const sessionId = 'untitled:temp-eager-lock';
1641
const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);
1642
const mockGetFolderRepository = vi.fn(async () => ({
1643
folder: repoUri,
1644
repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,
1645
trusted: true
1646
}));
1647
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1648
1649
const request = new TestChatRequest('Say hi');
1650
const context = createChatContext(sessionId, true, request);
1651
const stream = new MockChatResponseStream();
1652
const token = disposables.add(new CancellationTokenSource()).token;
1653
1654
await participant.createHandler()(request, context, stream, token);
1655
1656
// There should be multiple lock calls: one initial lock and one eager re-lock after session creation.
1657
// The eager lock should contain the updated repo information.
1658
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1659
const lockCalls = allCalls.filter(
1660
call => call[1].some((update: any) => update.optionId === 'repository' && update.value?.locked === true)
1661
);
1662
// Expect at least 2 lock calls (initial lock + eager re-lock after session creation)
1663
expect(lockCalls.length).toBeGreaterThanOrEqual(2);
1664
1665
// The last lock call should have the accurate repo information
1666
const lastLockCall = lockCalls[lockCalls.length - 1];
1667
const lastLockUpdate = lastLockCall[1].find(
1668
(update: any) => update.optionId === 'repository' && update.value?.locked === true
1669
);
1670
expect(lastLockUpdate.value.name).toBe('myrepo');
1671
expect(lastLockUpdate.value.id).toBe(repoUri.fsPath);
1672
});
1673
1674
it('locks with submodule/archive icon for submodule repositories', async () => {
1675
const sessionId = 'untitled:temp-submodule';
1676
const repoUri = Uri.file(`${sep}workspace${sep}submodule-repo`);
1677
const mockGetFolderRepository = vi.fn(async () => ({
1678
folder: repoUri,
1679
repository: { rootUri: repoUri, kind: 'submodule' } as unknown as RepoContext,
1680
trusted: true
1681
}));
1682
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1683
1684
const request = new TestChatRequest('Say hi');
1685
const context = createChatContext(sessionId, true, request);
1686
const stream = new MockChatResponseStream();
1687
const token = disposables.add(new CancellationTokenSource()).token;
1688
1689
await participant.createHandler()(request, context, stream, token);
1690
1691
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1692
const repoLockUpdate = allCalls.flatMap(call => call[1]).find(
1693
(update: any) => update.optionId === 'repository' && update.value?.locked === true
1694
);
1695
// Submodule repositories should use 'archive' icon (not 'repo')
1696
expect(repoLockUpdate.value.icon.id).toBe('archive');
1697
expect(repoLockUpdate.value.name).toBe('submodule-repo');
1698
});
1699
1700
it('locks branch option alongside repository option when branch is selected', async () => {
1701
const sessionId = 'untitled:temp-branch-lock';
1702
const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);
1703
const mockGetFolderRepository = vi.fn(async () => ({
1704
folder: repoUri,
1705
repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,
1706
trusted: true
1707
}));
1708
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1709
1710
// Simulate branch selection via initial options
1711
const request = new TestChatRequest('Say hi');
1712
const context = createChatContext(sessionId, true, request);
1713
(context.chatSessionContext as any).initialSessionOptions = [
1714
{ optionId: 'branch', value: 'feature-branch' }
1715
];
1716
const stream = new MockChatResponseStream();
1717
const token = disposables.add(new CancellationTokenSource()).token;
1718
1719
await participant.createHandler()(request, context, stream, token);
1720
1721
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1722
// Find a lock call that includes both repo and branch locking
1723
const branchLockCalls = allCalls.filter(
1724
call => call[1].some((update: any) => update.optionId === 'branch' && update.value?.locked === true)
1725
);
1726
expect(branchLockCalls.length).toBeGreaterThan(0);
1727
1728
const branchLockUpdate = branchLockCalls.flatMap(call => call[1]).find(
1729
(update: any) => update.optionId === 'branch' && update.value?.locked === true
1730
);
1731
expect(branchLockUpdate.value.name).toBe('feature-branch');
1732
expect(branchLockUpdate.value.icon.id).toBe('git-branch');
1733
});
1734
1735
it('does not lock branch option when no branch is selected', async () => {
1736
const sessionId = 'untitled:temp-no-branch-lock';
1737
const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);
1738
const mockGetFolderRepository = vi.fn(async () => ({
1739
folder: repoUri,
1740
repository: { rootUri: repoUri, kind: 'repository' } as unknown as RepoContext,
1741
trusted: true
1742
}));
1743
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1744
1745
const request = new TestChatRequest('Say hi');
1746
const context = createChatContext(sessionId, true, request);
1747
const stream = new MockChatResponseStream();
1748
const token = disposables.add(new CancellationTokenSource()).token;
1749
1750
await participant.createHandler()(request, context, stream, token);
1751
1752
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1753
const branchLockCalls = allCalls.filter(
1754
call => call[1].some((update: any) => update.optionId === 'branch')
1755
);
1756
expect(branchLockCalls.length).toBe(0);
1757
});
1758
1759
it('unlocks branch option alongside repository option when trust is denied', async () => {
1760
const sessionId = 'untitled:temp-branch-unlock';
1761
const mockGetFolderRepository = vi.fn(async () => ({
1762
trusted: false,
1763
folder: Uri.file(`${sep}workspace`)
1764
}));
1765
(folderRepositoryManager.getFolderRepository as any) = mockGetFolderRepository;
1766
const mockInitializeFolderRepository = vi.fn(async () => ({
1767
trusted: false,
1768
folder: Uri.file(`${sep}workspace`),
1769
repository: undefined,
1770
worktree: undefined,
1771
worktreeProperties: undefined
1772
}));
1773
(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;
1774
1775
// Simulate having a branch selected before running
1776
const request = new TestChatRequest('Say hi');
1777
const context = createChatContext(sessionId, true, request);
1778
(context.chatSessionContext as any).initialSessionOptions = [
1779
{ optionId: 'branch', value: 'my-branch' }
1780
];
1781
const stream = new MockChatResponseStream();
1782
const token = disposables.add(new CancellationTokenSource()).token;
1783
1784
await participant.createHandler()(request, context, stream, token);
1785
1786
const allCalls = (contentProvider.notifySessionOptionsChange as unknown as ReturnType<typeof vi.fn>).mock.calls;
1787
// Find unlock calls (value is string, not an object with locked flag)
1788
const branchUnlockCalls = allCalls.filter(
1789
call => call[1].some((update: any) => update.optionId === 'branch' && typeof update.value === 'string')
1790
);
1791
expect(branchUnlockCalls.length).toBeGreaterThan(0);
1792
});
1793
1794
it('passes branch to initializeFolderRepository when branch is set via initial options', async () => {
1795
const sessionId = 'untitled:temp-branch-pass';
1796
const repoUri = Uri.file(`${sep}workspace${sep}myrepo`);
1797
const mockInitializeFolderRepository = vi.fn(async () => ({
1798
folder: repoUri,
1799
repository: undefined,
1800
worktree: undefined,
1801
worktreeProperties: undefined,
1802
trusted: true,
1803
cancelled: false,
1804
}));
1805
(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;
1806
1807
const request = new TestChatRequest('Say hi');
1808
const context = createChatContext(sessionId, true, request);
1809
// Simulate branch being pre-selected (e.g. by provideChatSessionContent auto-selecting default branch)
1810
(context.chatSessionContext as any).initialSessionOptions = [
1811
{ optionId: 'branch', value: 'feature-branch' }
1812
];
1813
const stream = new MockChatResponseStream();
1814
const token = disposables.add(new CancellationTokenSource()).token;
1815
1816
await participant.createHandler()(request, context, stream, token);
1817
1818
expect(mockInitializeFolderRepository).toHaveBeenCalled();
1819
const [, options] = mockInitializeFolderRepository.mock.calls[0] as unknown as Parameters<typeof folderRepositoryManager.initializeFolderRepository>;
1820
expect(options.branch).toBe('feature-branch');
1821
});
1822
1823
it('passes undefined branch to initializeFolderRepository when no branch is selected', async () => {
1824
const sessionId = 'untitled:temp-no-branch-pass';
1825
const mockInitializeFolderRepository = vi.fn(async () => ({
1826
folder: Uri.file(`${sep}workspace`),
1827
repository: undefined,
1828
worktree: undefined,
1829
worktreeProperties: undefined,
1830
trusted: true,
1831
cancelled: false,
1832
}));
1833
(folderRepositoryManager.initializeFolderRepository as any) = mockInitializeFolderRepository;
1834
1835
const request = new TestChatRequest('Say hi');
1836
const context = createChatContext(sessionId, true, request);
1837
// No initialSessionOptions with branch
1838
const stream = new MockChatResponseStream();
1839
const token = disposables.add(new CancellationTokenSource()).token;
1840
1841
await participant.createHandler()(request, context, stream, token);
1842
1843
expect(mockInitializeFolderRepository).toHaveBeenCalled();
1844
const [, options] = mockInitializeFolderRepository.mock.calls[0] as unknown as Parameters<typeof folderRepositoryManager.initializeFolderRepository>;
1845
expect(options.branch).toBeUndefined();
1846
});
1847
});
1848
1849
describe('chatSessionContext lost workaround (core bug)', () => {
1850
// Full end-to-end tests for the delegation → executeCommand → workaround round-trip.
1851
//
1852
// When delegating from another chat:
1853
// 1. handleRequest is called with chatSessionContext=undefined → triggers handleDelegationFromAnotherChat
1854
// 2. createCLISessionAndSubmitRequest creates a session, stores prompt in contextForRequest,
1855
// then calls vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', ...)
1856
// 3. VS Code core opens the new session and calls handleRequest again with the copilotcli:// resource,
1857
// but due to a core bug chatSessionContext may be undefined
1858
// 4. The workaround detects the copilotcli:// scheme + stored contextForRequest data and
1859
// reconstructs a synthetic chatSessionContext, so the session is reused with the stored prompt.
1860
1861
let callbackDone: Promise<void> | undefined;
1862
1863
beforeEach(() => {
1864
callbackDone = undefined;
1865
// Override the default round-trip behavior to simulate VS Code core
1866
// calling handleRequest again with the copilotcli:// resource but with chatSessionContext lost.
1867
mockExecuteCommand.mockImplementation(async (command: string, args: any) => {
1868
if (command === 'workbench.action.chat.openSessionWithPrompt.copilotcli') {
1869
// Simulate VS Code core: it opens the session and fires handleRequest,
1870
// but the core bug means chatSessionContext is undefined.
1871
const callbackRequest = new TestChatRequest(args.prompt);
1872
callbackRequest.sessionResource = args.resource;
1873
const callbackContext = { chatSessionContext: undefined } as vscode.ChatContext;
1874
const callbackStream = new MockChatResponseStream();
1875
const callbackToken = disposables.add(new CancellationTokenSource()).token;
1876
const result = participant.createHandler()(callbackRequest, callbackContext, callbackStream, callbackToken);
1877
callbackDone = !result ? Promise.resolve() : Promise.resolve(result).then(() => {/** */ });
1878
await callbackDone;
1879
}
1880
});
1881
});
1882
1883
it('full delegation round-trip: executeCommand triggers callback that uses workaround to reconstruct context and reuse session', async () => {
1884
// Start delegation: call handleRequest with no chatSessionContext.
1885
// This triggers handleDelegationFromAnotherChat → createCLISessionAndSubmitRequest
1886
// which creates a session, stores prompt/attachments, calls executeCommand.
1887
// The mock executeCommand simulates VS Code calling handleRequest again with
1888
// the copilotcli:// resource but chatSessionContext=undefined (the core bug).
1889
// The workaround reconstructs context and reuses the session.
1890
const request = new TestChatRequest('Build feature X');
1891
const context = { chatSessionContext: undefined } as vscode.ChatContext;
1892
const stream = new MockChatResponseStream();
1893
const token = disposables.add(new CancellationTokenSource()).token;
1894
1895
await participant.createHandler()(request, context, stream, token);
1896
await callbackDone;
1897
1898
// executeCommand should have been called with the correct command and args
1899
expect(mockExecuteCommand).toHaveBeenCalledWith(
1900
'workbench.action.chat.openSessionWithPrompt.copilotcli',
1901
expect.objectContaining({
1902
resource: expect.objectContaining({ scheme: 'copilotcli' }),
1903
prompt: 'Build feature X',
1904
})
1905
);
1906
1907
// Only one session should have been created (the delegation creates it,
1908
// and the callback reuses it via the workaround — no second session).
1909
expect(cliSessions.length).toBe(1);
1910
1911
// The session's handleRequest should have been called exactly once,
1912
// using the stored prompt from contextForRequest (set during delegation).
1913
expect(cliSessions[0].requests.length).toBe(1);
1914
expect(cliSessions[0].requests[0].input).toEqual(
1915
expect.objectContaining({ prompt: expect.stringContaining('Build feature X') })
1916
);
1917
1918
// contextForRequest should have been consumed (cleaned up after use)
1919
expect((participant as any).contextForRequest.size).toBe(0);
1920
});
1921
1922
it('does not attempt workaround for non-copilotcli resource and proceeds with normal delegation', async () => {
1923
const request = new TestChatRequest('do some work');
1924
// Default sessionResource is test://session/... (not copilotcli scheme),
1925
// so the workaround check at the top of handleRequest is skipped entirely.
1926
const context = { chatSessionContext: undefined } as vscode.ChatContext;
1927
const stream = new MockChatResponseStream();
1928
const token = disposables.add(new CancellationTokenSource()).token;
1929
1930
await participant.createHandler()(request, context, stream, token);
1931
await callbackDone;
1932
1933
// A session should have been created via the delegation path
1934
expect(cliSessions.length).toBe(1);
1935
expect(cliSessions[0].requests.length).toBe(1);
1936
expect(cliSessions[0].requests[0].input).toEqual(
1937
expect.objectContaining({ prompt: expect.stringContaining('do some work') })
1938
);
1939
});
1940
});
1941
1942
describe('agent tool references via modeInstructions2', () => {
1943
class MockCopilotCLIAgentsWithCustomAgent extends NullCopilotCLIAgents {
1944
constructor(private readonly agentTools: string[] | null) {
1945
super();
1946
}
1947
override resolveAgent(agentId: string): Promise<SweCustomAgent | undefined> {
1948
if (agentId === 'custom-agent') {
1949
return Promise.resolve({
1950
name: 'custom-agent',
1951
displayName: 'Custom Agent',
1952
description: 'A test agent',
1953
tools: this.agentTools,
1954
prompt: async () => 'System prompt',
1955
disableModelInvocation: false,
1956
});
1957
}
1958
return Promise.resolve(undefined);
1959
}
1960
}
1961
1962
function makeParticipantWithAgents(agents: MockCopilotCLIAgentsWithCustomAgent): CopilotCLIChatSessionParticipant {
1963
const nullDelegationService = new class extends mock<IChatDelegationSummaryService>() {
1964
override async summarize(_context: vscode.ChatContext, _token: vscode.CancellationToken): Promise<string | undefined> {
1965
return undefined;
1966
}
1967
}();
1968
return new CopilotCLIChatSessionParticipant(
1969
contentProvider,
1970
promptResolver,
1971
itemProvider,
1972
cloudProvider,
1973
undefined,
1974
git,
1975
models as unknown as ICopilotCLIModels,
1976
agents,
1977
sessionService,
1978
worktree,
1979
worktreeCheckpointService,
1980
workspaceFolderService,
1981
telemetry,
1982
logService,
1983
disposables.add(new MockPromptsService()),
1984
nullDelegationService,
1985
folderRepositoryManager,
1986
configurationService,
1987
sdk,
1988
new MockChatSessionMetadataStore(),
1989
customSessionTitleService,
1990
new (mock<IOctoKitService>())(),
1991
);
1992
}
1993
1994
it('preserves agent tools when modeInstructions2 has no tool references', async () => {
1995
const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(['original-tool']));
1996
const createSessionSpy = vi.spyOn(sessionService, 'createSession');
1997
1998
const request = new TestChatRequest('Do something');
1999
(request as any).modeInstructions2 = { name: 'custom-agent', content: 'agent content' };
2000
const context = createChatContext('temp-new', true, request);
2001
const stream = new MockChatResponseStream();
2002
const token = disposables.add(new CancellationTokenSource()).token;
2003
2004
await agentParticipant.createHandler()(request, context, stream, token);
2005
2006
expect(createSessionSpy).toHaveBeenCalled();
2007
const { agent } = createSessionSpy.mock.calls[0][0];
2008
expect(agent?.tools).toEqual(['original-tool']);
2009
});
2010
2011
it('overrides agent tools when modeInstructions2 provides tool references', async () => {
2012
const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(['original-tool']));
2013
const createSessionSpy = vi.spyOn(sessionService, 'createSession');
2014
2015
const request = new TestChatRequest('Do something');
2016
(request as any).modeInstructions2 = {
2017
name: 'custom-agent',
2018
content: 'agent content',
2019
toolReferences: [{ name: 'override-tool-1' }, { name: 'override-tool-2' }],
2020
};
2021
const context = createChatContext('temp-new', true, request);
2022
const stream = new MockChatResponseStream();
2023
const token = disposables.add(new CancellationTokenSource()).token;
2024
2025
await agentParticipant.createHandler()(request, context, stream, token);
2026
2027
expect(createSessionSpy).toHaveBeenCalled();
2028
const { agent } = createSessionSpy.mock.calls[0][0];
2029
expect(agent?.tools).toEqual(['override-tool-1', 'override-tool-2']);
2030
});
2031
2032
it('preserves null tools when modeInstructions2 has no tool references', async () => {
2033
const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(null));
2034
const createSessionSpy = vi.spyOn(sessionService, 'createSession');
2035
2036
const request = new TestChatRequest('Do something');
2037
(request as any).modeInstructions2 = { name: 'custom-agent', content: 'agent content' };
2038
const context = createChatContext('temp-new', true, request);
2039
const stream = new MockChatResponseStream();
2040
const token = disposables.add(new CancellationTokenSource()).token;
2041
2042
await agentParticipant.createHandler()(request, context, stream, token);
2043
2044
expect(createSessionSpy).toHaveBeenCalled();
2045
const { agent } = createSessionSpy.mock.calls[0][0];
2046
expect(agent?.tools).toBeNull();
2047
});
2048
2049
it('does not use session agent when no modeInstructions2 is provided', async () => {
2050
const agentParticipant = makeParticipantWithAgents(new MockCopilotCLIAgentsWithCustomAgent(['tool-a']));
2051
const createSessionSpy = vi.spyOn(sessionService, 'createSession');
2052
2053
const request = new TestChatRequest('Do something');
2054
// No modeInstructions2 set — agent should be undefined regardless of session state
2055
const context = createChatContext('temp-new', true, request);
2056
const stream = new MockChatResponseStream();
2057
const token = disposables.add(new CancellationTokenSource()).token;
2058
2059
await agentParticipant.createHandler()(request, context, stream, token);
2060
2061
expect(createSessionSpy).toHaveBeenCalled();
2062
const { agent } = createSessionSpy.mock.calls[0][0];
2063
expect(agent).toBeUndefined();
2064
});
2065
});
2066
2067
describe('PR detection with retry', () => {
2068
let octoKitService: IOctoKitService;
2069
2070
const v2WorktreeProperties: ChatSessionWorktreePropertiesV2 = {
2071
version: 2,
2072
baseCommit: 'abc123',
2073
branchName: 'copilot/test-branch',
2074
baseBranchName: 'main',
2075
repositoryPath: `${sep}repo`,
2076
worktreePath: `${sep}worktree`,
2077
};
2078
2079
const repoContext: RepoContext = {
2080
rootUri: Uri.file(`${sep}repo`),
2081
kind: 'repository',
2082
remotes: ['origin'],
2083
remoteFetchUrls: ['https://github.com/testowner/testrepo.git'],
2084
} as unknown as RepoContext;
2085
2086
beforeEach(() => {
2087
vi.useFakeTimers();
2088
octoKitService = {
2089
findPullRequestByHeadBranch: vi.fn(async () => undefined),
2090
} as unknown as IOctoKitService;
2091
2092
// Set up folder & git repo so session creation succeeds with worktree isolation
2093
folderRepositoryManager.setNewSessionFolder('untitled:pr-test', Uri.file(`${sep}repo`));
2094
git.setRepo(repoContext);
2095
(worktree.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(v2WorktreeProperties);
2096
// After session creation, getWorktreeProperties returns v2 for any session
2097
(worktree.getWorktreeProperties as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(v2WorktreeProperties);
2098
TestCopilotCLISession.statusOverride = vscode.ChatSessionStatus.Completed;
2099
2100
// Recreate participant with the controllable octoKitService
2101
participant = new CopilotCLIChatSessionParticipant(
2102
contentProvider,
2103
promptResolver,
2104
itemProvider,
2105
cloudProvider,
2106
undefined,
2107
git,
2108
models as unknown as ICopilotCLIModels,
2109
new NullCopilotCLIAgents(),
2110
sessionService,
2111
worktree,
2112
worktreeCheckpointService,
2113
workspaceFolderService,
2114
telemetry,
2115
logService,
2116
disposables.add(new MockPromptsService()),
2117
new (mock<IChatDelegationSummaryService>())(),
2118
folderRepositoryManager,
2119
configurationService,
2120
sdk,
2121
new MockChatSessionMetadataStore(),
2122
customSessionTitleService,
2123
octoKitService,
2124
);
2125
});
2126
2127
afterEach(() => {
2128
vi.useRealTimers();
2129
});
2130
2131
it('retries PR detection with exponential backoff and succeeds on second attempt', async () => {
2132
const findPr = octoKitService.findPullRequestByHeadBranch as ReturnType<typeof vi.fn>;
2133
findPr
2134
.mockResolvedValueOnce(undefined) // attempt 1: not found
2135
.mockResolvedValueOnce({ url: 'https://github.com/testowner/testrepo/pull/42', state: 'OPEN' }); // attempt 2: found
2136
2137
const request = new TestChatRequest('Create a PR');
2138
const context = createChatContext('untitled:pr-test', true, request);
2139
const stream = new MockChatResponseStream();
2140
const token = disposables.add(new CancellationTokenSource()).token;
2141
2142
const handlerPromise = participant.createHandler()(request, context, stream, token);
2143
await vi.runAllTimersAsync();
2144
await handlerPromise;
2145
2146
// Should have been called twice (after 2s delay, then after 4s delay)
2147
expect(findPr).toHaveBeenCalledTimes(2);
2148
// Should have persisted the PR URL and state
2149
expect(worktree.setWorktreeProperties).toHaveBeenCalledWith(
2150
expect.any(String),
2151
expect.objectContaining({ pullRequestUrl: 'https://github.com/testowner/testrepo/pull/42', pullRequestState: 'open' })
2152
);
2153
});
2154
2155
it('stops retrying once all attempts are exhausted', async () => {
2156
const findPr = octoKitService.findPullRequestByHeadBranch as ReturnType<typeof vi.fn>;
2157
findPr.mockResolvedValue(undefined); // always returns not found
2158
2159
const request = new TestChatRequest('Create something');
2160
const context = createChatContext('untitled:pr-test', true, request);
2161
const stream = new MockChatResponseStream();
2162
const token = disposables.add(new CancellationTokenSource()).token;
2163
2164
const handlerPromise = participant.createHandler()(request, context, stream, token);
2165
await vi.runAllTimersAsync();
2166
await handlerPromise;
2167
2168
// 5 attempts total (after 2s, 4s, 8s, 16s, and 32s delays)
2169
expect(findPr).toHaveBeenCalledTimes(5);
2170
// Should NOT have persisted any PR URL since all attempts failed
2171
const setPropsCallsWithPrUrl = (worktree.setWorktreeProperties as ReturnType<typeof vi.fn>).mock.calls
2172
.filter((args: unknown[]) => (args[1] as { pullRequestUrl?: string })?.pullRequestUrl !== undefined);
2173
expect(setPropsCallsWithPrUrl).toHaveLength(0);
2174
});
2175
2176
it('skips retry when session already has createdPullRequestUrl', async () => {
2177
const findPr = octoKitService.findPullRequestByHeadBranch as ReturnType<typeof vi.fn>;
2178
2179
// Make the session report a PR URL directly
2180
TestCopilotCLISession.handleRequestHook = vi.fn(async () => {
2181
const session = cliSessions[cliSessions.length - 1];
2182
(session as any)._createdPullRequestUrl = 'https://github.com/testowner/testrepo/pull/99';
2183
});
2184
2185
const request = new TestChatRequest('Create a PR via MCP');
2186
const context = createChatContext('untitled:pr-test', true, request);
2187
const stream = new MockChatResponseStream();
2188
const token = disposables.add(new CancellationTokenSource()).token;
2189
2190
const handlerPromise = participant.createHandler()(request, context, stream, token);
2191
await vi.runAllTimersAsync();
2192
await handlerPromise;
2193
2194
// Should NOT have called the GitHub API since session had the URL
2195
expect(findPr).not.toHaveBeenCalled();
2196
// Should have persisted the session's PR URL
2197
expect(worktree.setWorktreeProperties).toHaveBeenCalledWith(
2198
expect.any(String),
2199
expect.objectContaining({ pullRequestUrl: 'https://github.com/testowner/testrepo/pull/99' })
2200
);
2201
});
2202
});
2203
2204
describe('sdkToUntitledUriMapping lifecycle', () => {
2205
it('populates sdkToUntitledUriMapping during request and cleans up after swap', async () => {
2206
folderRepositoryManager.setNewSessionFolder('untitled:mapping-test', Uri.file(`${sep}workspace`));
2207
2208
let capturedSdkSessionId: string | undefined;
2209
let mappingExistedDuringRequest = false;
2210
TestCopilotCLISession.handleRequestHook = vi.fn(async () => {
2211
const session = cliSessions[cliSessions.length - 1];
2212
capturedSdkSessionId = session.sessionId;
2213
mappingExistedDuringRequest = itemProvider.sdkToUntitledUriMapping.has(capturedSdkSessionId);
2214
});
2215
2216
const request = new TestChatRequest('Hello');
2217
const context = createChatContext('untitled:mapping-test', true, request);
2218
const stream = new MockChatResponseStream();
2219
const token = disposables.add(new CancellationTokenSource()).token;
2220
2221
await participant.createHandler()(request, context, stream, token);
2222
2223
// Mapping should have existed during the request
2224
expect(mappingExistedDuringRequest).toBe(true);
2225
// After the request completes and the session is swapped, the mapping should be cleaned up
2226
expect(itemProvider.sdkToUntitledUriMapping.has(capturedSdkSessionId!)).toBe(false);
2227
});
2228
2229
it('maps SDK session ID to the original untitled URI', async () => {
2230
folderRepositoryManager.setNewSessionFolder('untitled:uri-check', Uri.file(`${sep}workspace`));
2231
2232
let capturedUri: Uri | undefined;
2233
TestCopilotCLISession.handleRequestHook = vi.fn(async () => {
2234
const session = cliSessions[cliSessions.length - 1];
2235
capturedUri = itemProvider.sdkToUntitledUriMapping.get(session.sessionId);
2236
});
2237
2238
const request = new TestChatRequest('Hello');
2239
const context = createChatContext('untitled:uri-check', true, request);
2240
const stream = new MockChatResponseStream();
2241
const token = disposables.add(new CancellationTokenSource()).token;
2242
2243
await participant.createHandler()(request, context, stream, token);
2244
2245
expect(capturedUri).toBeDefined();
2246
expect(capturedUri!.scheme).toBe('copilotcli');
2247
expect(capturedUri!.path).toBe('/untitled:uri-check');
2248
});
2249
2250
it('does not populate sdkToUntitledUriMapping for existing sessions', async () => {
2251
const sessionId = 'existing-mapping-test';
2252
const sdkSession = new MockCliSdkSession(sessionId, new Date());
2253
manager.sessions.set(sessionId, sdkSession);
2254
2255
const request = new TestChatRequest('Continue');
2256
const context = createChatContext(sessionId, false, request);
2257
const stream = new MockChatResponseStream();
2258
const token = disposables.add(new CancellationTokenSource()).token;
2259
2260
await participant.createHandler()(request, context, stream, token);
2261
2262
expect(cliSessions.length).toBe(1);
2263
// Should NOT have set sdkToUntitledUriMapping for existing sessions
2264
expect(itemProvider.sdkToUntitledUriMapping.size).toBe(0);
2265
});
2266
});
2267
});
2268
2269