Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/sessions/contrib/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { Codicon } from '../../../../../base/common/codicons.js';
8
import { Emitter, Event } from '../../../../../base/common/event.js';
9
import { DisposableStore, toDisposable } from '../../../../../base/common/lifecycle.js';
10
import { URI } from '../../../../../base/common/uri.js';
11
import { mock } from '../../../../../base/test/common/mock.js';
12
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
13
import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js';
14
import { TestConfigurationService } from '../../../../../platform/configuration/test/common/testConfigurationService.js';
15
import { ICommandService } from '../../../../../platform/commands/common/commands.js';
16
import { IDialogService, IFileDialogService } from '../../../../../platform/dialogs/common/dialogs.js';
17
import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js';
18
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
19
import { TestStorageService } from '../../../../../workbench/test/common/workbenchTestServices.js';
20
import { IStorageService } from '../../../../../platform/storage/common/storage.js';
21
import { IAgentSession, IAgentSessionsModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsModel.js';
22
import { IAgentSessionsService } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessionsService.js';
23
import { AgentSessionProviders } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js';
24
import { IChatService, ChatSendResult, IChatSendRequestData } from '../../../../../workbench/contrib/chat/common/chatService/chatService.js';
25
import { ChatSessionStatus, IChatSessionItem, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js';
26
import { IChatWidget, IChatWidgetService } from '../../../../../workbench/contrib/chat/browser/chat.js';
27
import { ILanguageModelsService } from '../../../../../workbench/contrib/chat/common/languageModels.js';
28
import { ILanguageModelToolsService } from '../../../../../workbench/contrib/chat/common/tools/languageModelToolsService.js';
29
import { IChatResponseModel } from '../../../../../workbench/contrib/chat/common/model/chatModel.js';
30
import { IChatAgentData } from '../../../../../workbench/contrib/chat/common/participants/chatAgents.js';
31
import { IGitService } from '../../../../../workbench/contrib/git/common/gitService.js';
32
import { ISessionChangeEvent } from '../../../../services/sessions/common/sessionsProvider.js';
33
import { ClaudeCodeSessionType, CopilotCLISessionType, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../services/sessions/common/session.js';
34
import { CLAUDE_CODE_ENABLED_SETTING, CopilotChatSessionsProvider, COPILOT_PROVIDER_ID } from '../../browser/copilotChatSessionsProvider.js';
35
import { ILogService, NullLogService } from '../../../../../platform/log/common/log.js';
36
import { ILabelService } from '../../../../../platform/label/common/label.js';
37
38
// ---- Helpers ----------------------------------------------------------------
39
40
function createMockAgentSession(resource: URI, opts?: {
41
providerType?: string;
42
title?: string;
43
archived?: boolean;
44
read?: boolean;
45
createdAt?: number;
46
metadata?: Record<string, unknown>;
47
}): IAgentSession {
48
const providerType = opts?.providerType ?? AgentSessionProviders.Background;
49
let archived = opts?.archived ?? false;
50
let read = opts?.read ?? true;
51
return new class extends mock<IAgentSession>() {
52
override readonly resource = resource;
53
override readonly providerType = providerType;
54
override readonly providerLabel = 'Copilot';
55
override readonly label = opts?.title ?? 'Test Session';
56
override readonly status = ChatSessionStatus.Completed;
57
override readonly icon = Codicon.copilot;
58
override readonly timing = { created: opts?.createdAt ?? Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined };
59
override readonly metadata = opts?.metadata ?? { repositoryPath: '/test/repo' };
60
override isArchived(): boolean { return archived; }
61
override setArchived(value: boolean): void { archived = value; }
62
override isPinned(): boolean { return false; }
63
override setPinned(): void { }
64
override isRead(): boolean { return read; }
65
override isMarkedUnread(): boolean { return false; }
66
override setRead(value: boolean): void { read = value; }
67
}();
68
}
69
70
// ---- Mock Agent Sessions Service --------------------------------------------
71
72
class MockAgentSessionsModel {
73
private readonly _sessions: IAgentSession[] = [];
74
private readonly _onDidChangeSessions = new Emitter<void>();
75
readonly onDidChangeSessions = this._onDidChangeSessions.event;
76
readonly onWillResolve = Event.None;
77
readonly onDidResolve = Event.None;
78
readonly onDidChangeSessionArchivedState = Event.None;
79
readonly resolved = true;
80
81
get sessions(): IAgentSession[] { return [...this._sessions]; }
82
83
getSession(resource: URI): IAgentSession | undefined {
84
return this._sessions.find(s => s.resource.toString() === resource.toString());
85
}
86
87
addSession(session: IAgentSession): void {
88
this._sessions.push(session);
89
this._onDidChangeSessions.fire();
90
}
91
92
removeSession(resource: URI): void {
93
const idx = this._sessions.findIndex(s => s.resource.toString() === resource.toString());
94
if (idx !== -1) {
95
this._sessions.splice(idx, 1);
96
this._onDidChangeSessions.fire();
97
}
98
}
99
100
async resolve(): Promise<void> { }
101
102
dispose(): void {
103
this._onDidChangeSessions.dispose();
104
}
105
}
106
107
// ---- Provider factory -------------------------------------------------------
108
109
function createProvider(
110
disposables: DisposableStore,
111
model: MockAgentSessionsModel,
112
opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean },
113
): CopilotChatSessionsProvider {
114
return createProviderWithConfig(disposables, model, opts).provider;
115
}
116
117
function createProviderWithConfig(
118
disposables: DisposableStore,
119
model: MockAgentSessionsModel,
120
opts?: { multiChatEnabled?: boolean; claudeEnabled?: boolean },
121
): { provider: CopilotChatSessionsProvider; configService: TestConfigurationService } {
122
const instantiationService = disposables.add(new TestInstantiationService());
123
124
const configService = new TestConfigurationService();
125
configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', opts?.multiChatEnabled ?? true);
126
configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? true);
127
128
instantiationService.stub(IConfigurationService, configService);
129
instantiationService.stub(IStorageService, disposables.add(new TestStorageService()));
130
instantiationService.stub(IFileDialogService, {});
131
instantiationService.stub(IDialogService, {
132
confirm: async () => ({ confirmed: true }),
133
});
134
instantiationService.stub(ICommandService, {
135
executeCommand: async (_id: string, ...args: any[]) => {
136
// Simulate 'agents.github.copilot.cli.deleteSessions' removing sessions
137
const items = args[0];
138
if (Array.isArray(items)) {
139
for (const item of items) {
140
if (item?.resource) {
141
model.removeSession(item.resource);
142
}
143
}
144
} else if (items?.resource) {
145
model.removeSession(items.resource);
146
}
147
return undefined;
148
},
149
});
150
instantiationService.stub(IAgentSessionsService, {
151
model: model as unknown as IAgentSessionsModel,
152
onDidChangeSessionArchivedState: Event.None,
153
getSession: (resource: URI) => model.getSession(resource),
154
});
155
instantiationService.stub(IChatSessionsService, {
156
getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }),
157
getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),
158
onDidCommitSession: Event.None,
159
updateSessionOptions: () => true,
160
setSessionOption: () => true,
161
getSessionOption: () => undefined,
162
onDidChangeOptionGroups: Event.None,
163
});
164
instantiationService.stub(IChatService, {
165
acquireOrLoadSession: async () => undefined,
166
sendRequest: async (): Promise<ChatSendResult> => ({ kind: 'sent' as const, data: {} as IChatSendRequestData }),
167
removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); },
168
setChatSessionTitle: () => { },
169
});
170
instantiationService.stub(IChatWidgetService, {
171
openSession: async () => undefined,
172
lastFocusedWidget: undefined,
173
onDidChangeFocusedSession: Event.None,
174
});
175
instantiationService.stub(ILanguageModelsService, {
176
lookupLanguageModel: () => undefined,
177
});
178
instantiationService.stub(ILanguageModelToolsService, {
179
toToolReferences: () => [],
180
});
181
// Stub IInstantiationService so provider can use createInstance for CopilotCLISession
182
instantiationService.stub(IInstantiationService, instantiationService);
183
instantiationService.stub(ILabelService, {
184
getUriLabel: (uri: URI) => uri.path,
185
});
186
187
const provider = disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider));
188
return { provider, configService };
189
}
190
191
// ---- Provider factory for send/cancel tests ---------------------------------
192
193
/**
194
* Creates a provider suitable for testing sendChat flows. Stubs all services
195
* needed by CopilotCLISession and _sendFirstChat, including IGitService and a
196
* non-null IChatWidget mock.
197
*
198
* The caller can pass a custom `sendRequest` implementation to control the
199
* lifecycle of the in-flight request.
200
*/
201
function createProviderForSendTests(
202
disposables: DisposableStore,
203
model: MockAgentSessionsModel,
204
sendRequest: () => Promise<ChatSendResult>,
205
opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; claudeEnabled?: boolean; createNewChatSessionItem?: IChatSessionsService['createNewChatSessionItem'] },
206
): CopilotChatSessionsProvider {
207
const instantiationService = disposables.add(new TestInstantiationService());
208
209
const configService = new TestConfigurationService();
210
configService.setUserConfiguration('sessions.github.copilot.multiChatSessions', true);
211
configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, opts?.claudeEnabled ?? true);
212
213
instantiationService.stub(ILogService, NullLogService);
214
instantiationService.stub(IConfigurationService, configService);
215
instantiationService.stub(IStorageService, disposables.add(new TestStorageService()));
216
instantiationService.stub(IFileDialogService, {});
217
instantiationService.stub(IDialogService, {
218
confirm: async () => ({ confirmed: true }),
219
});
220
instantiationService.stub(ICommandService, { executeCommand: async () => undefined });
221
instantiationService.stub(IAgentSessionsService, {
222
model: model as unknown as IAgentSessionsModel,
223
onDidChangeSessionArchivedState: Event.None,
224
getSession: (resource: URI) => model.getSession(resource),
225
});
226
instantiationService.stub(IChatSessionsService, {
227
getChatSessionContribution: () => ({ type: 'test-copilot', name: 'test', displayName: 'Test', description: 'test', icon: undefined }),
228
getOrCreateChatSession: async () => ({ onWillDispose: () => ({ dispose() { } }), sessionResource: URI.from({ scheme: 'test' }), history: [], dispose() { } }),
229
onDidCommitSession: opts?.onDidCommitSession ?? Event.None,
230
updateSessionOptions: () => true,
231
setSessionOption: () => true,
232
getSessionOption: () => undefined,
233
onDidChangeOptionGroups: Event.None,
234
createNewChatSessionItem: opts?.createNewChatSessionItem ?? (async () => undefined),
235
});
236
instantiationService.stub(IChatService, {
237
acquireOrLoadSession: async () => undefined,
238
sendRequest: sendRequest,
239
removeHistoryEntry: async (resource: URI) => { model.removeSession(resource); },
240
setChatSessionTitle: () => { },
241
});
242
instantiationService.stub(IChatWidgetService, {
243
openSession: async () => new class extends mock<IChatWidget>() {
244
override input = new class extends mock<IChatWidget['input']>() {
245
override setPermissionLevel = () => { };
246
}();
247
}(),
248
lastFocusedWidget: undefined,
249
onDidChangeFocusedSession: Event.None,
250
});
251
instantiationService.stub(ILanguageModelsService, { lookupLanguageModel: () => undefined });
252
instantiationService.stub(ILanguageModelToolsService, { toToolReferences: () => [] });
253
instantiationService.stub(IGitService, { openRepository: async () => undefined });
254
instantiationService.stub(IInstantiationService, instantiationService);
255
instantiationService.stub(ILabelService, {
256
getUriLabel: (uri: URI) => uri.path,
257
});
258
259
return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider));
260
}
261
262
suite('CopilotChatSessionsProvider', () => {
263
const disposables = new DisposableStore();
264
let model: MockAgentSessionsModel;
265
266
setup(() => {
267
model = new MockAgentSessionsModel();
268
disposables.add(toDisposable(() => model.dispose()));
269
});
270
271
teardown(() => {
272
disposables.clear();
273
});
274
275
ensureNoDisposablesAreLeakedInTestSuite();
276
277
// ---- Provider identity -------
278
279
test('has correct id and label', () => {
280
const provider = createProvider(disposables, model);
281
assert.strictEqual(provider.id, COPILOT_PROVIDER_ID);
282
assert.strictEqual(provider.sessionTypes.length, 3);
283
});
284
285
test('sessionTypes excludes Claude when setting is disabled', () => {
286
const provider = createProvider(disposables, model, { claudeEnabled: false });
287
assert.strictEqual(provider.sessionTypes.length, 2);
288
assert.ok(!provider.sessionTypes.some(t => t.id === ClaudeCodeSessionType.id));
289
});
290
291
test('onDidChangeSessionTypes fires when claude setting changes', () => {
292
const { provider, configService } = createProviderWithConfig(disposables, model);
293
assert.strictEqual(provider.sessionTypes.length, 3);
294
295
let fired = false;
296
disposables.add(provider.onDidChangeSessionTypes(() => { fired = true; }));
297
298
// Disable claude via config change
299
configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, false);
300
configService.onDidChangeConfigurationEmitter.fire({
301
source: ConfigurationTarget.USER,
302
affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]),
303
change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] },
304
affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING,
305
});
306
307
assert.ok(fired, 'onDidChangeSessionTypes should have fired');
308
assert.strictEqual(provider.sessionTypes.length, 2);
309
});
310
311
test('toggling claude setting refreshes sessions list', () => {
312
const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });
313
model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));
314
315
const { provider, configService } = createProviderWithConfig(disposables, model);
316
assert.strictEqual(provider.getSessions().length, 1, 'Claude sessions should appear when enabled by default');
317
318
// Disable Claude
319
configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, false);
320
configService.onDidChangeConfigurationEmitter.fire({
321
source: ConfigurationTarget.USER,
322
affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]),
323
change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] },
324
affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING,
325
});
326
327
assert.strictEqual(provider.getSessions().length, 0, 'Claude sessions should disappear after disabling');
328
329
// Re-enable Claude
330
configService.setUserConfiguration(CLAUDE_CODE_ENABLED_SETTING, true);
331
configService.onDidChangeConfigurationEmitter.fire({
332
source: ConfigurationTarget.USER,
333
affectedKeys: new Set([CLAUDE_CODE_ENABLED_SETTING]),
334
change: { keys: [CLAUDE_CODE_ENABLED_SETTING], overrides: [] },
335
affectsConfiguration: (key: string) => key === CLAUDE_CODE_ENABLED_SETTING,
336
});
337
338
assert.strictEqual(provider.getSessions().length, 1, 'Claude sessions should reappear after re-enabling');
339
});
340
341
// ---- getSessionTypes -------
342
343
test('getSessionTypes returns Claude for local workspace when enabled', () => {
344
const provider = createProvider(disposables, model, { claudeEnabled: true });
345
const types = provider.getSessionTypes(URI.file('/test/project'));
346
assert.ok(types.some(t => t.id === ClaudeCodeSessionType.id));
347
});
348
349
test('getSessionTypes does not return Claude for local workspace when disabled', () => {
350
const provider = createProvider(disposables, model, { claudeEnabled: false });
351
const types = provider.getSessionTypes(URI.file('/test/project'));
352
assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id));
353
});
354
355
test('getSessionTypes returns only Cloud for remote workspace regardless of claude setting', () => {
356
const provider = createProvider(disposables, model, { claudeEnabled: true });
357
const types = provider.getSessionTypes(URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/owner/repo' }));
358
assert.strictEqual(types.length, 1);
359
assert.ok(!types.some(t => t.id === ClaudeCodeSessionType.id));
360
});
361
362
// ---- Session listing -------
363
364
test('getSessions returns empty array initially', () => {
365
const provider = createProvider(disposables, model);
366
assert.strictEqual(provider.getSessions().length, 0);
367
});
368
369
test('getSessions returns adapted sessions from agent model', () => {
370
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
371
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
372
model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));
373
model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));
374
375
const provider = createProvider(disposables, model);
376
const sessions = provider.getSessions();
377
378
assert.strictEqual(sessions.length, 2);
379
});
380
381
test('getSessions ignores non-Background/Cloud/Claude sessions', () => {
382
const bgResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/bg-session' });
383
const localResource = URI.from({ scheme: AgentSessionProviders.Local, path: '/local-session' });
384
model.addSession(createMockAgentSession(bgResource));
385
model.addSession(createMockAgentSession(localResource, { providerType: AgentSessionProviders.Local }));
386
387
const provider = createProvider(disposables, model);
388
const sessions = provider.getSessions();
389
390
assert.strictEqual(sessions.length, 1);
391
});
392
393
test('getSessions includes Claude agent sessions when enabled', () => {
394
const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });
395
model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));
396
397
const provider = createProvider(disposables, model, { claudeEnabled: true });
398
const sessions = provider.getSessions();
399
400
assert.strictEqual(sessions.length, 1);
401
});
402
403
test('getSessions excludes Claude agent sessions when disabled', () => {
404
const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });
405
model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));
406
407
const provider = createProvider(disposables, model, { claudeEnabled: false });
408
const sessions = provider.getSessions();
409
410
assert.strictEqual(sessions.length, 0);
411
});
412
413
test('onDidChangeSessions fires when agent model changes', () => {
414
const provider = createProvider(disposables, model);
415
provider.getSessions(); // Initialize cache
416
417
const changes: ISessionChangeEvent[] = [];
418
disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
419
420
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/new-session' });
421
model.addSession(createMockAgentSession(resource, { title: 'New Session' }));
422
423
assert.ok(changes.length > 0);
424
assert.strictEqual(changes[0].added.length, 1);
425
});
426
427
// ---- Session creation -------
428
// Note: createNewSession tests are limited because CopilotCLISession
429
// requires IGitService and creates disposables that are hard to clean
430
// up in isolation. Full integration tests should cover session creation.
431
432
// ---- Session actions -------
433
434
test('archiveSession sets archived state', () => {
435
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
436
const agentSession = createMockAgentSession(resource);
437
model.addSession(agentSession);
438
439
const provider = createProvider(disposables, model);
440
provider.getSessions(); // Initialize cache
441
442
const session = provider.getSessions()[0];
443
provider.archiveSession(session.sessionId);
444
445
assert.strictEqual(agentSession.isArchived(), true);
446
});
447
448
test('unarchiveSession clears archived state', () => {
449
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
450
const agentSession = createMockAgentSession(resource, { archived: true });
451
model.addSession(agentSession);
452
453
const provider = createProvider(disposables, model);
454
provider.getSessions();
455
456
const session = provider.getSessions()[0];
457
provider.unarchiveSession(session.sessionId);
458
459
assert.strictEqual(agentSession.isArchived(), false);
460
});
461
462
// ---- Session capabilities -------
463
464
test('copilot CLI sessions have supportsMultipleChats capability', () => {
465
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
466
model.addSession(createMockAgentSession(resource));
467
468
const provider = createProvider(disposables, model);
469
const sessions = provider.getSessions();
470
471
assert.strictEqual(sessions.length, 1);
472
assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, true);
473
});
474
475
test('copilot cloud sessions do not have supportsMultipleChats capability', () => {
476
const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/session-1' });
477
model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud }));
478
479
const provider = createProvider(disposables, model);
480
const sessions = provider.getSessions();
481
482
assert.strictEqual(sessions.length, 1);
483
assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false);
484
});
485
486
test('copilot CLI sessions do not have supportsMultipleChats when setting is disabled', () => {
487
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
488
model.addSession(createMockAgentSession(resource));
489
490
const provider = createProvider(disposables, model, { multiChatEnabled: false });
491
const sessions = provider.getSessions();
492
493
assert.strictEqual(sessions.length, 1);
494
assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false);
495
});
496
497
test('claude sessions do not have supportsMultipleChats capability', () => {
498
const resource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/session-1' });
499
model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Claude }));
500
501
const provider = createProvider(disposables, model, { claudeEnabled: true });
502
const sessions = provider.getSessions();
503
504
assert.strictEqual(sessions.length, 1);
505
assert.strictEqual(sessions[0].capabilities.supportsMultipleChats, false);
506
});
507
508
// ---- Session listing & grouping -------
509
510
test('each session has exactly one chat initially', () => {
511
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
512
model.addSession(createMockAgentSession(resource));
513
514
const provider = createProvider(disposables, model);
515
const sessions = provider.getSessions();
516
517
assert.strictEqual(sessions.length, 1);
518
assert.strictEqual(sessions[0].chats.get().length, 1);
519
assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString());
520
});
521
522
test('sendAndCreateChat throws for unknown session', async () => {
523
const provider = createProvider(disposables, model);
524
await assert.rejects(
525
() => provider.sendAndCreateChat('nonexistent', { query: 'test' }),
526
/not found/,
527
);
528
});
529
530
test('getSessions groups chats by session group', () => {
531
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
532
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
533
model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' }));
534
model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' }));
535
536
const provider = createProvider(disposables, model);
537
const sessions = provider.getSessions();
538
539
// Without explicit grouping, each chat is its own session
540
assert.strictEqual(sessions.length, 2);
541
});
542
543
test('groups committed chats using metadata.sessionParentId', () => {
544
const rootResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/root-session' });
545
const child1Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/child-session-1' });
546
const child2Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/child-session-2' });
547
548
model.addSession(createMockAgentSession(rootResource, { title: 'Root', createdAt: 1 }));
549
model.addSession(createMockAgentSession(child1Resource, {
550
title: 'Child 1',
551
createdAt: 2,
552
metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }
553
}));
554
model.addSession(createMockAgentSession(child2Resource, {
555
title: 'Child 2',
556
createdAt: 3,
557
metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }
558
}));
559
560
const provider = createProvider(disposables, model);
561
const sessions = provider.getSessions();
562
563
assert.strictEqual(sessions.length, 1);
564
assert.strictEqual(sessions[0].chats.get().length, 3);
565
assert.strictEqual(sessions[0].mainChat.resource.toString(), rootResource.toString());
566
});
567
568
test('orders chats within a grouped session by createdAt', () => {
569
const rootResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/root-session' });
570
const olderChildResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/older-child' });
571
const newerChildResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/newer-child' });
572
573
// Add out of order to ensure grouping order is driven by createdAt rather than insertion order.
574
model.addSession(createMockAgentSession(newerChildResource, {
575
title: 'Newer Child',
576
createdAt: 30,
577
metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }
578
}));
579
model.addSession(createMockAgentSession(rootResource, { title: 'Root', createdAt: 10 }));
580
model.addSession(createMockAgentSession(olderChildResource, {
581
title: 'Older Child',
582
createdAt: 20,
583
metadata: { repositoryPath: '/test/repo', sessionParentId: 'root-session' }
584
}));
585
586
const provider = createProvider(disposables, model);
587
const sessions = provider.getSessions();
588
589
assert.strictEqual(sessions.length, 1);
590
assert.deepStrictEqual(
591
sessions[0].chats.get().map(chat => chat.resource.toString()),
592
[rootResource.toString(), olderChildResource.toString(), newerChildResource.toString()]
593
);
594
});
595
596
test('groups child sessions even when the parent/root session is missing', () => {
597
const orphan1Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/orphan-child-1' });
598
const orphan2Resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/orphan-child-2' });
599
const provider = createProvider(disposables, model);
600
601
provider.getSessions(); // initialize cache
602
603
const changes: ISessionChangeEvent[] = [];
604
disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
605
606
model.addSession(createMockAgentSession(orphan1Resource, {
607
title: 'Orphan Child 1',
608
createdAt: 1,
609
metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' }
610
}));
611
model.addSession(createMockAgentSession(orphan2Resource, {
612
title: 'Orphan Child 2',
613
createdAt: 2,
614
metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' }
615
}));
616
617
const sessions = provider.getSessions();
618
619
assert.strictEqual(sessions.length, 1);
620
assert.deepStrictEqual(
621
sessions[0].chats.get().map(chat => chat.resource.toString()),
622
[orphan1Resource.toString(), orphan2Resource.toString()]
623
);
624
assert.deepStrictEqual(changes.map(e => ({ added: e.added.length, changed: e.changed.length })), [
625
{ added: 1, changed: 0 },
626
{ added: 0, changed: 1 },
627
]);
628
});
629
630
test('groups nested parent chains under the ultimate root', () => {
631
const middleResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/middle-session' });
632
const leafResource = URI.from({ scheme: AgentSessionProviders.Background, path: '/leaf-session' });
633
634
model.addSession(createMockAgentSession(middleResource, {
635
title: 'Middle Session',
636
createdAt: 2,
637
metadata: { repositoryPath: '/test/repo', sessionParentId: 'missing-root' }
638
}));
639
model.addSession(createMockAgentSession(leafResource, {
640
title: 'Leaf Session',
641
createdAt: 3,
642
metadata: { repositoryPath: '/test/repo', sessionParentId: 'middle-session' }
643
}));
644
645
const provider = createProvider(disposables, model);
646
const sessions = provider.getSessions();
647
648
assert.strictEqual(sessions.length, 1);
649
assert.deepStrictEqual(
650
sessions[0].chats.get().map(chat => chat.resource.toString()),
651
[middleResource.toString(), leafResource.toString()]
652
);
653
});
654
655
test('session title comes from primary (first) chat', () => {
656
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
657
model.addSession(createMockAgentSession(resource, { title: 'Primary Title' }));
658
659
const provider = createProvider(disposables, model);
660
const sessions = provider.getSessions();
661
662
assert.strictEqual(sessions[0].title.get(), 'Primary Title');
663
});
664
665
test('session has mainChat set to the first chat', () => {
666
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
667
model.addSession(createMockAgentSession(resource));
668
669
const provider = createProvider(disposables, model);
670
const sessions = provider.getSessions();
671
672
assert.ok(sessions[0].mainChat);
673
assert.strictEqual(sessions[0].mainChat.resource.toString(), resource.toString());
674
});
675
676
test('deleteSession removes session from model and list', async () => {
677
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
678
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
679
model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));
680
model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));
681
682
const provider = createProvider(disposables, model);
683
const sessions = provider.getSessions();
684
assert.strictEqual(sessions.length, 2);
685
686
await provider.deleteSession(sessions[0].sessionId);
687
688
const remainingSessions = provider.getSessions();
689
assert.strictEqual(remainingSessions.length, 1);
690
assert.strictEqual(remainingSessions[0].title.get(), 'Session 2');
691
});
692
693
test('deleteChat with single chat delegates to deleteSession', async () => {
694
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
695
model.addSession(createMockAgentSession(resource));
696
697
const provider = createProvider(disposables, model);
698
const sessions = provider.getSessions();
699
const session = sessions[0];
700
701
await provider.deleteChat(session.sessionId, resource);
702
703
// Model should no longer have the session
704
assert.strictEqual(model.sessions.length, 0);
705
});
706
707
test('deleteChat throws when session does not support multi-chat', async () => {
708
const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/session-1' });
709
model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud }));
710
711
const provider = createProvider(disposables, model);
712
const sessions = provider.getSessions();
713
const session = sessions[0];
714
715
await assert.rejects(
716
() => provider.deleteChat(session.sessionId, resource),
717
/not supported when multi-chat is disabled/,
718
);
719
});
720
721
test('session group cache is invalidated on session removal', () => {
722
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
723
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
724
model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));
725
model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));
726
727
const provider = createProvider(disposables, model);
728
729
// Initialize sessions
730
let sessions = provider.getSessions();
731
assert.strictEqual(sessions.length, 2);
732
733
// Remove one from the model
734
model.removeSession(resource1);
735
736
// Re-fetch
737
sessions = provider.getSessions();
738
assert.strictEqual(sessions.length, 1);
739
assert.strictEqual(sessions[0].title.get(), 'Session 2');
740
});
741
742
test('chats observable updates when group model changes', () => {
743
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
744
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
745
model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' }));
746
model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' }));
747
748
const provider = createProvider(disposables, model);
749
const sessions = provider.getSessions();
750
assert.strictEqual(sessions.length, 2);
751
752
// Both are separate sessions initially
753
const session1 = sessions[0];
754
assert.strictEqual(session1.chats.get().length, 1);
755
});
756
757
test('session status aggregates across chats', () => {
758
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
759
model.addSession(createMockAgentSession(resource));
760
761
const provider = createProvider(disposables, model);
762
const sessions = provider.getSessions();
763
764
// With a single chat, session status should match the chat status
765
assert.ok(sessions[0].status.get() !== undefined);
766
});
767
768
test('session isRead aggregates across all chats', () => {
769
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
770
model.addSession(createMockAgentSession(resource, { read: true }));
771
772
const provider = createProvider(disposables, model);
773
const sessions = provider.getSessions();
774
775
assert.strictEqual(sessions[0].isRead.get(), true);
776
});
777
778
test('session isRead is false when any chat is unread', () => {
779
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
780
model.addSession(createMockAgentSession(resource, { read: false }));
781
782
const provider = createProvider(disposables, model);
783
const sessions = provider.getSessions();
784
785
assert.strictEqual(sessions[0].isRead.get(), false);
786
});
787
788
test('removing a chat from a group fires changed (not removed) with correct sessionId', async () => {
789
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
790
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
791
model.addSession(createMockAgentSession(resource1, { title: 'Chat 1' }));
792
model.addSession(createMockAgentSession(resource2, { title: 'Chat 2' }));
793
794
const provider = createProvider(disposables, model);
795
const sessions = provider.getSessions();
796
assert.strictEqual(sessions.length, 2);
797
798
// Manually group both chats under the first session
799
const chat2Id = sessions[1].sessionId;
800
// Access the group model indirectly by deleting the second session's group
801
// and re-adding its chat to the first group via deleteChat flow
802
// Instead, simulate by removing the second chat from the model
803
const changes: ISessionChangeEvent[] = [];
804
disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
805
806
model.removeSession(resource2);
807
808
// The removed chat was standalone, so it should fire a removed event
809
assert.ok(changes.length > 0);
810
const lastChange = changes[changes.length - 1];
811
assert.strictEqual(lastChange.removed.length, 1);
812
assert.strictEqual(lastChange.removed[0].sessionId, chat2Id);
813
});
814
815
test('getSessions does not create duplicate groups on repeated calls', () => {
816
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
817
model.addSession(createMockAgentSession(resource));
818
819
const provider = createProvider(disposables, model);
820
821
// Call getSessions multiple times
822
const sessions1 = provider.getSessions();
823
const sessions2 = provider.getSessions();
824
825
assert.strictEqual(sessions1.length, 1);
826
assert.strictEqual(sessions2.length, 1);
827
// Should return the same cached session object
828
assert.strictEqual(sessions1[0], sessions2[0]);
829
});
830
831
test('changed events are not duplicated when multiple chats update', () => {
832
const resource1 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-1' });
833
const resource2 = URI.from({ scheme: AgentSessionProviders.Background, path: '/session-2' });
834
model.addSession(createMockAgentSession(resource1, { title: 'Session 1' }));
835
model.addSession(createMockAgentSession(resource2, { title: 'Session 2' }));
836
837
const provider = createProvider(disposables, model);
838
provider.getSessions(); // Initialize
839
840
const changes: ISessionChangeEvent[] = [];
841
disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
842
843
// Trigger a refresh that updates both sessions
844
model.addSession(createMockAgentSession(
845
URI.from({ scheme: AgentSessionProviders.Background, path: '/session-3' }),
846
{ title: 'Session 3' }
847
));
848
849
// Each event should not have duplicates in the changed array
850
for (const change of changes) {
851
const changedIds = change.changed.map(s => s.sessionId);
852
const uniqueIds = new Set(changedIds);
853
assert.strictEqual(changedIds.length, uniqueIds.size, 'Changed events should not have duplicates');
854
}
855
});
856
857
// ---- Browse actions -------
858
859
test('resolveWorkspace creates proper workspace structure', () => {
860
const provider = createProvider(disposables, model);
861
const uri = URI.file('/test/project');
862
863
const workspace = provider.resolveWorkspace(uri);
864
865
assert.ok(workspace, 'resolveWorkspace should resolve file:// URIs');
866
assert.strictEqual(workspace.label, 'project');
867
assert.strictEqual(workspace.repositories.length, 1);
868
assert.strictEqual(workspace.repositories[0].uri.toString(), uri.toString());
869
assert.strictEqual(workspace.requiresWorkspaceTrust, true);
870
});
871
872
test('builds an unknown workspace fallback when repository metadata is missing', () => {
873
const resource = URI.from({ scheme: AgentSessionProviders.Background, path: '/unknown-workspace-session' });
874
model.addSession(createMockAgentSession(resource, { metadata: {} }));
875
876
const provider = createProvider(disposables, model);
877
const sessions = provider.getSessions();
878
const workspace = sessions[0].workspace.get();
879
880
assert.ok(workspace);
881
assert.strictEqual(workspace.repositories.length, 1);
882
assert.strictEqual(workspace.repositories[0].uri.toString(), URI.parse('unknown:///').toString());
883
assert.strictEqual(workspace.requiresWorkspaceTrust, true);
884
885
// The core symptom of #310777: any of these calls must not throw.
886
assert.doesNotThrow(() => URI.joinPath(workspace.repositories[0].uri, '.vscode', 'settings.json'));
887
assert.doesNotThrow(() => URI.joinPath(workspace.repositories[0].uri, '.vscode/extensions.json'));
888
});
889
890
// ---- Claude session creation -------
891
892
function makeClaudeInFlightProvider(): { provider: CopilotChatSessionsProvider; cancelRequest: () => void; realResource: URI; commitSession: () => void } {
893
let resolveComplete!: () => void;
894
let resolveCreated!: (r: IChatResponseModel) => void;
895
const responseCompletePromise = new Promise<void>(r => { resolveComplete = r; });
896
const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });
897
898
// The real resource that createNewChatSessionItem returns
899
const realResource = URI.from({ scheme: AgentSessionProviders.Claude, path: `/claude-session-${Date.now()}` });
900
901
const provider = createProviderForSendTests(disposables, model, async () => ({
902
kind: 'sent' as const,
903
data: {
904
responseCompletePromise,
905
responseCreatedPromise,
906
agent: new class extends mock<IChatAgentData>() { }(),
907
} as IChatSendRequestData,
908
}), {
909
claudeEnabled: true,
910
createNewChatSessionItem: async (_type, request): Promise<IChatSessionItem> => ({
911
resource: realResource,
912
label: request.prompt,
913
timing: { created: Date.now(), lastRequestStarted: undefined, lastRequestEnded: undefined },
914
}),
915
});
916
917
return {
918
provider,
919
realResource,
920
cancelRequest: () => {
921
resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);
922
resolveComplete();
923
},
924
commitSession: () => {
925
// Add the agent session to the model so _waitForSessionInCache resolves
926
model.addSession(createMockAgentSession(realResource, { providerType: AgentSessionProviders.Claude }));
927
},
928
};
929
}
930
931
function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise<void> {
932
return new Promise<void>(resolve => {
933
const d = provider.onDidChangeSessions(e => {
934
if (e.added.length > 0) {
935
d.dispose();
936
resolve();
937
}
938
});
939
});
940
}
941
942
test('createNewSession with Claude type creates a session', async () => {
943
const { provider, commitSession } = makeClaudeInFlightProvider();
944
const workspace = URI.file('/test/project');
945
946
const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);
947
948
assert.ok(session);
949
assert.strictEqual(session.sessionType, ClaudeCodeSessionType.id);
950
assert.strictEqual(session.status.get(), SessionStatus.Untitled);
951
952
// Send and commit so the session enters the cache and can be disposed
953
const added = waitForSessionAdded(provider);
954
const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });
955
await added;
956
commitSession();
957
await assert.doesNotReject(sendPromise);
958
});
959
960
test('archiveSession archives a Claude temp session', async () => {
961
const { provider, cancelRequest } = makeClaudeInFlightProvider();
962
const workspace = URI.file('/test/project');
963
const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);
964
965
const added = waitForSessionAdded(provider);
966
const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });
967
await added;
968
969
await provider.archiveSession(session.sessionId);
970
assert.strictEqual(provider.getSessions()[0].isArchived.get(), true);
971
972
cancelRequest();
973
await assert.doesNotReject(sendPromise);
974
975
// Clean up
976
await provider.deleteSession(session.sessionId);
977
});
978
979
test('unarchiveSession unarchives a Claude temp session', async () => {
980
const { provider, cancelRequest } = makeClaudeInFlightProvider();
981
const workspace = URI.file('/test/project');
982
const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);
983
984
const added = waitForSessionAdded(provider);
985
const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });
986
await added;
987
988
await provider.archiveSession(session.sessionId);
989
assert.strictEqual(provider.getSessions()[0].isArchived.get(), true);
990
991
await provider.unarchiveSession(session.sessionId);
992
assert.strictEqual(provider.getSessions()[0].isArchived.get(), false);
993
994
cancelRequest();
995
await assert.doesNotReject(sendPromise);
996
997
// Clean up
998
await provider.deleteSession(session.sessionId);
999
});
1000
1001
// ---- Claude controller-based send flow -------
1002
1003
test('sendAndCreateChat replaces temp session with committed session on success', async () => {
1004
const { provider, commitSession } = makeClaudeInFlightProvider();
1005
const workspace = URI.file('/test/project');
1006
const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);
1007
1008
const replacements: { from: unknown; to: unknown }[] = [];
1009
disposables.add(provider.onDidReplaceSession(e => replacements.push(e)));
1010
1011
const added = waitForSessionAdded(provider);
1012
const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'hello world' });
1013
await added;
1014
1015
assert.strictEqual(provider.getSessions().length, 1, 'temp session should appear while in-flight');
1016
1017
// Simulate the agent session appearing in the model
1018
commitSession();
1019
await sendPromise;
1020
1021
// The temp session should have been replaced by the committed one
1022
assert.ok(replacements.length > 0, 'onDidReplaceSessions should have fired');
1023
});
1024
1025
test('sendAndCreateChat uses the query as the temp session title', async () => {
1026
const { provider, cancelRequest } = makeClaudeInFlightProvider();
1027
const workspace = URI.file('/test/project');
1028
const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);
1029
1030
const added = waitForSessionAdded(provider);
1031
const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'fix the login bug' });
1032
await added;
1033
1034
const sessions = provider.getSessions();
1035
assert.strictEqual(sessions[0].title.get(), 'fix the login bug');
1036
1037
cancelRequest();
1038
await assert.doesNotReject(sendPromise);
1039
await provider.deleteSession(session.sessionId);
1040
});
1041
1042
test('sendAndCreateChat keeps temp session on cancellation', async () => {
1043
const { provider, cancelRequest } = makeClaudeInFlightProvider();
1044
const workspace = URI.file('/test/project');
1045
const session = provider.createNewSession(workspace, ClaudeCodeSessionType.id);
1046
1047
const added = waitForSessionAdded(provider);
1048
const sendPromise = provider.sendAndCreateChat(session.sessionId, { query: 'test' });
1049
await added;
1050
1051
// Cancel before the agent session appears
1052
cancelRequest();
1053
await sendPromise;
1054
1055
assert.strictEqual(provider.getSessions().length, 1, 'session should remain after cancellation');
1056
assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed, 'should be marked completed');
1057
1058
await provider.deleteSession(session.sessionId);
1059
});
1060
1061
// ---- Rename -------
1062
1063
test('renameChat delegates to claude rename command', async () => {
1064
const claudeResource = URI.from({ scheme: AgentSessionProviders.Claude, path: '/claude-session' });
1065
model.addSession(createMockAgentSession(claudeResource, { providerType: AgentSessionProviders.Claude }));
1066
1067
const provider = createProvider(disposables, model, { claudeEnabled: true });
1068
const sessions = provider.getSessions();
1069
assert.strictEqual(sessions.length, 1);
1070
1071
// Should not throw — delegates to ICommandService.executeCommand
1072
await provider.renameChat(sessions[0].sessionId, claudeResource, 'New Title');
1073
});
1074
1075
test('renameChat throws for unsupported session type', async () => {
1076
const resource = URI.from({ scheme: AgentSessionProviders.Cloud, path: '/cloud-session' });
1077
model.addSession(createMockAgentSession(resource, { providerType: AgentSessionProviders.Cloud }));
1078
1079
const provider = createProvider(disposables, model);
1080
const sessions = provider.getSessions();
1081
1082
await assert.rejects(
1083
() => provider.renameChat(sessions[0].sessionId, resource, 'New Title'),
1084
/not supported/,
1085
);
1086
});
1087
1088
// ---- Uncommitted temp session cleanup ------------------------------------
1089
1090
suite('uncommitted temp session cleanup', () => {
1091
const workspace = URI.file('/test/repo');
1092
1093
/**
1094
* Returns a provider wired up so that sendRequest keeps the request
1095
* in-flight indefinitely. Also returns helpers to resolve the request
1096
* as a cancellation (so the provider cleans up promptly in tests).
1097
*/
1098
function makeInFlightProvider(): {
1099
provider: CopilotChatSessionsProvider;
1100
cancelRequest: () => void;
1101
} {
1102
let resolveComplete!: () => void;
1103
let resolveCreated!: (r: IChatResponseModel) => void;
1104
const responseCompletePromise = new Promise<void>(r => { resolveComplete = r; });
1105
const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });
1106
1107
const provider = createProviderForSendTests(disposables, model, async () => ({
1108
kind: 'sent' as const,
1109
data: {
1110
responseCompletePromise,
1111
responseCreatedPromise,
1112
agent: new class extends mock<IChatAgentData>() { }(),
1113
} as IChatSendRequestData,
1114
}));
1115
1116
return {
1117
provider,
1118
cancelRequest: () => {
1119
resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);
1120
resolveComplete();
1121
},
1122
};
1123
}
1124
1125
/** Wait for the provider to fire an "added" session change event. */
1126
function waitForSessionAdded(provider: CopilotChatSessionsProvider): Promise<void> {
1127
return new Promise<void>(resolve => {
1128
const d = provider.onDidChangeSessions(e => {
1129
if (e.added.length > 0) {
1130
d.dispose();
1131
resolve();
1132
}
1133
});
1134
});
1135
}
1136
1137
test('deleteSession removes a temp session that is awaiting commit', async () => {
1138
const { provider, cancelRequest } = makeInFlightProvider();
1139
1140
const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);
1141
const sessionId = newSession.sessionId;
1142
1143
const added = waitForSessionAdded(provider);
1144
const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });
1145
await added;
1146
1147
assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');
1148
1149
await provider.deleteSession(sessionId);
1150
assert.strictEqual(provider.getSessions().length, 0, 'session should be removed after deleteSession');
1151
1152
// Cancellation after delete should resolve cleanly
1153
cancelRequest();
1154
await assert.doesNotReject(sendPromise);
1155
});
1156
1157
test('archiveSession archives a temp session that is awaiting commit', async () => {
1158
const { provider, cancelRequest } = makeInFlightProvider();
1159
1160
const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);
1161
const sessionId = newSession.sessionId;
1162
1163
const added = waitForSessionAdded(provider);
1164
const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });
1165
await added;
1166
1167
assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');
1168
1169
await provider.archiveSession(sessionId);
1170
assert.strictEqual(provider.getSessions().length, 1, 'session should still be in the list after archiveSession');
1171
assert.strictEqual(provider.getSessions()[0].isArchived.get(), true, 'session should be archived');
1172
1173
// Cancellation after archive should resolve cleanly
1174
cancelRequest();
1175
await assert.doesNotReject(sendPromise);
1176
1177
// Clean up to avoid leaked disposable
1178
await provider.deleteSession(sessionId);
1179
});
1180
1181
test('archiveSession archives a stopped session that was never committed', async () => {
1182
const { provider, cancelRequest } = makeInFlightProvider();
1183
1184
const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);
1185
const sessionId = newSession.sessionId;
1186
1187
const added = waitForSessionAdded(provider);
1188
const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });
1189
await added;
1190
1191
// Stop before commit arrives — session should stay as completed
1192
cancelRequest();
1193
await sendPromise;
1194
1195
assert.strictEqual(provider.getSessions().length, 1, 'stopped session should remain in the list');
1196
assert.strictEqual(provider.getSessions()[0].status.get(), SessionStatus.Completed, 'session should be completed');
1197
1198
await provider.archiveSession(sessionId);
1199
assert.strictEqual(provider.getSessions().length, 1, 'session should still be in the list after archiving');
1200
assert.strictEqual(provider.getSessions()[0].isArchived.get(), true, 'session should be archived');
1201
1202
// Unarchive should also work
1203
await provider.unarchiveSession(sessionId);
1204
assert.strictEqual(provider.getSessions()[0].isArchived.get(), false, 'session should be unarchived');
1205
1206
// Clean up to avoid leaked disposable
1207
await provider.deleteSession(sessionId);
1208
});
1209
1210
/**
1211
* Returns a provider where the commit event is controllable. The
1212
* caller can fire the commit event at the right moment to simulate
1213
* the session being committed mid-request, then cancel the request
1214
* afterwards. The session should persist after cancellation.
1215
*/
1216
function makeCommittableProvider(): {
1217
provider: CopilotChatSessionsProvider;
1218
commitSession: (original: URI, committed: URI) => void;
1219
cancelRequest: () => void;
1220
} {
1221
let resolveComplete!: () => void;
1222
let resolveCreated!: (r: IChatResponseModel) => void;
1223
const responseCompletePromise = new Promise<void>(r => { resolveComplete = r; });
1224
const responseCreatedPromise = new Promise<IChatResponseModel>(r => { resolveCreated = r; });
1225
1226
const commitEmitter = disposables.add(new Emitter<{ original: URI; committed: URI }>());
1227
1228
const provider = createProviderForSendTests(disposables, model, async () => ({
1229
kind: 'sent' as const,
1230
data: {
1231
responseCompletePromise,
1232
responseCreatedPromise,
1233
agent: new class extends mock<IChatAgentData>() { }(),
1234
} as IChatSendRequestData,
1235
}), { onDidCommitSession: commitEmitter.event });
1236
1237
return {
1238
provider,
1239
commitSession: (original, committed) => commitEmitter.fire({ original, committed }),
1240
cancelRequest: () => {
1241
resolveCreated({ isCanceled: true } as unknown as IChatResponseModel);
1242
resolveComplete();
1243
},
1244
};
1245
}
1246
1247
test('stopping a committed session keeps it in the list', async () => {
1248
const { provider, commitSession, cancelRequest } = makeCommittableProvider();
1249
1250
const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);
1251
const sessionId = newSession.sessionId;
1252
1253
const added = waitForSessionAdded(provider);
1254
const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });
1255
await added;
1256
1257
assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');
1258
1259
// Get the temp session's resource so we can fire the commit event
1260
const tempSession = provider.getSessions()[0];
1261
const tempResource = tempSession.resource;
1262
1263
// Simulate commit: the agent created the worktree, so the URI
1264
// swaps from untitled to a real committed resource.
1265
const committedResource = URI.from({ scheme: AgentSessionProviders.Background, path: `/committed-${Date.now()}` });
1266
const committedAgentSession = createMockAgentSession(committedResource);
1267
model.addSession(committedAgentSession);
1268
commitSession(tempResource, committedResource);
1269
1270
// _sendFirstChat should complete successfully now
1271
await sendPromise;
1272
1273
assert.strictEqual(provider.getSessions().length, 1, 'committed session should remain in list');
1274
1275
// Now cancel the request — session must stay
1276
cancelRequest();
1277
1278
assert.strictEqual(provider.getSessions().length, 1, 'committed session should persist after stopping');
1279
});
1280
1281
test('cancelling the request before commit keeps the session with completed status', async () => {
1282
const { provider, cancelRequest } = makeInFlightProvider();
1283
1284
const changes: ISessionChangeEvent[] = [];
1285
disposables.add(provider.onDidChangeSessions(e => changes.push(e)));
1286
1287
const newSession = provider.createNewSession(workspace, CopilotCLISessionType.id);
1288
const sessionId = newSession.sessionId;
1289
1290
const added = waitForSessionAdded(provider);
1291
const sendPromise = provider.sendAndCreateChat(sessionId, { query: 'test' });
1292
await added;
1293
1294
assert.strictEqual(provider.getSessions().length, 1, 'session should appear while in-flight');
1295
assert.ok(changes.some(e => e.added.some(s => s.sessionId === sessionId)), 'added event should have fired');
1296
1297
// Simulate user stopping the request
1298
cancelRequest();
1299
await sendPromise;
1300
1301
assert.strictEqual(provider.getSessions().length, 1, 'session should stay in list after cancellation');
1302
assert.ok(
1303
changes.some(e => e.changed.some(s => s.sessionId === sessionId)),
1304
'changed event should have fired',
1305
);
1306
1307
// Clean up the kept session so it doesn't leak
1308
await provider.deleteSession(sessionId);
1309
});
1310
});
1311
});
1312
1313