Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/test/copilotCliSessionService.spec.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { SessionOptions } from '@github/copilot/sdk';
7
import { mkdir, mkdtemp, rm, writeFile as writeNodeFile } from 'node:fs/promises';
8
import { tmpdir } from 'node:os';
9
import { join } from 'node:path';
10
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
11
import type { ChatContext, ChatParticipantToolToken, Uri } from 'vscode';
12
import { CancellationToken } from 'vscode-languageserver-protocol';
13
import { IAuthenticationService } from '../../../../../platform/authentication/common/authentication';
14
import { NullChatDebugFileLoggerService } from '../../../../../platform/chat/common/chatDebugFileLoggerService';
15
import { IConfigurationService } from '../../../../../platform/configuration/common/configurationService';
16
import { NullNativeEnvService } from '../../../../../platform/env/common/nullEnvService';
17
import { MockFileSystemService } from '../../../../../platform/filesystem/node/test/mockFileSystemService';
18
import { MockGitService } from '../../../../../platform/ignore/node/test/mockGitService';
19
import { ILogService } from '../../../../../platform/log/common/logService';
20
import { NullMcpService } from '../../../../../platform/mcp/common/mcpService';
21
import { NoopOTelService, resolveOTelConfig } from '../../../../../platform/otel/common/index';
22
import { MockPromptsService } from '../../../../../platform/promptFiles/test/common/mockPromptsService';
23
import { NullRequestLogger } from '../../../../../platform/requestLogger/node/nullRequestLogger';
24
import { NullWorkspaceService } from '../../../../../platform/workspace/common/workspaceService';
25
import { mock } from '../../../../../util/common/test/simpleMock';
26
import { DisposableStore, IReference, toDisposable } from '../../../../../util/vs/base/common/lifecycle';
27
import { URI } from '../../../../../util/vs/base/common/uri';
28
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
29
import { NullPromptVariablesService } from '../../../../prompt/node/promptVariablesService';
30
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
31
import { IAgentSessionsWorkspace } from '../../../common/agentSessionsWorkspace';
32
import { IChatSessionWorkspaceFolderService } from '../../../common/chatSessionWorkspaceFolderService';
33
import { IChatSessionWorktreeService } from '../../../common/chatSessionWorktreeService';
34
import { MockChatSessionMetadataStore } from '../../../common/test/mockChatSessionMetadataStore';
35
import { IWorkspaceInfo } from '../../../common/workspaceInfo';
36
import { FakeToolsService } from '../../common/copilotCLITools';
37
import { ICustomSessionTitleService } from '../../common/customSessionTitleService';
38
import { IChatDelegationSummaryService } from '../../common/delegationSummaryService';
39
import { getCopilotCLISessionDir } from '../cliHelpers';
40
import { ICopilotCLISDK } from '../copilotCli';
41
import { CopilotCLISession, ICopilotCLISession } from '../copilotcliSession';
42
import { CopilotCLISessionService, CopilotCLISessionWorkspaceTracker, ICopilotCLISessionItem } from '../copilotcliSessionService';
43
import { CopilotCLIMCPHandler } from '../mcpHandler';
44
import { MissionControlApiClient } from '../missionControlApiClient';
45
import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../userInputHelpers';
46
import { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullCopilotCLIModels, NullICopilotCLIImageSupport } from './testHelpers';
47
48
// Re-export for backward compatibility with other spec files
49
export { MockCliSdkSession, MockCliSdkSessionManager, MockSkillLocations, NullCopilotCLIAgents, NullICopilotCLIImageSupport } from './testHelpers';
50
51
class MockLocalSession {
52
static async fromEvents(events: readonly { type: string }[]): Promise<{}> {
53
const unknownEvent = events.find(event => event.type === 'custom.unknown');
54
if (unknownEvent) {
55
throw new Error(`Unknown event type: ${unknownEvent.type}. Failed to deserialize session.`);
56
}
57
return {};
58
}
59
}
60
61
export class NullAgentSessionsWorkspace implements IAgentSessionsWorkspace {
62
_serviceBrand: undefined;
63
readonly isAgentSessionsWorkspace = false;
64
}
65
66
class NullChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
67
override deleteTrackedWorkspaceFolder = vi.fn(async () => { });
68
override trackSessionWorkspaceFolder = vi.fn(async () => { });
69
override getSessionWorkspaceFolder = vi.fn(async () => undefined);
70
override handleRequestCompleted = vi.fn(async () => { });
71
override getWorkspaceChanges = vi.fn(async () => undefined);
72
override clearWorkspaceChanges: IChatSessionWorkspaceFolderService['clearWorkspaceChanges'] = vi.fn((_sessionIdOrFolderUri: string | Uri) => []);
73
}
74
75
class NullChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {
76
override getWorktreeProperties: IChatSessionWorktreeService['getWorktreeProperties'] = vi.fn(async () => undefined);
77
}
78
79
class NullCustomSessionTitleService implements ICustomSessionTitleService {
80
declare _serviceBrand: undefined;
81
private readonly titles = new Map<string, string>();
82
async getCustomSessionTitle(sessionId: string): Promise<string | undefined> { return this.titles.get(sessionId); }
83
async setCustomSessionTitle(sessionId: string, title: string): Promise<void> {
84
this.titles.set(sessionId, title);
85
}
86
async generateSessionTitle(_sessionId: string, _request: { prompt?: string; command?: string }): Promise<string | undefined> { return undefined; }
87
}
88
89
function workspaceInfoFor(workingDirectory: Uri | undefined): IWorkspaceInfo {
90
return {
91
folder: workingDirectory,
92
repository: undefined,
93
worktree: undefined,
94
worktreeProperties: undefined,
95
};
96
}
97
98
function sessionOptionsFor(workingDirectory?: Uri) {
99
return {
100
workspace: workspaceInfoFor(workingDirectory),
101
};
102
}
103
104
describe('CopilotCLISessionService', () => {
105
const disposables = new DisposableStore();
106
let logService: ILogService;
107
let instantiationService: IInstantiationService;
108
let service: CopilotCLISessionService;
109
let manager: MockCliSdkSessionManager;
110
let tempStateHome: string | undefined;
111
const originalXdgStateHome = process.env.XDG_STATE_HOME;
112
beforeEach(async () => {
113
vi.useRealTimers();
114
const sdk = {
115
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
116
getRequestId: vi.fn(() => undefined),
117
} as unknown as ICopilotCLISDK;
118
119
const services = disposables.add(createExtensionUnitTestingServices());
120
const accessor = services.createTestingAccessor();
121
logService = accessor.get(ILogService);
122
const workspaceService = new NullWorkspaceService();
123
const cliAgents = new NullCopilotCLIAgents();
124
const authService = {
125
getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),
126
} as unknown as IAuthenticationService;
127
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
128
override async summarize(context: ChatContext, token: CancellationToken): Promise<string | undefined> {
129
return undefined;
130
}
131
override extractPrompt(): { prompt: string; reference: never } | undefined {
132
return undefined;
133
}
134
}();
135
class FakeUserQuestionHandler implements IUserQuestionHandler {
136
_serviceBrand: undefined;
137
async askUserQuestion(question: IQuestion, toolInvocationToken: ChatParticipantToolToken, token: CancellationToken): Promise<IQuestionAnswer | undefined> {
138
return undefined;
139
}
140
}
141
142
instantiationService = {
143
invokeFunction(fn: (accessor: unknown, ...args: any[]) => any, ...args: any[]): any {
144
return fn(accessor, ...args);
145
},
146
createInstance: (ctor: unknown, workspaceInfo: any, agentName: any, sdkSession: any) => {
147
if (ctor === CopilotCLISessionWorkspaceTracker) {
148
return new class extends mock<CopilotCLISessionWorkspaceTracker>() {
149
override async initialize(): Promise<void> { return; }
150
override shouldShowSession(_sessionId: string): { isOldGlobalSession?: boolean; isWorkspaceSession?: boolean } {
151
return { isOldGlobalSession: false, isWorkspaceSession: true };
152
}
153
}();
154
}
155
if (ctor === MissionControlApiClient) {
156
return {
157
createSession: vi.fn(),
158
submitEvents: vi.fn(),
159
getPendingCommands: vi.fn(async () => []),
160
deleteSession: vi.fn(async () => { }),
161
};
162
}
163
return disposables.add(new CopilotCLISession(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 MockGitService(), { _serviceBrand: undefined } as any));
164
}
165
} as unknown as IInstantiationService;
166
const configurationService = accessor.get(IConfigurationService);
167
const nullMcpServer = disposables.add(new NullMcpService());
168
const titleService = new NullCustomSessionTitleService();
169
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
170
manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;
171
});
172
173
afterEach(() => {
174
if (tempStateHome) {
175
void rm(tempStateHome, { recursive: true, force: true });
176
tempStateHome = undefined;
177
}
178
process.env.XDG_STATE_HOME = originalXdgStateHome;
179
vi.useRealTimers();
180
vi.restoreAllMocks();
181
disposables.clear();
182
});
183
184
// --- Tests ----------------------------------------------------------------------------------
185
186
it('falls back to a compatibility auto-mode manager when the SDK export is not constructable', async () => {
187
const sdk = {
188
getPackage: vi.fn(async () => ({
189
internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } },
190
LocalSession: MockLocalSession,
191
createLocalFeatureFlagService: () => ({}),
192
AutoModeSessionManager: {} as never,
193
acquireAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode acquire'); }),
194
refreshAutoModeSession: vi.fn(async () => { throw new Error('unexpected auto-mode refresh'); }),
195
AutoModeUnavailableError: class extends Error { },
196
AutoModeUnsupportedError: class extends Error { },
197
isAutoModel: (model: string | undefined) => model === 'auto',
198
noopTelemetryBinder: {},
199
})),
200
getRequestId: vi.fn(() => undefined),
201
} as unknown as ICopilotCLISDK;
202
203
const services = disposables.add(createExtensionUnitTestingServices());
204
const accessor = services.createTestingAccessor();
205
const configurationService = accessor.get(IConfigurationService);
206
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
207
const nullMcpServer = disposables.add(new NullMcpService());
208
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
209
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
210
override async summarize(): Promise<string | undefined> { return undefined; }
211
}();
212
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
213
214
const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager & { opts: { autoModeManager: Record<string, unknown> } };
215
216
expect(localManager.opts.autoModeManager).toEqual(expect.objectContaining({
217
resolve: expect.any(Function),
218
clear: expect.any(Function),
219
handleModelChange: expect.any(Function),
220
subscribe: expect.any(Function),
221
}));
222
});
223
224
describe('CopilotCLISessionService.createSession', () => {
225
it('get session will return the same session created using createSession', async () => {
226
const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
227
228
const existingSession = await service.getSession({ sessionId: session.object.sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
229
230
expect(existingSession).toBe(session);
231
});
232
it('get session will return new once previous session is disposed', async () => {
233
const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
234
235
session.dispose();
236
await new Promise(resolve => setTimeout(resolve, 0)); // allow dispose async cleanup to run
237
const existingSession = await service.getSession({ sessionId: session.object.sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
238
239
expect(existingSession?.object).toBeDefined();
240
expect(existingSession?.object).not.toBe(session);
241
expect(existingSession?.object.sessionId).toBe(session.object.sessionId);
242
});
243
244
it('passes clientName: vscode to session manager', async () => {
245
const createSessionSpy = vi.spyOn(manager, 'createSession');
246
await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
247
248
expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({
249
clientName: 'vscode'
250
}));
251
});
252
253
it('passes reasoningEffort to session manager when provided', async () => {
254
const createSessionSpy = vi.spyOn(manager, 'createSession');
255
await service.createSession({ model: 'gpt-test', reasoningEffort: 'high', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
256
257
expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({
258
model: 'gpt-test',
259
}));
260
});
261
262
it('does not set reasoningEffort when not provided', async () => {
263
const createSessionSpy = vi.spyOn(manager, 'createSession');
264
await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
265
266
expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({
267
model: 'gpt-test',
268
}));
269
const callArgs = createSessionSpy.mock.calls[0][0];
270
expect(callArgs.reasoningEffort).toBeUndefined();
271
});
272
});
273
274
describe('CopilotCLISessionService.getSession', () => {
275
it('passes reasoningEffort to session manager when creating a new session', async () => {
276
const targetId = 'reasoning-get';
277
manager.sessions.set(targetId, new MockCliSdkSession(targetId, new Date()));
278
const getSessionSpy = vi.spyOn(manager, 'getSession');
279
await service.getSession({ sessionId: targetId, model: 'gpt-test', reasoningEffort: 'medium', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
280
281
expect(getSessionSpy).toHaveBeenCalledWith(expect.objectContaining({
282
model: 'gpt-test',
283
}), true);
284
});
285
286
it('does not set reasoningEffort when not provided', async () => {
287
const targetId = 'no-reasoning-get';
288
manager.sessions.set(targetId, new MockCliSdkSession(targetId, new Date()));
289
const getSessionSpy = vi.spyOn(manager, 'getSession');
290
await service.getSession({ sessionId: targetId, model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
291
292
expect(getSessionSpy).toHaveBeenCalled();
293
const callArgs = getSessionSpy.mock.calls[0][0];
294
expect(callArgs.reasoningEffort).toBeUndefined();
295
});
296
});
297
298
describe('CopilotCLISessionService.getSession concurrency & locking', () => {
299
it('concurrent getSession calls for same id create only one wrapper', async () => {
300
const targetId = 'concurrent';
301
const sdkSession = new MockCliSdkSession(targetId, new Date());
302
manager.sessions.set(targetId, sdkSession);
303
const originalGetSession = manager.getSession.bind(manager);
304
const getSessionSpy = vi.fn((opts: SessionOptions & { sessionId: string }, writable: boolean) => {
305
// Introduce delay to force overlapping acquire attempts
306
return new Promise(resolve => setTimeout(() => resolve(originalGetSession(opts, writable)), 20));
307
});
308
manager.getSession = getSessionSpy as unknown as typeof manager.getSession;
309
310
const promises: Promise<IReference<ICopilotCLISession> | undefined>[] = [];
311
for (let i = 0; i < 10; i++) {
312
promises.push(service.getSession({ sessionId: targetId, ...sessionOptionsFor() }, CancellationToken.None));
313
}
314
const results = await Promise.all(promises);
315
// All results refer to same instance
316
const first = results.shift()!;
317
for (const r of results) {
318
expect(r).toBe(first);
319
}
320
expect(getSessionSpy).toHaveBeenCalledTimes(1);
321
322
// Verify ref-count like disposal only disposes when all callers release
323
let sentinelDisposed = false;
324
(first.object as CopilotCLISession).add(toDisposable(() => { sentinelDisposed = true; }));
325
326
results.forEach(r => r?.dispose());
327
expect(sentinelDisposed).toBe(false);
328
329
// Only after disposing the last reference is the session disposed.
330
first.dispose();
331
expect(sentinelDisposed).toBe(true);
332
});
333
334
it('getSession for different ids does not block on mutex for another id', async () => {
335
const slowId = 'slow';
336
const fastId = 'fast';
337
manager.sessions.set(slowId, new MockCliSdkSession(slowId, new Date()));
338
manager.sessions.set(fastId, new MockCliSdkSession(fastId, new Date()));
339
340
const originalGetSession = manager.getSession.bind(manager);
341
manager.getSession = vi.fn((opts: SessionOptions & { sessionId: string }, writable: boolean) => {
342
if (opts.sessionId === slowId) {
343
return new Promise(resolve => setTimeout(() => resolve(originalGetSession(opts, writable)), 40));
344
}
345
return originalGetSession(opts, writable);
346
}) as unknown as typeof manager.getSession;
347
348
const slowPromise = service.getSession({ sessionId: slowId, ...sessionOptionsFor() }, CancellationToken.None).then(() => 'slow');
349
const fastPromise = service.getSession({ sessionId: fastId, ...sessionOptionsFor() }, CancellationToken.None).then(() => 'fast');
350
const firstResolved = await Promise.race([slowPromise, fastPromise]);
351
expect(firstResolved).toBe('fast');
352
});
353
354
it('session only fully disposes after all acquired references dispose', async () => {
355
const id = 'refcount';
356
manager.sessions.set(id, new MockCliSdkSession(id, new Date()));
357
// Acquire 5 times sequentially
358
const sessions: IReference<ICopilotCLISession>[] = [];
359
for (let i = 0; i < 5; i++) {
360
sessions.push((await service.getSession({ sessionId: id, ...sessionOptionsFor() }, CancellationToken.None))!);
361
}
362
const base = sessions[0];
363
for (const s of sessions) {
364
expect(s).toBe(base);
365
}
366
let sentinelDisposed = false;
367
const lastSession = sessions.pop()!;
368
(lastSession.object as CopilotCLISession).add(toDisposable(() => { sentinelDisposed = true; }));
369
// Dispose all other session refs, session should not yet be disposed
370
sessions.forEach(s => s.dispose());
371
expect(sentinelDisposed).toBe(false);
372
// Final dispose triggers actual disposal
373
lastSession.dispose();
374
expect(sentinelDisposed).toBe(true);
375
});
376
});
377
378
describe('CopilotCLISessionService.getSession missing', () => {
379
it('returns undefined when underlying manager has no session', async () => {
380
const session = await service.getSession({ sessionId: 'does-not-exist', ...sessionOptionsFor() }, CancellationToken.None);
381
disposables.add(session!);
382
expect(session).toBeUndefined();
383
});
384
});
385
386
describe('CopilotCLISessionService.renameSession', () => {
387
it('renames an inactive session through copilot/sdk', async () => {
388
const sessionId = 'rename-inactive';
389
manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date()));
390
391
await service.renameSession(sessionId, 'Renamed From VS Code');
392
393
expect(manager.sessions.get(sessionId)?.title).toBe('Renamed From VS Code');
394
expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Renamed From VS Code');
395
});
396
397
it('renames an active wrapped session through copilot/sdk', async () => {
398
const session = await service.createSession({ sessionId: 'rename-active', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
399
400
await service.renameSession(session.object.sessionId, 'Wrapped Session Name');
401
402
expect(manager.sessions.get(session.object.sessionId)?.title).toBe('Wrapped Session Name');
403
expect(await service.getSessionTitle(session.object.sessionId, CancellationToken.None)).toBe('Wrapped Session Name');
404
session.dispose();
405
});
406
407
it('updates session summaries through copilot/sdk for untitled sessions', async () => {
408
const sessionId = 'summary-session';
409
manager.sessions.set(sessionId, new MockCliSdkSession(sessionId, new Date()));
410
411
await service.updateSessionSummary(sessionId, 'Generated Summary');
412
413
expect(manager.sessions.get(sessionId)?.summary).toBe('Generated Summary');
414
expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Generated Summary');
415
});
416
417
it('syncs staged titles for newly created vscode sessions into copilot/sdk', async () => {
418
const sessionId = service.createNewSessionId();
419
await (service as unknown as { customSessionTitleService: ICustomSessionTitleService }).customSessionTitleService.setCustomSessionTitle(sessionId, 'Staged Session Title');
420
421
const session = await service.createSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
422
423
expect(manager.sessions.get(sessionId)?.summary).toBe('Staged Session Title');
424
expect(await service.getSessionTitle(sessionId, CancellationToken.None)).toBe('Staged Session Title');
425
session.dispose();
426
});
427
428
it('keeps an established session list title stable while a new request is pending', async () => {
429
const sessionId = 'stable-while-pending';
430
const sdkSession = new MockCliSdkSession(sessionId, new Date());
431
sdkSession.summary = 'Original Session Title';
432
manager.sessions.set(sessionId, sdkSession);
433
434
const session = await service.getSession({ sessionId, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
435
(session!.object as unknown as { _pendingPrompt: string | undefined })._pendingPrompt = 'Latest in-flight request';
436
437
const sessions = await service.getAllSessions(CancellationToken.None);
438
expect(sessions.find(item => item.id === sessionId)?.label).toBe('Original Session Title');
439
440
session!.dispose();
441
});
442
});
443
444
describe('CopilotCLISessionService.tryGetPartialSesionHistory', () => {
445
it('reconstructs history from persisted files', async () => {
446
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
447
process.env.XDG_STATE_HOME = tempStateHome;
448
const sessionId = 'partial-session';
449
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
450
const fileSystem = new MockFileSystemService();
451
const sdk = {
452
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
453
} as unknown as ICopilotCLISDK;
454
const services = createExtensionUnitTestingServices();
455
disposables.add(services);
456
const accessor = services.createTestingAccessor();
457
const configurationService = accessor.get(IConfigurationService);
458
const authService = {
459
getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),
460
} as unknown as IAuthenticationService;
461
const nullMcpServer = disposables.add(new NullMcpService());
462
const titleService = new NullCustomSessionTitleService();
463
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
464
override extractPrompt(): { prompt: string; reference: never } | undefined {
465
return undefined;
466
}
467
}();
468
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
469
470
await mkdir(sessionDir.fsPath, { recursive: true });
471
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
472
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath, gitRoot: URI.file('/workspace/repo').fsPath, repository: URI.file('/workspace/repo').fsPath } } }),
473
JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'Repair the session', attachments: [] } }),
474
JSON.stringify({ id: '3', type: 'assistant.message', timestamp: '2024-01-01T00:00:03.000Z', parentId: '2', data: { content: 'Recovered history' } }),
475
].join('\n'));
476
477
const partialHistory = await partialService.tryGetPartialSessionHistory(sessionId);
478
479
expect(partialHistory).toBeDefined();
480
expect(partialHistory).toHaveLength(2);
481
expect(partialService.getSessionWorkingDirectory(sessionId)?.fsPath).toBe(URI.file('/workspace/project').fsPath);
482
});
483
484
it('returns cached result on second call without re-reading the file', async () => {
485
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
486
process.env.XDG_STATE_HOME = tempStateHome;
487
const sessionId = 'cache-test-session';
488
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
489
const fileSystem = new MockFileSystemService();
490
const sdk = {
491
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
492
} as unknown as ICopilotCLISDK;
493
const services = createExtensionUnitTestingServices();
494
disposables.add(services);
495
const accessor = services.createTestingAccessor();
496
const configurationService = accessor.get(IConfigurationService);
497
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
498
const nullMcpServer = disposables.add(new NullMcpService());
499
const titleService = new NullCustomSessionTitleService();
500
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
501
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
502
}();
503
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
504
505
await mkdir(sessionDir.fsPath, { recursive: true });
506
const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl');
507
await writeNodeFile(eventsFilePath, [
508
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),
509
JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'First call fills cache', attachments: [] } }),
510
].join('\n'));
511
512
const history1 = await partialService.tryGetPartialSessionHistory(sessionId);
513
514
// Remove the file so a second disk read would fail
515
await rm(eventsFilePath);
516
517
// Second call must return the cached array (same reference, no re-read)
518
const history2 = await partialService.tryGetPartialSessionHistory(sessionId);
519
520
expect(history2).toBe(history1);
521
});
522
523
it('returns undefined when the events file does not exist', async () => {
524
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
525
process.env.XDG_STATE_HOME = tempStateHome;
526
527
const result = await service.tryGetPartialSessionHistory('nonexistent-session-id');
528
expect(result).toBeUndefined();
529
});
530
});
531
532
describe('CopilotCLISessionService.getAllSessions', () => {
533
it('will not list created sessions', async () => {
534
const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
535
disposables.add(session);
536
537
const s1 = new MockCliSdkSession('s1', new Date(0));
538
s1.messages.push({ role: 'user', content: 'a'.repeat(100) });
539
s1.events.push({ type: 'user.message', data: { content: 'a'.repeat(100) }, timestamp: '2024-01-01T00:00:00.000Z' });
540
manager.sessions.set(s1.sessionId, s1);
541
542
const result = await service.getAllSessions(CancellationToken.None);
543
544
expect(result.length).toBe(1);
545
const item = result[0];
546
expect(item.id).toBe('s1');
547
});
548
549
it('falls back to partial session data when getSession fails with an unknown event type', async () => {
550
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
551
process.env.XDG_STATE_HOME = tempStateHome;
552
const sessionId = 'invalid-session';
553
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
554
const fileSystem = new MockFileSystemService();
555
const sdk = {
556
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
557
} as unknown as ICopilotCLISDK;
558
const services = createExtensionUnitTestingServices();
559
disposables.add(services);
560
const accessor = services.createTestingAccessor();
561
const configurationService = accessor.get(IConfigurationService);
562
const authService = {
563
getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),
564
} as unknown as IAuthenticationService;
565
const nullMcpServer = disposables.add(new NullMcpService());
566
const titleService = new NullCustomSessionTitleService();
567
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
568
override extractPrompt(): { prompt: string; reference: never } | undefined {
569
return undefined;
570
}
571
}();
572
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
573
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
574
575
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
576
session.summary = 'Broken summary <current_dateti...';
577
partialManager.sessions.set(sessionId, session);
578
partialManager.getSession = vi.fn(async () => {
579
throw new Error('Failed to load session. Unknown event type: custom.unknown.');
580
}) as unknown as typeof partialManager.getSession;
581
582
await mkdir(sessionDir.fsPath, { recursive: true });
583
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
584
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),
585
JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'Use fallback history', attachments: [] } }),
586
].join('\n'));
587
588
const sessions = await partialService.getAllSessions(CancellationToken.None);
589
590
expect(sessions).toHaveLength(1);
591
expect(sessions[0].id).toBe(sessionId);
592
expect(sessions[0].label).toBe('Use fallback history');
593
});
594
595
it('does not emit session when summary is truncated and no user turns exist', async () => {
596
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
597
process.env.XDG_STATE_HOME = tempStateHome;
598
const sessionId = 'no-user-turns-session';
599
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
600
const fileSystem = new MockFileSystemService();
601
const sdk = {
602
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} }))
603
} as unknown as ICopilotCLISDK;
604
const services = createExtensionUnitTestingServices();
605
disposables.add(services);
606
const accessor = services.createTestingAccessor();
607
const configurationService = accessor.get(IConfigurationService);
608
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
609
const nullMcpServer = disposables.add(new NullMcpService());
610
const titleService = new NullCustomSessionTitleService();
611
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
612
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
613
}();
614
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
615
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
616
617
// Session has a summary with '<' (which forces the session-load fallback path)
618
// but no readable user turns in the events file.
619
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
620
session.summary = 'Summary without user turns <current_dateti...';
621
partialManager.sessions.set(sessionId, session);
622
partialManager.getSession = vi.fn(async () => {
623
throw new Error('Failed to load session. Unknown event type: custom.unknown.');
624
}) as unknown as typeof partialManager.getSession;
625
626
await mkdir(sessionDir.fsPath, { recursive: true });
627
// events.jsonl only contains session.start — no user.message events
628
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
629
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),
630
].join('\n'));
631
632
const sessions = await partialService.getAllSessions(CancellationToken.None);
633
634
// Session still appears, using the metadata summary as a best-effort label
635
expect(sessions).toHaveLength(1);
636
expect(sessions[0].id).toBe(sessionId);
637
expect(sessions[0].label).toBe('Summary without user turns <current_dateti...');
638
});
639
});
640
641
describe('CopilotCLISessionService.deleteSession', () => {
642
it('disposes active wrapper, removes from manager and fires change event', async () => {
643
const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);
644
const id = session!.object.sessionId;
645
let fired = false;
646
disposables.add(session);
647
disposables.add(service.onDidChangeSessions(() => { fired = true; }));
648
await service.deleteSession(id);
649
650
expect(manager.sessions.has(id)).toBe(false);
651
expect(fired).toBe(true);
652
653
expect(await service.getSession({ sessionId: id, ...sessionOptionsFor() }, CancellationToken.None)).toBeUndefined();
654
});
655
656
it('fires onDidDeleteSession with the session id', async () => {
657
const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);
658
const id = session!.object.sessionId;
659
const deletedIds: string[] = [];
660
disposables.add(session);
661
disposables.add(service.onDidDeleteSession(deletedId => deletedIds.push(deletedId)));
662
await service.deleteSession(id);
663
664
expect(deletedIds).toHaveLength(1);
665
expect(deletedIds[0]).toBe(id);
666
});
667
668
it('clears partial session history cache and working directory on delete', async () => {
669
const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);
670
const id = session.object.sessionId;
671
disposables.add(session);
672
673
// Manually populate both caches to simulate a prior tryGetPartialSesionHistory call
674
const partialHistories = (service as any)._partialSessionHistories as Map<string, readonly unknown[]>;
675
const workingDirs = (service as any)._sessionWorkingDirectories as Map<string, Uri | undefined>;
676
partialHistories.set(id, []);
677
workingDirs.set(id, URI.file('/some/working/dir'));
678
679
expect(partialHistories.has(id)).toBe(true);
680
expect(workingDirs.has(id)).toBe(true);
681
682
await service.deleteSession(id);
683
684
expect(partialHistories.has(id)).toBe(false);
685
expect(workingDirs.has(id)).toBe(false);
686
});
687
});
688
689
describe('CopilotCLISessionService.getSession cache clearing', () => {
690
it('clears partial session history when reusing an existing active session', async () => {
691
const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);
692
const id = session.object.sessionId;
693
694
// Simulate a partial history entry that was populated before the session was loaded
695
const partialHistories = (service as any)._partialSessionHistories as Map<string, readonly unknown[]>;
696
partialHistories.set(id, []);
697
expect(partialHistories.has(id)).toBe(true);
698
699
// getSession with the same id reuses the existing wrapper and should clear the partial cache
700
const reused = await service.getSession({ sessionId: id, ...sessionOptionsFor() }, CancellationToken.None);
701
702
expect(reused).toBe(session);
703
expect(partialHistories.has(id)).toBe(false);
704
705
session.dispose();
706
reused?.dispose();
707
});
708
});
709
710
describe('CopilotCLISessionService.label generation', () => {
711
it('uses first user message line when present', async () => {
712
const s = new MockCliSdkSession('lab1', new Date());
713
s.messages.push({ role: 'user', content: 'Line1\nLine2' });
714
s.events.push({ type: 'user.message', data: { content: 'Line1\nLine2' }, timestamp: Date.now().toString() });
715
manager.sessions.set(s.sessionId, s);
716
717
const sessions = await service.getAllSessions(CancellationToken.None);
718
const item = sessions.find(i => i.id === 'lab1');
719
expect(item?.label).includes('Line1');
720
expect(item?.label).includes('Line2');
721
});
722
723
it('uses clean summary from metadata without loading the full session', async () => {
724
const s = new MockCliSdkSession('summary1', new Date());
725
s.summary = 'Fix the login bug';
726
s.events.push({ type: 'user.message', data: { content: 'Fix the login bug in auth.ts' }, timestamp: Date.now().toString() });
727
manager.sessions.set(s.sessionId, s);
728
729
const getSessionSpy = vi.spyOn(manager, 'getSession');
730
const sessions = await service.getAllSessions(CancellationToken.None);
731
732
const item = sessions.find(i => i.id === 'summary1');
733
expect(item?.label).toBe('Fix the login bug');
734
// Should not have loaded the full session since summary was clean
735
expect(getSessionSpy).not.toHaveBeenCalled();
736
});
737
738
it('falls through to session load when summary contains angle bracket', async () => {
739
const s = new MockCliSdkSession('truncated1', new Date());
740
s.summary = 'Fix the bug... <current_dateti...';
741
s.events.push({ type: 'user.message', data: { content: 'Fix the bug in the parser' }, timestamp: Date.now().toString() });
742
manager.sessions.set(s.sessionId, s);
743
744
const getSessionSpy = vi.spyOn(manager, 'getSession');
745
const sessions = await service.getAllSessions(CancellationToken.None);
746
747
const item = sessions.find(i => i.id === 'truncated1');
748
expect(item?.label).toBe('Fix the bug in the parser');
749
// Should have loaded the full session because summary had '<'
750
expect(getSessionSpy).toHaveBeenCalled();
751
});
752
753
it('uses cached label on second call without loading session again', async () => {
754
const s = new MockCliSdkSession('cache1', new Date());
755
// No summary forces session load on first call
756
s.events.push({ type: 'user.message', data: { content: 'Refactor the tests' }, timestamp: Date.now().toString() });
757
manager.sessions.set(s.sessionId, s);
758
759
// First call - loads session and caches the label
760
const sessions1 = await service.getAllSessions(CancellationToken.None);
761
const item1 = sessions1.find(i => i.id === 'cache1');
762
expect(item1?.label).toBe('Refactor the tests');
763
764
// Now spy on getSession for the second call
765
const getSessionSpy = vi.spyOn(manager, 'getSession');
766
767
// Second call - should use cached label
768
const sessions2 = await service.getAllSessions(CancellationToken.None);
769
const item2 = sessions2.find(i => i.id === 'cache1');
770
expect(item2?.label).toBe('Refactor the tests');
771
// Should not have loaded the full session on second call
772
expect(getSessionSpy).not.toHaveBeenCalled();
773
});
774
775
it('uses metadata summary over stale internal label cache', async () => {
776
const s = new MockCliSdkSession('priority1', new Date());
777
// No summary initially - forces session load and caching
778
s.events.push({ type: 'user.message', data: { content: 'Original label from events' }, timestamp: Date.now().toString() });
779
manager.sessions.set(s.sessionId, s);
780
781
// First call caches label from events
782
const sessions1 = await service.getAllSessions(CancellationToken.None);
783
expect(sessions1.find(i => i.id === 'priority1')?.label).toBe('Original label from events');
784
785
// Now add a summary to the metadata - the cached label should still be used
786
s.summary = 'Different summary label';
787
788
const sessions2 = await service.getAllSessions(CancellationToken.None);
789
expect(sessions2.find(i => i.id === 'priority1')?.label).toBe('Original label from events');
790
});
791
792
it('populates cache after loading session for label', async () => {
793
const s = new MockCliSdkSession('populate1', new Date());
794
s.events.push({ type: 'user.message', data: { content: 'Add unit tests for auth' }, timestamp: Date.now().toString() });
795
manager.sessions.set(s.sessionId, s);
796
797
await service.getAllSessions(CancellationToken.None);
798
799
// Verify the internal cache was populated
800
const labelCache = (service as any)._sessionLabels as Map<string, string>;
801
expect(labelCache.get('populate1')).toBe('Add unit tests for auth');
802
});
803
804
it('does not cache when using clean summary from metadata directly', async () => {
805
const s = new MockCliSdkSession('nocache1', new Date());
806
s.summary = 'Clean summary without brackets';
807
manager.sessions.set(s.sessionId, s);
808
809
await service.getAllSessions(CancellationToken.None);
810
811
// The cache should not have an entry since the summary was used directly
812
const labelCache = (service as any)._sessionLabels as Map<string, string>;
813
expect(labelCache.has('nocache1')).toBe(false);
814
});
815
});
816
817
describe('CopilotCLISessionService.createNewSessionId / isNewSessionId', () => {
818
it('createNewSessionId returns a unique id that isNewSessionId recognises', () => {
819
const id = service.createNewSessionId();
820
expect(id).toBeTruthy();
821
expect(service.isNewSessionId(id)).toBe(true);
822
});
823
824
it('isNewSessionId returns false for an unknown id', () => {
825
expect(service.isNewSessionId('not-a-new-id')).toBe(false);
826
});
827
828
it('successive calls return distinct ids', () => {
829
const a = service.createNewSessionId();
830
const b = service.createNewSessionId();
831
expect(a).not.toBe(b);
832
expect(service.isNewSessionId(a)).toBe(true);
833
expect(service.isNewSessionId(b)).toBe(true);
834
});
835
836
it('createSession clears the new-session flag', async () => {
837
const id = service.createNewSessionId();
838
expect(service.isNewSessionId(id)).toBe(true);
839
840
await service.createSession({ model: 'gpt-test', sessionId: id, ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
841
842
expect(service.isNewSessionId(id)).toBe(false);
843
});
844
});
845
846
describe('CopilotCLISessionService.forkSession', () => {
847
it('delegates to sessionManager.forkSession and returns the new session id', async () => {
848
const sourceId = 'source-session';
849
manager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));
850
const forkSpy = vi.spyOn(manager, 'forkSession');
851
852
const newId = await service.forkSession({ sessionId: sourceId, requestId: undefined, workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);
853
854
expect(forkSpy).toHaveBeenCalledWith(sourceId, undefined);
855
expect(newId).toBeTruthy();
856
expect(newId).not.toBe(sourceId);
857
});
858
859
it('stores forked session metadata via storeForkedSessionMetadata', async () => {
860
const sourceId = 'meta-source';
861
manager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));
862
const metadataStore = new MockChatSessionMetadataStore();
863
const storeMetadataSpy = vi.spyOn(metadataStore, 'storeForkedSessionMetadata');
864
865
const sdk = {
866
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
867
getRequestId: vi.fn(() => undefined),
868
} as unknown as ICopilotCLISDK;
869
const services = disposables.add(createExtensionUnitTestingServices());
870
const accessor = services.createTestingAccessor();
871
const configurationService = accessor.get(IConfigurationService);
872
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
873
const nullMcpServer = disposables.add(new NullMcpService());
874
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
875
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
876
}();
877
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
878
const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager;
879
localManager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));
880
881
const newId = await localService.forkSession({ sessionId: sourceId, requestId: undefined, workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);
882
883
expect(storeMetadataSpy).toHaveBeenCalledWith(sourceId, newId, expect.stringContaining('Forked:'));
884
});
885
886
it('fires onDidCreateSession with the forked session id and title', async () => {
887
const sourceId = 'event-source';
888
manager.sessions.set(sourceId, new MockCliSdkSession(sourceId, new Date()));
889
890
const created: ICopilotCLISessionItem[] = [];
891
disposables.add(service.onDidCreateSession(item => created.push(item)));
892
893
const newId = await service.forkSession({ sessionId: sourceId, requestId: undefined, workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);
894
895
expect(created).toHaveLength(1);
896
expect(created[0].id).toBe(newId);
897
expect(created[0].label).toContain('Forked:');
898
});
899
900
it('passes toEventId to sessionManager.forkSession when requestId matches a stored copilot request id', async () => {
901
const sourceId = 'truncate-source';
902
const sdkSession = new MockCliSdkSession(sourceId, new Date());
903
sdkSession.events.push({ type: 'user.message', id: 'sdk-event-1', data: { content: 'hello' }, timestamp: '2024-01-01T00:00:00.000Z' });
904
manager.sessions.set(sourceId, sdkSession);
905
906
const sdk = {
907
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession, createLocalFeatureFlagService: () => ({}), AutoModeSessionManager: class { }, noopTelemetryBinder: {} })),
908
getRequestId: vi.fn(() => ({ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1' })),
909
} as unknown as ICopilotCLISDK;
910
const services = disposables.add(createExtensionUnitTestingServices());
911
const accessor = services.createTestingAccessor();
912
const configurationService = accessor.get(IConfigurationService);
913
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
914
const nullMcpServer = disposables.add(new NullMcpService());
915
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
916
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
917
override async summarize(): Promise<string | undefined> { return undefined; }
918
}();
919
const metadataStore = new MockChatSessionMetadataStore();
920
await metadataStore.updateRequestDetails(sourceId, [{ vscodeRequestId: 'vsc-req-1', copilotRequestId: 'sdk-event-1', toolIdEditMap: {} }]);
921
const localService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), new NullCustomSessionTitleService(), configurationService, new MockSkillLocations(), delegationService, metadataStore, new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService(), new NoopOTelService(resolveOTelConfig({ env: {}, extensionVersion: '0.0.0', sessionId: 'test' })), new NullPromptVariablesService(), new NullChatDebugFileLoggerService(), disposables.add(new MockPromptsService()), new NullCopilotCLIModels()));
922
const localManager = await localService.getSessionManager() as unknown as MockCliSdkSessionManager;
923
localManager.sessions.set(sourceId, sdkSession);
924
const forkSpy = vi.spyOn(localManager, 'forkSession');
925
926
await localService.forkSession({ sessionId: sourceId, requestId: 'vsc-req-1', workspace: workspaceInfoFor(URI.file('/workspace')) }, CancellationToken.None);
927
928
expect(forkSpy).toHaveBeenCalledWith(sourceId, 'sdk-event-1');
929
});
930
});
931
932
describe('CopilotCLISessionService.auto disposal timeout', () => {
933
it.skip('disposes session after completion timeout and aborts underlying sdk session', async () => {
934
vi.useFakeTimers();
935
const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);
936
937
vi.advanceTimersByTime(31000);
938
await Promise.resolve(); // allow any pending promises to run
939
940
// dispose should have been called by timeout
941
expect(session.object.isDisposed).toBe(true);
942
});
943
});
944
});
945
946