Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
13405 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import type { AutoModeSessionManager as SDKAutoModeSessionManager, AutoModeSessionResult, internal, LocalSession, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
7
import * as l10n from '@vscode/l10n';
8
import { createReadStream } from 'node:fs';
9
import { devNull } from 'node:os';
10
import { createInterface } from 'node:readline';
11
import type { ChatCustomAgent, ChatRequest, ChatSessionItem } from 'vscode';
12
import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';
13
import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';
14
import { INativeEnvService } from '../../../../platform/env/common/envService';
15
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
16
import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';
17
import { RelativePattern } from '../../../../platform/filesystem/common/fileTypes';
18
import { ILogService } from '../../../../platform/log/common/logService';
19
import { deriveCopilotCliOTelEnv } from '../../../../platform/otel/common/agentOTelEnv';
20
import { IOTelService } from '../../../../platform/otel/common/otelService';
21
import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
22
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
23
import { createServiceIdentifier } from '../../../../util/common/services';
24
import { coalesce } from '../../../../util/vs/base/common/arrays';
25
import { disposableTimeout, raceCancellation, raceCancellationError, SequencerByKey, ThrottledDelayer } from '../../../../util/vs/base/common/async';
26
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
27
import { Emitter, Event } from '../../../../util/vs/base/common/event';
28
import { Lazy } from '../../../../util/vs/base/common/lazy';
29
import { Disposable, DisposableMap, IDisposable, IReference, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';
30
import { basename, dirname, joinPath } from '../../../../util/vs/base/common/resources';
31
import { URI } from '../../../../util/vs/base/common/uri';
32
import { generateUuid } from '../../../../util/vs/base/common/uuid';
33
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
34
import { ChatRequestTurn2, ChatResponseTurn2, ChatSessionStatus, Uri } from '../../../../vscodeTypes';
35
import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';
36
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
37
import { IChatSessionMetadataStore, RequestDetails, StoredModeInstructions } from '../../common/chatSessionMetadataStore';
38
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
39
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
40
import { isUntitledSessionId } from '../../common/utils';
41
import { emptyWorkspaceInfo, getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo';
42
import { buildChatHistoryFromEvents, RequestIdDetails, stripReminders } from '../common/copilotCLITools';
43
import { ICustomSessionTitleService } from '../common/customSessionTitleService';
44
import { IChatDelegationSummaryService } from '../common/delegationSummaryService';
45
import { SessionIdForCLI } from '../common/utils';
46
import { getCopilotCLISessionDir } from './cliHelpers';
47
import { formatModelDetails, getAgentFileNameFromFilePath, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isEnabledForCopilotCLI } from './copilotCli';
48
import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';
49
import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession';
50
import { ICopilotCLISkills } from './copilotCLISkills';
51
import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from './mcpHandler';
52
import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgreement';
53
54
55
const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
56
const AUTO_MODE_REFRESH_LEAD_TIME_MS = 300 * 1000;
57
58
type SDKPackage = Awaited<ReturnType<ICopilotCLISDK['getPackage']>>;
59
type AutoModeResolveArgs = Parameters<SDKAutoModeSessionManager['resolve']>[0];
60
type AutoModeResolveResult = Awaited<ReturnType<SDKAutoModeSessionManager['resolve']>>;
61
type AutoModeListener = Parameters<SDKAutoModeSessionManager['subscribe']>[0];
62
63
class AutoModeSessionManagerCompat {
64
65
private current: AutoModeSessionResult | undefined;
66
private previousConcreteModel: string | undefined;
67
private inflight: Promise<AutoModeResolveResult> | undefined;
68
private readonly listeners = new Set<AutoModeListener>();
69
70
constructor(private readonly sdkPackage: Pick<SDKPackage, 'AutoModeUnavailableError' | 'AutoModeUnsupportedError' | 'acquireAutoModeSession' | 'isAutoModel' | 'refreshAutoModeSession'>) { }
71
72
recordPreviousConcreteModel(modelId: string | undefined): void {
73
if (modelId && !this.sdkPackage.isAutoModel(modelId)) {
74
this.previousConcreteModel = modelId;
75
}
76
}
77
78
getLastResolved(): string | undefined {
79
return this.current?.selectedModel;
80
}
81
82
getDiscountPercent(): number | undefined {
83
const discountedCosts = this.current?.discountedCosts;
84
if (!discountedCosts) {
85
return undefined;
86
}
87
88
const selectedModelDiscount = this.current?.selectedModel ? discountedCosts[this.current.selectedModel] : undefined;
89
if (selectedModelDiscount !== undefined) {
90
return Math.round(selectedModelDiscount * 100);
91
}
92
93
const allDiscounts = Object.values(discountedCosts);
94
if (allDiscounts.length === 0) {
95
return undefined;
96
}
97
98
return Math.round((allDiscounts.reduce((sum, discount) => sum + discount, 0) / allDiscounts.length) * 100);
99
}
100
101
getPreviousConcreteModel(): string | undefined {
102
return this.previousConcreteModel;
103
}
104
105
subscribe(listener: AutoModeListener): () => void {
106
this.listeners.add(listener);
107
return () => {
108
this.listeners.delete(listener);
109
};
110
}
111
112
async resolve(args: AutoModeResolveArgs): Promise<AutoModeResolveResult> {
113
if (this.isFresh() && this.current) {
114
const current = this.current;
115
this.applySessionToken(args.settings, current.sessionToken);
116
return { modelId: current.selectedModel, sessionToken: current.sessionToken };
117
}
118
119
if (this.inflight) {
120
const resolved = await this.inflight;
121
if (resolved) {
122
this.applySessionToken(args.settings, resolved.sessionToken);
123
}
124
125
return resolved;
126
}
127
128
this.inflight = this.doResolve(args).finally(() => {
129
this.inflight = undefined;
130
});
131
132
return this.inflight;
133
}
134
135
clear(settings?: AutoModeResolveArgs['settings']): void {
136
this.current = undefined;
137
if (settings) {
138
this.clearSessionToken(settings);
139
}
140
this.notify();
141
}
142
143
handleModelChange(prevModel: string | undefined, nextModel: string, settings?: AutoModeResolveArgs['settings']): void {
144
if (this.sdkPackage.isAutoModel(nextModel) && !this.sdkPackage.isAutoModel(prevModel)) {
145
this.recordPreviousConcreteModel(prevModel);
146
} else if (!this.sdkPackage.isAutoModel(nextModel) && this.sdkPackage.isAutoModel(prevModel)) {
147
this.clear(settings);
148
}
149
}
150
151
private notify(): void {
152
const resolvedModel = this.current?.selectedModel;
153
const discountPercent = this.getDiscountPercent();
154
for (const listener of this.listeners) {
155
try {
156
listener(resolvedModel, discountPercent);
157
} catch {
158
// Ignore listener failures to mirror the SDK manager behavior.
159
}
160
}
161
}
162
163
private async doResolve(args: AutoModeResolveArgs): Promise<AutoModeResolveResult> {
164
const { logger, settings } = args;
165
166
if (this.current) {
167
try {
168
const refreshed = await this.sdkPackage.refreshAutoModeSession({ ...args, existingToken: this.current.sessionToken });
169
this.current = refreshed;
170
this.applySessionToken(settings, refreshed.sessionToken);
171
this.notify();
172
return { modelId: refreshed.selectedModel, sessionToken: refreshed.sessionToken };
173
} catch (error) {
174
if (this.isUnauthorizedError(error)) {
175
logger.debug('Auto-mode refresh unauthorized; acquiring a new session');
176
} else if (error instanceof this.sdkPackage.AutoModeUnsupportedError) {
177
logger.debug(`Auto-mode refresh unsupported: ${error.message}`);
178
this.current = undefined;
179
this.notify();
180
return undefined;
181
} else if (error instanceof this.sdkPackage.AutoModeUnavailableError) {
182
logger.debug(`Auto-mode unavailable during refresh: ${error.message}`);
183
this.current = undefined;
184
this.notify();
185
return undefined;
186
} else {
187
logger.debug(`Auto-mode refresh failed; reusing last token until expiry: ${this.formatError(error)}`);
188
this.applySessionToken(settings, this.current.sessionToken);
189
return { modelId: this.current.selectedModel, sessionToken: this.current.sessionToken };
190
}
191
}
192
}
193
194
try {
195
const acquired = await this.sdkPackage.acquireAutoModeSession(args);
196
this.current = acquired;
197
this.applySessionToken(settings, acquired.sessionToken);
198
this.notify();
199
logger.debug(`Auto-mode session acquired: selected_model=${acquired.selectedModel}${acquired.expiresAt ? ` expires_at=${acquired.expiresAt}` : ''}`);
200
return { modelId: acquired.selectedModel, sessionToken: acquired.sessionToken };
201
} catch (error) {
202
if (error instanceof this.sdkPackage.AutoModeUnsupportedError) {
203
logger.debug(`Auto-mode unsupported: ${error.message}`);
204
return undefined;
205
}
206
207
if (error instanceof this.sdkPackage.AutoModeUnavailableError) {
208
logger.debug(`Auto-mode unavailable: ${error.message}`);
209
return undefined;
210
}
211
212
logger.debug(`Auto-mode acquire failed: ${this.formatError(error)}`);
213
return undefined;
214
}
215
}
216
217
private isFresh(): boolean {
218
return this.current ? (this.current.expiresAt ? this.current.expiresAt * 1000 - Date.now() > AUTO_MODE_REFRESH_LEAD_TIME_MS : true) : false;
219
}
220
221
private isUnauthorizedError(error: unknown): error is { kind: 'unauthorized' } {
222
return typeof error === 'object' && error !== null && 'kind' in error && error.kind === 'unauthorized';
223
}
224
225
private applySessionToken(settings: AutoModeResolveArgs['settings'], sessionToken: string): void {
226
if (!settings) {
227
return;
228
}
229
230
settings.api ??= {};
231
settings.api.copilot ??= {};
232
settings.api.copilot.capiSessionToken = sessionToken;
233
}
234
235
private clearSessionToken(settings: AutoModeResolveArgs['settings']): void {
236
if (settings?.api?.copilot) {
237
delete settings.api.copilot.capiSessionToken;
238
}
239
}
240
241
private formatError(error: unknown): string {
242
return error instanceof Error ? error.message : String(error);
243
}
244
}
245
246
export interface ICopilotCLISessionItem {
247
readonly id: string;
248
readonly label: string;
249
readonly timing: ChatSessionItem['timing'];
250
readonly status?: ChatSessionStatus;
251
readonly workingDirectory?: Uri;
252
}
253
export type ExtendedChatRequest = ChatRequest & { prompt: string };
254
export type ISessionOptions = {
255
model?: string;
256
reasoningEffort?: string;
257
workspace: IWorkspaceInfo;
258
agent?: SweCustomAgent;
259
debugTargetSessionIds?: readonly string[];
260
mcpServerMappings?: McpServerMappings;
261
additionalWorkspaces?: IWorkspaceInfo[];
262
sessionParentId?: string;
263
};
264
export type IGetSessionOptions = ISessionOptions & { sessionId: string };
265
export type ICreateSessionOptions = ISessionOptions & { sessionId?: string };
266
267
export interface ICopilotCLISessionService {
268
readonly _serviceBrand: undefined;
269
270
/**
271
* @deprecated Kept only for non-controller API
272
*/
273
onDidChangeSessions: Event<void>;
274
onDidDeleteSession: Event<string>;
275
onDidChangeSession: Event<ICopilotCLISessionItem>;
276
onDidCreateSession: Event<ICopilotCLISessionItem>;
277
278
getSessionWorkingDirectory(sessionId: string): Uri | undefined;
279
280
// Session metadata querying
281
getSessionItem(sessionId: string, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined>;
282
getSessionTitle(sessionId: string, token: CancellationToken): Promise<string>;
283
getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]>;
284
285
// SDK session management
286
createNewSessionId(): string;
287
isNewSessionId(sessionId: string): boolean;
288
deleteSession(sessionId: string): Promise<void>;
289
290
// Session rename
291
renameSession(sessionId: string, title: string): Promise<void>;
292
updateSessionSummary(sessionId: string, title: string): Promise<void>;
293
294
// Session wrapper tracking
295
getSession(options: IGetSessionOptions, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;
296
createSession(options: ICreateSessionOptions, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
297
getChatHistory(options: { sessionId: string; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]>;
298
forkSession(options: { sessionId: string; requestId: string | undefined; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<string>;
299
tryGetPartialSessionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined>;
300
}
301
302
export const ICopilotCLISessionService = createServiceIdentifier<ICopilotCLISessionService>('ICopilotCLISessionService');
303
304
export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService {
305
declare _serviceBrand: undefined;
306
307
private _sessionManager: Lazy<Promise<internal.LocalSessionManager>>;
308
private _sessionWrappers = new DisposableMap<string, RefCountedSession>();
309
private readonly _partialSessionHistories = new Map<string, readonly (ChatRequestTurn2 | ChatResponseTurn2)[]>();
310
311
312
private readonly _onDidChangeSessions = this._register(new Emitter<void>());
313
public readonly onDidChangeSessions = this._onDidChangeSessions.event;
314
315
private readonly _onDidDeleteSession = this._register(new Emitter<string>());
316
public readonly onDidDeleteSession = this._onDidDeleteSession.event;
317
318
private readonly _onDidChangeSession = this._register(new Emitter<ICopilotCLISessionItem>());
319
public readonly onDidChangeSession = this._onDidChangeSession.event;
320
private readonly _onDidCreateSession = this._register(new Emitter<ICopilotCLISessionItem>());
321
public readonly onDidCreateSession = this._onDidCreateSession.event;
322
323
private readonly _onDidCloseSession = this._register(new Emitter<string>());
324
325
private sessionMutexForGetSession = new Map<string, Mutex>();
326
327
private readonly _sessionTracker: CopilotCLISessionWorkspaceTracker;
328
private readonly _sessionWorkingDirectories = new Map<string, Uri | undefined>();
329
private readonly _onDidChangeSessionsThrottler = this._register(new ThrottledDelayer<void>(500));
330
private readonly _cachedSessionItems = new Map<string, ICopilotCLISessionItem>();
331
private readonly _sessionsBeingCreatedViaFork = new Set<string>();
332
private readonly _newSessionIds = new Set<string>();
333
/** Bridge processor that forwards SDK native OTel spans to the debug panel. */
334
private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined;
335
/** Whether we've attempted to install the bridge (only try once). */
336
private _bridgeInstalled = false;
337
private showExternalSessions: boolean;
338
constructor(
339
@ILogService protected readonly logService: ILogService,
340
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
341
@IInstantiationService protected readonly instantiationService: IInstantiationService,
342
@INativeEnvService private readonly nativeEnv: INativeEnvService,
343
@IFileSystemService private readonly fileSystem: IFileSystemService,
344
@ICopilotCLIMCPHandler private readonly mcpHandler: ICopilotCLIMCPHandler,
345
@ICopilotCLIAgents private readonly agents: ICopilotCLIAgents,
346
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
347
@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,
348
@IConfigurationService private readonly configurationService: IConfigurationService,
349
@ICopilotCLISkills private readonly copilotCLISkills: ICopilotCLISkills,
350
@IChatDelegationSummaryService private readonly _delegationSummaryService: IChatDelegationSummaryService,
351
@IChatSessionMetadataStore private readonly _chatSessionMetadataStore: IChatSessionMetadataStore,
352
@IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace,
353
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
354
@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,
355
@IOTelService private readonly _otelService: IOTelService,
356
@IPromptVariablesService private readonly _promptVariablesService: IPromptVariablesService,
357
@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,
358
@IPromptsService private readonly _promptsService: IPromptsService,
359
@ICopilotCLIModels private readonly _copilotCLIModels: ICopilotCLIModels,
360
) {
361
super();
362
this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions);
363
this._register(this.configurationService.onDidChangeConfiguration(e => {
364
if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) {
365
this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions);
366
}
367
}));
368
this.monitorSessionFiles();
369
this._sessionManager = new Lazy<Promise<internal.LocalSessionManager>>(async () => {
370
try {
371
const sdkPackage = await this.getSDKPackage();
372
const { internal, createLocalFeatureFlagService } = sdkPackage;
373
// Always enable SDK OTel so the debug panel receives native spans via the bridge.
374
// When user OTel is disabled, we force file exporter to /dev/null so the SDK
375
// creates OtelSessionTracker (for debug panel) but doesn't export to any collector.
376
if (!process.env['COPILOT_OTEL_ENABLED']) {
377
process.env['COPILOT_OTEL_ENABLED'] = 'true';
378
}
379
// Default content capture to 'true' for the debug panel. When user OTel
380
// is enabled, their captureContent setting overrides this default below.
381
// When user OTel is disabled, the default gives debug panel content.
382
// If the user explicitly set the env var, respect their choice.
383
if (!process.env['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT']) {
384
process.env['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true';
385
}
386
if (this._otelService.config.enabled) {
387
const otelEnv = deriveCopilotCliOTelEnv(this._otelService.config);
388
for (const [key, value] of Object.entries(otelEnv)) {
389
process.env[key] = value;
390
}
391
// When user OTel is enabled, their captureContent config takes
392
// precedence over the debug-panel default set above.
393
process.env['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = String(this._otelService.config.captureContent);
394
} else {
395
// User OTel disabled: ensure SDK doesn't export to any external collector.
396
// Use file exporter to /dev/null so the SDK creates OtelSessionTracker
397
// (for debug panel) but writes spans nowhere.
398
process.env['COPILOT_OTEL_EXPORTER_TYPE'] = 'file';
399
process.env['COPILOT_OTEL_FILE_EXPORTER_PATH'] = devNull;
400
}
401
return new internal.LocalSessionManager({
402
featureFlagService: createLocalFeatureFlagService(),
403
telemetryService: new internal.NoopTelemetryService(),
404
autoModeManager: this.createAutoModeManager(sdkPackage),
405
}, { flushDebounceMs: undefined, settings: undefined, version: undefined });
406
}
407
catch (error) {
408
this.logService.error(`Failed to initialize Copilot CLI Session Manager: ${error}`);
409
throw error;
410
}
411
});
412
this._sessionTracker = this.instantiationService.createInstance(CopilotCLISessionWorkspaceTracker);
413
}
414
415
private async getSDKPackage(): Promise<SDKPackage> {
416
return this.copilotCLISDK.getPackage();
417
}
418
419
private createAutoModeManager(sdkPackage: SDKPackage): SDKAutoModeSessionManager {
420
if (typeof sdkPackage.AutoModeSessionManager === 'function') {
421
try {
422
return new sdkPackage.AutoModeSessionManager();
423
} catch (error) {
424
if (!(error instanceof TypeError)) {
425
throw error;
426
}
427
}
428
}
429
430
this.logService.warn('Failed to construct SDK AutoModeSessionManager, using compatibility fallback.');
431
return new AutoModeSessionManagerCompat(sdkPackage) as unknown as SDKAutoModeSessionManager;
432
}
433
434
getSessionWorkingDirectory(sessionId: string): Uri | undefined {
435
return this._sessionWorkingDirectories.get(sessionId);
436
}
437
438
private triggerSessionsChangeEvent() {
439
// If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.
440
if (this._isGettingSessions > 0) {
441
return;
442
}
443
444
this._onDidChangeSessionsThrottler.trigger(() => Promise.resolve(this._onDidChangeSessions.fire()));
445
}
446
447
public createNewSessionId(): string {
448
const sessionId = generateUuid();
449
this._newSessionIds.add(sessionId);
450
return sessionId;
451
}
452
453
public isNewSessionId(sessionId: string): boolean {
454
return this._newSessionIds.has(sessionId);
455
}
456
457
protected monitorSessionFiles() {
458
try {
459
const sessionDir = joinPath(this.nativeEnv.userHome, '.copilot', 'session-state');
460
const watcher = this._register(this.fileSystem.createFileSystemWatcher(new RelativePattern(sessionDir, '**/*.jsonl')));
461
this._register(watcher.onDidCreate(async (e) => {
462
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
463
if (sessionId && this._sessionsBeingCreatedViaFork.has(sessionId)) {
464
return;
465
}
466
this.triggerSessionsChangeEvent();
467
const sessionItem = sessionId ? await this.getSessionItemImpl(sessionId, 'disk', CancellationToken.None) : undefined;
468
if (sessionItem) {
469
this._onDidChangeSession.fire(sessionItem);
470
}
471
}));
472
this._register(watcher.onDidDelete(e => {
473
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
474
if (sessionId) {
475
this._cachedSessionItems.delete(sessionId);
476
this._onDidDeleteSession.fire(sessionId);
477
}
478
this.triggerSessionsChangeEvent();
479
}));
480
this._register(watcher.onDidChange((e) => {
481
// If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.
482
if (this._isGettingSessions > 0) {
483
return;
484
}
485
486
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
487
if (sessionId && this._sessionsBeingCreatedViaFork.has(sessionId)) {
488
return;
489
}
490
491
// If we're already working on a session that we're aware of then no need to trigger a refresh.
492
if (Array.from(this._sessionWrappers.keys()).some(sessionId => e.path.includes(sessionId))) {
493
return;
494
}
495
if (sessionId) {
496
this.triggerOnDidChangeSessionItem(sessionId, 'fileSystemChange');
497
}
498
this.triggerSessionsChangeEvent();
499
}));
500
} catch (error) {
501
this.logService.error(`Failed to monitor Copilot CLI session files: ${error}`);
502
}
503
}
504
async getSessionManager() {
505
return this._sessionManager.value;
506
}
507
508
private _sessionChangeNotifierByKey = new SequencerByKey<string>();
509
private triggerOnDidChangeSessionItem(sessionId: string, reason: 'fileSystemChange' | 'statusChange') {
510
this._sessionChangeNotifierByKey.queue(sessionId, async () => {
511
// lets wait for 500ms, as we could get a lot of change events in a short period of time.
512
// E.g. if you have a session running in integrated terminal, then its possible we will see a lot of updates.
513
// In such cases its best to just delay (throttle) by 500ms (we get that via the sequncer and this delay)
514
if (reason === 'fileSystemChange') {
515
await new Promise<void>(resolve => disposableTimeout(resolve, 500, this._store));
516
// If already getting all sessions, no point in triggering individual change event.
517
if (this._isGettingSessions > 0) {
518
return;
519
}
520
}
521
522
const sessionItem = await this.getSessionItemImpl(sessionId, reason === 'statusChange' ? 'inMemorySession' : 'disk', CancellationToken.None);
523
if (sessionItem) {
524
this._onDidChangeSession.fire(sessionItem);
525
}
526
}).catch(error => {
527
this.logService.error(`Failed to trigger session change event for session ${sessionId}: ${error}`);
528
});
529
}
530
531
/**
532
* This can be very expensive, as this involves loading all of the sessions.
533
* TODO @DonJayamanne We need to try to use SDK to open a session and get the details.
534
*/
535
public async getSessionItem(sessionId: string, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
536
return this.getSessionItemImpl(sessionId, 'inMemorySession', token);
537
}
538
539
public async getSessionItemImpl(sessionId: string, source: 'inMemorySession' | 'disk', token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
540
const wrappedSession = this._sessionWrappers.get(sessionId);
541
// Give preference to the session we have in memory, as this contains the latest information.
542
if (wrappedSession && (source === 'inMemorySession' || wrappedSession.object.status === ChatSessionStatus.InProgress)) {
543
const item = await this.constructSessionItemFromWrappedSession(wrappedSession, token);
544
if (item) {
545
return item;
546
}
547
}
548
549
// // We can get the item from cache, as the ICopilotCLISessionItem doesn't store anything that changes.
550
// // Except the title
551
// let item = this._cachedSessionItems.get(sessionId);
552
// if (item) {
553
// // Since this was a change event for an existing session, we must get the latest title.
554
// const label = await this.getSessionTitle(sessionId, CancellationToken.None);
555
// const sessionItem = Object.assign({}, item, { label });
556
// return sessionItem;
557
// }
558
559
const sessionManager = await raceCancellation(this.getSessionManager(), token);
560
const metadata = sessionManager ? await raceCancellationError(sessionManager.getSessionMetadata({ sessionId }), token) : undefined;
561
if (!metadata || token.isCancellationRequested) {
562
return;
563
}
564
await this._sessionTracker.initialize();
565
return await this.constructSessionItem(metadata, token);
566
}
567
568
public async getSessionTitle(sessionId: string, token: CancellationToken): Promise<string> {
569
const sessionManager = await this.getSessionManager();
570
const metadata = await sessionManager.getSessionMetadata({ sessionId });
571
return this.getSessionTitleImpl(sessionId, metadata, token);
572
}
573
574
/**
575
* Single source of truth for both `getSessionTitle()` (editor/header) and
576
* `_getAllSessions()` (sidebar list) so the two surfaces never diverge.
577
*
578
* Precedence:
579
* 1. Explicit renamed title — active wrapper title, SDK `name`, or legacy custom title.
580
* 2. Cached derived label in `_sessionLabels` (from a previous history scan).
581
* 3. Clean metadata `summary` (rejected if it looks truncated).
582
* 4. First user message from session history (cached on success).
583
* 5. Raw metadata `summary` as a display-only last resort (not cached).
584
*
585
* Pending prompts are intentionally excluded here for established sessions.
586
* They are only used for brand-new sessions that have not been persisted yet
587
* via the wrapper-only fallback in `_getAllSessions()` / `constructSessionItemFromWrappedSession()`.
588
*/
589
private async getSessionTitleImpl(sessionId: string, metadata: LocalSessionMetadata | undefined, token: CancellationToken): Promise<string> {
590
const explicitTitle =
591
this._sessionWrappers.get(sessionId)?.object.title ??
592
metadata?.name ??
593
await this.customSessionTitleService.getCustomSessionTitle(sessionId);
594
if (explicitTitle) {
595
return explicitTitle;
596
}
597
598
const cached = this._sessionLabels.get(sessionId);
599
if (cached) {
600
return cached;
601
}
602
603
const summarizedTitle = labelFromPrompt(metadata?.summary ?? '');
604
if (summarizedTitle && !summarizedTitle.endsWith('...') && !summarizedTitle.includes('<')) {
605
return summarizedTitle;
606
}
607
608
const firstUserMessage = await this.getFirstUserMessageFromSession(sessionId, token);
609
const fromHistory = labelFromPrompt(firstUserMessage ?? '');
610
if (fromHistory) {
611
this._sessionLabels.set(sessionId, fromHistory);
612
return fromHistory;
613
}
614
615
return metadata?.summary ?? '';
616
}
617
618
619
private _getAllSessionsProgress: Promise<readonly ICopilotCLISessionItem[]> | undefined;
620
private _isGettingSessions: number = 0;
621
async getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
622
if (!this._getAllSessionsProgress) {
623
this._getAllSessionsProgress = this._getAllSessions(token);
624
}
625
return this._getAllSessionsProgress.finally(() => {
626
this._getAllSessionsProgress = undefined;
627
});
628
}
629
630
private _sessionLabels: Map<string, string> = new Map();
631
632
async _getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
633
this._isGettingSessions++;
634
try {
635
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
636
const sessionMetadataList = await raceCancellationError(sessionManager.listSessions(), token);
637
638
await this._sessionTracker.initialize();
639
640
// Convert SessionMetadata to ICopilotCLISession
641
const diskSessions: ICopilotCLISessionItem[] = coalesce(await Promise.all(
642
sessionMetadataList.map(async (metadata): Promise<ICopilotCLISessionItem | undefined> => {
643
const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;
644
this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);
645
if (!await this.shouldShowSession(metadata.sessionId, metadata.context)) {
646
return;
647
}
648
const id = metadata.sessionId;
649
const startTime = metadata.startTime.getTime();
650
const endTime = metadata.modifiedTime.getTime();
651
const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token);
652
if (!label) {
653
return;
654
}
655
return {
656
id,
657
label,
658
timing: { created: startTime, startTime, endTime },
659
workingDirectory
660
};
661
})
662
));
663
664
const diskSessionIds = new Set(diskSessions.map(s => s.id));
665
// If we have a new session that has started, then return that as well.
666
// Possible SDK has not yet persisted it to disk.
667
const newSessions = coalesce(await Promise.all(Array.from(this._sessionWrappers.values())
668
.filter(session => !diskSessionIds.has(session.object.sessionId))
669
.filter(session => session.object.status === ChatSessionStatus.InProgress)
670
.map(async (session): Promise<ICopilotCLISessionItem | undefined> => {
671
const label = session.object.title ?? await this.customSessionTitleService.getCustomSessionTitle(session.object.sessionId) ?? labelFromPrompt(session.object.pendingPrompt ?? '');
672
if (!label) {
673
return;
674
}
675
676
const createTime = Date.now();
677
return {
678
id: session.object.sessionId,
679
label,
680
status: session.object.status,
681
timing: { created: createTime, startTime: createTime },
682
};
683
})));
684
685
// Merge with cached sessions (new sessions not yet persisted by SDK)
686
const allSessions = diskSessions
687
.map((session): ICopilotCLISessionItem => {
688
return {
689
...session,
690
status: this._sessionWrappers.get(session.id)?.object?.status
691
};
692
}).concat(newSessions);
693
694
allSessions.forEach(session => this._cachedSessionItems.set(session.id, session));
695
return allSessions;
696
} catch (error) {
697
this.logService.error(`Failed to get all sessions: ${error}`);
698
throw error;
699
} finally {
700
this._isGettingSessions--;
701
}
702
}
703
704
private async constructSessionItem(metadata: LocalSessionMetadata, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
705
const sessionItem = await this.constructSessionItemImpl(metadata, token);
706
if (sessionItem) {
707
this._cachedSessionItems.set(metadata.sessionId, sessionItem);
708
}
709
return sessionItem;
710
}
711
712
private async constructSessionItemFromWrappedSession(session: RefCountedSession, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
713
const label = (await this.getSessionTitle(session.object.sessionId, token)) || this._cachedSessionItems.get(session.object.sessionId)?.label || labelFromPrompt(session.object.pendingPrompt ?? '');
714
const createTime = Date.now();
715
return {
716
id: session.object.sessionId,
717
label,
718
status: session.object.status,
719
timing: this._cachedSessionItems.get(session.object.sessionId)?.timing ?? { created: createTime, startTime: createTime },
720
};
721
}
722
723
private async constructSessionItemImpl(metadata: LocalSessionMetadata, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
724
const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;
725
this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);
726
const shouldShowSession = await this.shouldShowSession(metadata.sessionId, metadata.context);
727
if (!shouldShowSession) {
728
return undefined;
729
}
730
731
const id = metadata.sessionId;
732
const startTime = metadata.startTime.getTime();
733
const endTime = metadata.modifiedTime.getTime();
734
const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token) ?? labelFromPrompt(metadata.summary ?? '');
735
736
if (label) {
737
return {
738
id,
739
label,
740
timing: { created: startTime, startTime, endTime },
741
workingDirectory,
742
status: this._sessionWrappers.get(id)?.object?.status
743
};
744
}
745
}
746
747
public async createSession(options: ICreateSessionOptions, token: CancellationToken): Promise<RefCountedSession> {
748
const resource = options.sessionId ? SessionIdForCLI.getResource(options.sessionId) : URI.from({ scheme: 'copilot-cli', path: `mcp-gateway-${generateUuid()}` });
749
const { mcpConfig: mcpServers, disposable: mcpGateway } = await this.mcpHandler.loadMcpConfig(resource);
750
try {
751
const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers });
752
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
753
const sdkSession = await sessionManager.createSession({ ...sessionOptions, sessionId: options.sessionId });
754
const wasNewSession = this._newSessionIds.delete(sdkSession.sessionId);
755
// After the first session creation, the SDK's OTel TracerProvider is
756
// initialized. Install the bridge processor so SDK-native spans flow
757
// to the debug panel.
758
this._installBridgeIfNeeded();
759
760
const promises: Promise<unknown>[] = [];
761
if (wasNewSession) {
762
promises.push(this.customSessionTitleService.getCustomSessionTitle(sdkSession.sessionId).then(stagedTitle => {
763
if (stagedTitle) {
764
sdkSession.updateSessionSummary(stagedTitle);
765
}
766
}));
767
}
768
promises.push(sessionManager.loadDeferredRepoHooks(sdkSession));
769
await Promise.all(promises);
770
771
if (sessionOptions.copilotUrl) {
772
sdkSession.setAuthInfo({
773
type: 'hmac',
774
hmac: 'empty',
775
host: 'https://github.com',
776
copilotUser: {
777
endpoints: {
778
api: sessionOptions.copilotUrl
779
}
780
}
781
});
782
}
783
this.logService.trace(`[CopilotCLISession] Created new CopilotCLI session ${sdkSession.sessionId}.`);
784
785
const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager);
786
session.object.add(mcpGateway);
787
788
// Set origin
789
void this._chatSessionMetadataStore.setSessionOrigin(session.object.sessionId);
790
791
// Set session parent id
792
if (options.sessionParentId) {
793
void this._chatSessionMetadataStore.setSessionParentId(session.object.sessionId, options.sessionParentId);
794
}
795
796
return session;
797
}
798
catch (error) {
799
mcpGateway.dispose();
800
throw error;
801
}
802
}
803
804
/**
805
* Install the bridge SpanProcessor on the SDK's global TracerProvider.
806
* Called once after the first session creation (when the SDK provider is ready).
807
*/
808
private _installBridgeIfNeeded(): void {
809
if (this._bridgeInstalled) {
810
return;
811
}
812
this._bridgeInstalled = true;
813
814
try {
815
// The SDK registered its BasicTracerProvider as the global provider.
816
// In OTel SDK v2, addSpanProcessor() was removed from BasicTracerProvider.
817
// We access the internal MultiSpanProcessor._spanProcessors array to inject
818
// our bridge. This is the same pattern the SDK itself uses in forceFlush().
819
const api = require('@opentelemetry/api') as typeof import('@opentelemetry/api');
820
const globalProvider = api.trace.getTracerProvider();
821
822
// Navigate: ProxyTracerProvider._delegate → BasicTracerProvider._activeSpanProcessor → MultiSpanProcessor._spanProcessors
823
const delegate = (globalProvider as unknown as Record<string, unknown>)._delegate ?? globalProvider;
824
const activeProcessor = (delegate as unknown as Record<string, unknown>)._activeSpanProcessor as Record<string, unknown> | undefined;
825
const processorArray = activeProcessor?._spanProcessors;
826
827
if (Array.isArray(processorArray)) {
828
this._bridgeProcessor = new CopilotCliBridgeSpanProcessor(this._otelService);
829
processorArray.push(this._bridgeProcessor);
830
this.logService.info('[CopilotCLISession] Bridge SpanProcessor installed on SDK TracerProvider');
831
} else {
832
this.logService.warn('[CopilotCLISession] Could not access SDK TracerProvider internals — debug panel will not show SDK spans');
833
}
834
} catch (err) {
835
this.logService.warn(`[CopilotCLISession] Failed to install bridge SpanProcessor: ${err}`);
836
}
837
}
838
839
private async shouldShowSession(sessionId: string, context?: SessionContext): Promise<boolean> {
840
if (isUntitledSessionId(sessionId)) {
841
return true;
842
}
843
844
if (!this.showExternalSessions) {
845
const sessionOrigin = await this._chatSessionMetadataStore.getSessionOrigin(sessionId);
846
if (sessionOrigin !== 'vscode') {
847
return false;
848
}
849
}
850
// If we're in an empty workspace then show all sessions.
851
if (this.workspaceService.getWorkspaceFolders().length === 0) {
852
return true;
853
}
854
if (this._agentSessionsWorkspace.isAgentSessionsWorkspace) {
855
return true;
856
}
857
// This session was started from a specified workspace (e.g. multiroot, untitled or other), hence continue showing it.
858
const sessionTrackerVisibility = this._sessionTracker.shouldShowSession(sessionId);
859
if (sessionTrackerVisibility.isWorkspaceSession) {
860
return true;
861
}
862
// Possible we have the workspace info in cli metadata.
863
if (context && (
864
(context.cwd && this.workspaceService.getWorkspaceFolder(URI.file(context.cwd))) ||
865
(context.gitRoot && this.workspaceService.getWorkspaceFolder(URI.file(context.gitRoot)))
866
)) {
867
return true;
868
}
869
// If we have a workspace folder for this and the workspace folder belongs to one of the open workspace folders, show it.
870
const workspaceFolder = await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);
871
if (workspaceFolder && this.workspaceService.getWorkspaceFolder(workspaceFolder)) {
872
return true;
873
}
874
// If we have a git worktree and the worktree's repo belongs to one of the workspace folders, show it.
875
const worktree = await this.worktreeManager.getWorktreeProperties(sessionId);
876
if (worktree && this.workspaceService.getWorkspaceFolder(URI.file(worktree.repositoryPath))) {
877
return true;
878
}
879
// If this is an old global session, show it if we don't have specific data to exclude it.
880
if (sessionTrackerVisibility.isOldGlobalSession && !workspaceFolder && !worktree && (this.workspaceService.getWorkspaceFolders().length === 0 || this._agentSessionsWorkspace.isAgentSessionsWorkspace)) {
881
return true;
882
}
883
return false;
884
}
885
886
protected async createSessionsOptions(options: ICreateSessionOptions & { mcpServers?: SessionOptions['mcpServers'] }): Promise<Readonly<SessionOptions>> {
887
const [agentInfos, skillLocations] = await Promise.all([
888
this.agents.getAgents(),
889
this.copilotCLISkills.getSkillsLocations(CancellationToken.None),
890
]);
891
const customAgents = agentInfos.map(i => i.agent);
892
const variablesContext = this._promptVariablesService.buildTemplateVariablesContext(options.sessionId, options.debugTargetSessionIds);
893
const systemMessage = variablesContext ? { mode: 'append' as const, content: variablesContext } : undefined;
894
895
const allOptions: SessionOptions = {
896
clientName: 'vscode',
897
integrationId: INTEGRATION_ID
898
};
899
900
const workingDirectory = getWorkingDirectory(options.workspace);
901
if (workingDirectory) {
902
allOptions.workingDirectory = workingDirectory.fsPath;
903
}
904
if (options.model) {
905
allOptions.model = options.model as unknown as SessionOptions['model'];
906
}
907
if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {
908
allOptions.mcpServers = options.mcpServers;
909
this.logService.info(`[CopilotCLISession] Passing ${Object.keys(options.mcpServers).length} MCP server(s) to SDK: [${Object.keys(options.mcpServers).join(', ')}]`);
910
for (const [id, cfg] of Object.entries(options.mcpServers)) {
911
this.logService.info(`[CopilotCLISession] ${id}: type=${cfg.type}`);
912
}
913
} else {
914
this.logService.info('[CopilotCLISession] No MCP servers to pass to SDK');
915
}
916
if (skillLocations.length > 0) {
917
allOptions.skillDirectories = skillLocations.map(uri => uri.fsPath);
918
}
919
if (options.mcpServerMappings?.size && customAgents && options.mcpServers) {
920
remapCustomAgentTools(customAgents, options.mcpServerMappings, options.mcpServers, options.agent);
921
}
922
if (options.agent) {
923
allOptions.selectedCustomAgent = options.agent;
924
}
925
if (customAgents.length > 0) {
926
allOptions.customAgents = customAgents;
927
}
928
allOptions.enableStreaming = true;
929
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
930
if (copilotUrl) {
931
allOptions.copilotUrl = copilotUrl;
932
}
933
if (systemMessage) {
934
allOptions.systemMessage = systemMessage;
935
}
936
allOptions.sessionCapabilities = new Set(['plan-mode', 'memory', 'cli-documentation', 'ask-user', 'interactive-mode', 'system-notifications']);
937
if (options.reasoningEffort && this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled)) {
938
allOptions.reasoningEffort = options.reasoningEffort;
939
}
940
941
return allOptions as Readonly<SessionOptions>;
942
}
943
944
public async getSession(options: IGetSessionOptions, token: CancellationToken): Promise<RefCountedSession | undefined> {
945
// https://github.com/microsoft/vscode/issues/276573
946
const lock = this.sessionMutexForGetSession.get(options.sessionId) ?? new Mutex();
947
this.sessionMutexForGetSession.set(options.sessionId, lock);
948
const lockDisposable = await lock.acquire(token);
949
try {
950
{
951
const session = this._sessionWrappers.get(options.sessionId);
952
if (session) {
953
this.logService.trace(`[CopilotCLISession] Reusing CopilotCLI session ${options.sessionId}.`);
954
this._partialSessionHistories.delete(options.sessionId);
955
session.acquire();
956
if (options.agent) {
957
await session.object.sdkSession.selectCustomAgent(options.agent.name);
958
} else {
959
session.object.sdkSession.clearCustomAgent();
960
}
961
return session;
962
}
963
}
964
965
const [sessionManager, { mcpConfig: mcpServers, disposable: mcpGateway }] = await Promise.all([
966
raceCancellationError(this.getSessionManager(), token),
967
this.mcpHandler.loadMcpConfig(SessionIdForCLI.getResource(options.sessionId)),
968
]);
969
try {
970
const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers });
971
972
const sdkSession = await sessionManager.getSession({ ...sessionOptions, sessionId: options.sessionId }, true);
973
if (!sdkSession) {
974
this.logService.error(`[CopilotCLISession] CopilotCLI failed to get session ${options.sessionId}.`);
975
return undefined;
976
}
977
await sessionManager.loadDeferredRepoHooks(sdkSession);
978
const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager);
979
session.object.add(mcpGateway);
980
return session;
981
}
982
catch (error) {
983
mcpGateway.dispose();
984
throw error;
985
}
986
} finally {
987
lockDisposable?.dispose();
988
}
989
}
990
public async getChatHistory({ sessionId, workspace }: { sessionId: string; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]> {
991
const { history } = await this.getChatHistoryImpl({ sessionId, workspace }, token);
992
return history;
993
}
994
995
private async getChatHistoryImpl({ sessionId, workspace }: { sessionId: string; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<{ history: (ChatRequestTurn2 | ChatResponseTurn2)[]; events: readonly SessionEvent[] }> {
996
const requestDetailsPromise = this._chatSessionMetadataStore.getRequestDetails(sessionId);
997
const agentIdPromise = this._chatSessionMetadataStore.getSessionAgent(sessionId);
998
const sessionManager = await raceCancellation(this.getSessionManager(), token);
999
1000
if (!sessionManager || token.isCancellationRequested) {
1001
requestDetailsPromise.catch(error => {/** */ });
1002
agentIdPromise.catch(error => {/** */ });
1003
return { history: [], events: [] };
1004
}
1005
1006
let events: readonly SessionEvent[] = [];
1007
let modelId: string | undefined = undefined;
1008
1009
// Try to shutdown session as soon as possible.
1010
const existingSession = this._sessionWrappers.get(sessionId)?.object?.sdkSession;
1011
if (existingSession) {
1012
modelId = await existingSession.getSelectedModel();
1013
events = existingSession.getEvents();
1014
} else {
1015
let shutdown = Promise.resolve();
1016
try {
1017
const session = await sessionManager.getSession({ sessionId }, false);
1018
if (!session) {
1019
return { history: [], events: [] };
1020
}
1021
modelId = await session.getSelectedModel();
1022
events = session.getEvents();
1023
shutdown = sessionManager.closeSession(sessionId).catch(error => {
1024
this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after fetching chat history: ${error}`);
1025
});
1026
} finally {
1027
await shutdown;
1028
}
1029
}
1030
1031
const [agentId, storedDetails] = await Promise.all([agentIdPromise, requestDetailsPromise]);
1032
1033
// Build lookup from copilotRequestId → RequestDetails for the callback
1034
const customAgentLookup = await this.createCustomAgentLookup();
1035
const legacyMappings: RequestDetails[] = [];
1036
const detailsByCopilotId = new Map<string, RequestIdDetails>();
1037
const defaultModeInstructions = agentId ? await this.resolveAgentModeInstructions(agentId, customAgentLookup) : undefined;
1038
1039
for (const d of storedDetails) {
1040
if (d.copilotRequestId) {
1041
const modeInstructions = d.modeInstructions ?? await this.resolveAgentModeInstructions(d.agentId, customAgentLookup) ?? defaultModeInstructions;
1042
detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions });
1043
}
1044
}
1045
1046
const getVSCodeRequestId = (sdkRequestId: string) => {
1047
const stored = detailsByCopilotId.get(sdkRequestId);
1048
if (stored) {
1049
return stored;
1050
}
1051
const mapping = this.copilotCLISDK.getRequestId(sdkRequestId);
1052
if (mapping) {
1053
detailsByCopilotId.set(sdkRequestId, mapping);
1054
legacyMappings.push({
1055
copilotRequestId: sdkRequestId,
1056
vscodeRequestId: mapping.requestId,
1057
toolIdEditMap: mapping.toolIdEditMap,
1058
});
1059
}
1060
return mapping;
1061
};
1062
1063
const lastResponseDetails = await this.getModelDetailsString(modelId);
1064
const history = buildChatHistoryFromEvents(sessionId, modelId, events, getVSCodeRequestId, this._delegationSummaryService, this.logService, getWorkingDirectory(workspace), defaultModeInstructions, lastResponseDetails);
1065
1066
if (legacyMappings.length > 0) {
1067
void this._chatSessionMetadataStore.updateRequestDetails(sessionId, legacyMappings).catch(error => {
1068
this.logService.error(`[CopilotCLISession] Failed to update chat session metadata store with legacy mappings for session ${sessionId}`, error);
1069
});
1070
}
1071
1072
return { history, events };
1073
}
1074
1075
private async createCustomAgentLookup(): Promise<Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>> {
1076
const agents = await this._promptsService.getCustomAgents(CancellationToken.None);
1077
const lookup = new Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>();
1078
for (const agent of agents) {
1079
if (!agent.enabled || !isEnabledForCopilotCLI(agent)) {
1080
continue;
1081
}
1082
const lazyContent = new Lazy(() => this._promptsService.parseFile(agent.uri, CancellationToken.None).then(parsed => parsed.body?.getContent() ?? ''));
1083
const keys = [
1084
agent.name?.trim(),
1085
agent.uri.toString(),
1086
getAgentFileNameFromFilePath(agent.uri),
1087
];
1088
for (const key of keys) {
1089
if (key && !lookup.has(key)) {
1090
lookup.set(key, [agent, lazyContent]);
1091
}
1092
}
1093
}
1094
return lookup;
1095
}
1096
1097
private async resolveAgentModeInstructions(agentId: string | undefined, customAgentLookup: Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>): Promise<StoredModeInstructions | undefined> {
1098
if (!agentId) {
1099
return undefined;
1100
}
1101
const agentEntry = customAgentLookup.get(agentId);
1102
if (!agentEntry) {
1103
return undefined;
1104
}
1105
const [agent, lazyContent] = agentEntry;
1106
return {
1107
uri: agent.uri.toString(),
1108
name: agent.name?.trim() || agentId,
1109
content: await lazyContent.value,
1110
};
1111
}
1112
1113
private async getModelDetailsString(modelId: string | undefined): Promise<string | undefined> {
1114
if (!modelId) {
1115
return undefined;
1116
}
1117
const models = await this._copilotCLIModels.getModels().catch(ex => {
1118
this.logService.error(ex, 'Failed to get models');
1119
return [];
1120
});
1121
const modelInfo = models.find(m => m.id === modelId);
1122
return modelInfo ? formatModelDetails(modelInfo) : undefined;
1123
}
1124
1125
1126
/**
1127
* Fork an existing session using the SDK's `forkSession` API.
1128
*
1129
* The SDK handles copying the event log and (optionally) truncating to a boundary event.
1130
* This method additionally stores VS Code-specific workspace metadata and custom title.
1131
*
1132
* Returns the id of the forked session.
1133
*/
1134
public async forkSession({ sessionId, requestId, workspace }: { sessionId: string; requestId: string | undefined; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<string> {
1135
// Resolve the SDK event ID boundary for truncation BEFORE forking.
1136
// We need the source session's history and request details to translate the VS Code requestId
1137
// into the SDK event ID that the SDK's forkSession accepts.
1138
const [sessionManager, title, { history, events: originalSessionEvents }] = await Promise.all([
1139
raceCancellationError(this.getSessionManager(), token),
1140
this.getSessionTitle(sessionId, token),
1141
requestId ? this.getChatHistoryImpl({ sessionId, workspace }, token) : Promise.resolve({ history: [], events: [] }),
1142
]);
1143
1144
let toEventId: string | undefined;
1145
if (requestId) {
1146
const requestToTruncateTo = history.find(event => event instanceof ChatRequestTurn2 && event.id === requestId);
1147
if (requestToTruncateTo) {
1148
const storedDetails = await this._chatSessionMetadataStore.getRequestDetails(sessionId);
1149
const translatedSDKEvent = storedDetails.find(d => d.vscodeRequestId === requestToTruncateTo.id || d.copilotRequestId === requestToTruncateTo.id)?.copilotRequestId;
1150
const sdkEvent = originalSessionEvents.find(e => e.type === 'user.message' && e.id === requestToTruncateTo.id)?.id;
1151
toEventId = translatedSDKEvent ?? sdkEvent;
1152
if (!toEventId) {
1153
this.logService.warn(`[CopilotCLISession] Cannot find SDK event id for request id ${requestId} in session ${sessionId}. Will fork without truncation.`);
1154
}
1155
} else {
1156
this.logService.warn(`[CopilotCLISession] Failed to find request ${requestId} in session ${sessionId} history. Will fork without truncation.`);
1157
}
1158
}
1159
1160
const { sessionId: newSessionId } = await sessionManager.forkSession(sessionId, toEventId);
1161
this._sessionsBeingCreatedViaFork.add(newSessionId);
1162
try {
1163
const forkedTitlePrefix = l10n.t("Forked: ");
1164
const customTitle = title.startsWith(forkedTitlePrefix) ? title : l10n.t("Forked: {0}", title);
1165
await this._chatSessionMetadataStore.storeForkedSessionMetadata(sessionId, newSessionId, customTitle);
1166
1167
this._onDidChangeSessions.fire();
1168
this._onDidCreateSession.fire({
1169
id: newSessionId,
1170
label: customTitle,
1171
timing: { created: Date.now(), startTime: Date.now() },
1172
workingDirectory: getWorkingDirectory(workspace)
1173
});
1174
1175
return newSessionId;
1176
} finally {
1177
this._sessionsBeingCreatedViaFork.delete(newSessionId);
1178
}
1179
}
1180
public async tryGetPartialSessionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined> {
1181
const cached = this._partialSessionHistories.get(sessionId);
1182
if (cached) {
1183
return cached;
1184
}
1185
1186
try {
1187
const events = await readSessionEventsFile(sessionId);
1188
1189
const sessionStartEvent = events.find((event): event is Extract<SessionEvent, { type: 'session.start' }> => event.type === 'session.start');
1190
const workingDirectory = sessionStartEvent?.data.context?.cwd;
1191
if (workingDirectory) {
1192
this._sessionWorkingDirectories.set(sessionId, URI.file(workingDirectory));
1193
}
1194
1195
const history = buildChatHistoryFromEvents(sessionId, undefined, events, () => undefined, this._delegationSummaryService, this.logService, workingDirectory ? URI.file(workingDirectory) : undefined);
1196
this._partialSessionHistories.set(sessionId, history);
1197
return history;
1198
} catch (error) {
1199
this.logService.warn(`[CopilotCLISession] Failed to reconstruct partial session ${sessionId}: ${error}`);
1200
return undefined;
1201
}
1202
}
1203
1204
private async getFirstUserMessageFromSession(sessionId: string, token: CancellationToken): Promise<string | undefined> {
1205
const cached = await this._chatSessionMetadataStore.getSessionFirstUserMessage(sessionId);
1206
if (typeof cached === 'string') {
1207
return cached;
1208
}
1209
1210
let firstUserMessage: string | undefined;
1211
try {
1212
const events = await raceCancellation(readSessionEventsFile(sessionId, 'user.message'), token);
1213
if (events?.length) {
1214
// Find the first user message and use that as the title.
1215
firstUserMessage = events.find((msg: SessionEvent) => msg.type === 'user.message')?.data.content;
1216
}
1217
} catch (error) {
1218
this.logService.warn(`[CopilotCLISession] Failed to get session title for session ${sessionId}: ${error}`);
1219
}
1220
1221
if (!firstUserMessage) {
1222
try {
1223
const { events } = await this.getChatHistoryImpl({ sessionId, workspace: emptyWorkspaceInfo() }, token);
1224
firstUserMessage = events.find((msg: SessionEvent) => msg.type === 'user.message')?.data.content;
1225
} catch (error) {
1226
this.logService.warn(`[CopilotCLISession] Failed to load session for first user message ${sessionId}: ${error}`);
1227
}
1228
}
1229
1230
this._chatSessionMetadataStore.setSessionFirstUserMessage(sessionId, firstUserMessage ?? '').catch(err => {
1231
this.logService.warn(`[CopilotCLISession] Failed to store first user message for session ${sessionId}: ${err}`);
1232
});
1233
1234
return firstUserMessage;
1235
}
1236
1237
private createCopilotSession(sdkSession: Session, workspaceInfo: IWorkspaceInfo, agentName: string | undefined, sessionManager: internal.LocalSessionManager): RefCountedSession {
1238
sdkSession.setPermissionsRequired(true);
1239
const session = this.instantiationService.createInstance(CopilotCLISession, workspaceInfo, agentName, sdkSession, []);
1240
this._debugFileLogger.startSession(session.sessionId).catch(err => {
1241
this.logService.error('[CopilotCLISession] Failed to start debug log session', err);
1242
});
1243
session.add(toDisposable(() => {
1244
this._debugFileLogger.endSession(session.sessionId).catch(err => {
1245
this.logService.error('[CopilotCLISession] Failed to end debug log session', err);
1246
});
1247
}));
1248
// Wire the bridge processor so the session can register traceId → sessionId mappings
1249
session.setBridgeProcessor(this._bridgeProcessor);
1250
// Wire SDK trace context updater so the session can propagate traceparent to SDK spans
1251
const otelLifecycle = sessionManager.otel;
1252
if (otelLifecycle) {
1253
session.setSdkTraceContextUpdater((traceparent, tracestate) =>
1254
otelLifecycle.updateParentTraceContext(sdkSession.sessionId, traceparent, tracestate));
1255
}
1256
session.add(session.onDidChangeStatus(() => {
1257
this.triggerOnDidChangeSessionItem(sdkSession.sessionId, 'statusChange');
1258
this._onDidChangeSessions.fire();
1259
}));
1260
session.add(toDisposable(() => {
1261
this._sessionWrappers.deleteAndLeak(sdkSession.sessionId);
1262
this.sessionMutexForGetSession.delete(sdkSession.sessionId);
1263
(async () => {
1264
if (sdkSession.isAbortable()) {
1265
await sdkSession.abort().catch(error => {
1266
this.logService.error(`Failed to abort session ${sdkSession.sessionId}: ${error}`);
1267
});
1268
}
1269
await sessionManager.closeSession(sdkSession.sessionId).catch(error => {
1270
this.logService.error(`Failed to close session ${sdkSession.sessionId}: ${error}`);
1271
});
1272
this._onDidCloseSession.fire(sdkSession.sessionId);
1273
})();
1274
}));
1275
1276
const refCountedSession = new RefCountedSession(session);
1277
this._sessionWrappers.set(sdkSession.sessionId, refCountedSession);
1278
return refCountedSession;
1279
}
1280
1281
public async deleteSession(sessionId: string): Promise<void> {
1282
this._sessionLabels.delete(sessionId);
1283
this._partialSessionHistories.delete(sessionId);
1284
this._sessionWorkingDirectories.delete(sessionId);
1285
try {
1286
{
1287
const session = this._sessionWrappers.get(sessionId);
1288
if (session) {
1289
session.dispose();
1290
this.logService.warn(`Delete an active session ${sessionId}.`);
1291
}
1292
}
1293
1294
// Delete from session manager first
1295
const sessionManager = await this.getSessionManager();
1296
await sessionManager.deleteSession(sessionId);
1297
1298
} catch (error) {
1299
this.logService.error(`Failed to delete session ${sessionId}: ${error}`);
1300
} finally {
1301
this._sessionWrappers.deleteAndLeak(sessionId);
1302
// Possible the session was deleted in another vscode session or the like.
1303
this._onDidChangeSessions.fire();
1304
this._onDidDeleteSession.fire(sessionId);
1305
}
1306
}
1307
1308
private async updateSdkSessionMetadata(sessionId: string, title: string, operation: (sdkSession: LocalSession) => Promise<void>): Promise<void> {
1309
let sessionManager: internal.LocalSessionManager | undefined;
1310
let shouldCloseSession = false;
1311
const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSession | undefined) ?? await (async () => {
1312
sessionManager = await this.getSessionManager();
1313
const session = await sessionManager.getSession({ sessionId }, true) as LocalSession | undefined;
1314
shouldCloseSession = !!session;
1315
return session;
1316
})();
1317
1318
if (!sdkSession) {
1319
// SDK session not yet materialized (e.g. brand-new VS Code sessionId).
1320
// Stage locally; `createSession` syncs it into the SDK once the session is created.
1321
await this.customSessionTitleService.setCustomSessionTitle(sessionId, title);
1322
return;
1323
}
1324
1325
try {
1326
await operation(sdkSession);
1327
} finally {
1328
if (shouldCloseSession && sessionManager) {
1329
await sessionManager.closeSession(sessionId).catch(error => {
1330
this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after updating title metadata: ${error}`);
1331
});
1332
}
1333
}
1334
}
1335
1336
public async renameSession(sessionId: string, title: string): Promise<void> {
1337
await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.renameSession(title));
1338
this._sessionLabels.delete(sessionId);
1339
this._onDidChangeSessions.fire();
1340
}
1341
1342
public async updateSessionSummary(sessionId: string, title: string): Promise<void> {
1343
await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.updateSessionSummary(title));
1344
// Invalidate the derived-label cache so a subsequent title resolution
1345
// can pick up the freshly-written summary instead of returning a stale
1346
// label that was extracted from session history on a prior pass.
1347
this._sessionLabels.delete(sessionId);
1348
this._onDidChangeSessions.fire();
1349
}
1350
}
1351
1352
async function readSessionEventsFile(sessionId: string, findFirstEventType?: string): Promise<SessionEvent[]> {
1353
const sessionDirPath = getCopilotCLISessionDir(sessionId);
1354
const sessionDir = URI.file(sessionDirPath);
1355
const eventsFile = joinPath(sessionDir, 'events.jsonl');
1356
1357
const events: SessionEvent[] = [];
1358
const stream = createReadStream(eventsFile.fsPath, { encoding: 'utf8' });
1359
const reader = createInterface({
1360
input: stream,
1361
crlfDelay: Infinity,
1362
});
1363
try {
1364
for await (const line of reader) {
1365
if (line.trim().length === 0) {
1366
continue;
1367
}
1368
const sessionEvent = JSON.parse(line) as SessionEvent;
1369
events.push(sessionEvent);
1370
if (findFirstEventType && sessionEvent.type === findFirstEventType) {
1371
break;
1372
}
1373
}
1374
} finally {
1375
reader.close();
1376
stream.close();
1377
}
1378
1379
return events;
1380
}
1381
1382
export class CopilotCLISessionWorkspaceTracker {
1383
private readonly _initializeSessionStorageFiles: Lazy<Promise<{ global: Uri; workspace: Uri }>>;
1384
private _oldGlobalSessions?: Set<string>;
1385
private readonly _workspaceSessions = new Set<string>();
1386
constructor(
1387
@IFileSystemService private readonly fileSystem: IFileSystemService,
1388
@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,
1389
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
1390
) {
1391
this._initializeSessionStorageFiles = new Lazy<Promise<{ global: Uri; workspace: Uri }>>(async () => {
1392
const globalFile = joinPath(this.context.globalStorageUri, 'copilot.cli.oldGlobalSessions.json');
1393
let workspaceFile = joinPath(this.context.globalStorageUri, 'copilot.cli.workspaceSessions.json');
1394
// If we have workspace folders, track workspace sessions separately. Otherwise treat them as global sessions.
1395
if (this.workspaceService.getWorkspaceFolders().length) {
1396
let workspaceFileName = this.context.workspaceState.get<string | undefined>(COPILOT_CLI_WORKSPACE_JSON_FILE_KEY);
1397
if (!workspaceFileName) {
1398
workspaceFileName = `copilot.cli.workspaceSessions.${generateUuid()}.json`;
1399
await this.context.workspaceState.update(COPILOT_CLI_WORKSPACE_JSON_FILE_KEY, workspaceFileName);
1400
}
1401
workspaceFile = joinPath(this.context.globalStorageUri, workspaceFileName);
1402
}
1403
1404
await Promise.all([
1405
// Load old sessions
1406
(async () => {
1407
const oldSessions = await this.fileSystem.readFile(globalFile).then(c => new TextDecoder().decode(c).split(',')).catch(() => undefined);
1408
if (oldSessions) {
1409
this._oldGlobalSessions = new Set<string>(oldSessions);
1410
}
1411
})(),
1412
// Load workspace sessions
1413
(async () => {
1414
const workspaceSessions = this.workspaceService.getWorkspaceFolders().length ?
1415
await this.fileSystem.readFile(workspaceFile).then(c => new TextDecoder().decode(c).split(',')).catch(() => []) : [];
1416
workspaceSessions.forEach(s => this._workspaceSessions.add(s));
1417
})(),
1418
]);
1419
1420
return { global: globalFile, workspace: workspaceFile };
1421
});
1422
void this._initializeSessionStorageFiles.value;
1423
}
1424
1425
public async initialize(): Promise<void> {
1426
await this._initializeSessionStorageFiles.value;
1427
}
1428
1429
/**
1430
* InitializeOldSessions should have been called before this.
1431
*/
1432
public shouldShowSession(sessionId: string): { isOldGlobalSession?: boolean; isWorkspaceSession?: boolean } {
1433
return {
1434
isOldGlobalSession: this._oldGlobalSessions?.has(sessionId),
1435
isWorkspaceSession: this._workspaceSessions.has(sessionId),
1436
};
1437
}
1438
}
1439
1440
function labelFromPrompt(prompt: string): string {
1441
// Strip system reminders from the prompt
1442
return stripReminders(prompt);
1443
}
1444
1445
/**
1446
* Extracts the session ID from a deleted events.jsonl file path.
1447
* Expected path format: <sessionDir>/<sessionId>/events.jsonl
1448
*/
1449
function extractSessionIdFromEventPath(sessionDir: URI, deletedFileUri: URI): string | undefined {
1450
if (basename(deletedFileUri) !== 'events.jsonl') {
1451
return undefined;
1452
}
1453
const parentDir = dirname(deletedFileUri);
1454
const parentOfParent = dirname(parentDir);
1455
if (parentOfParent.path !== sessionDir.path) {
1456
return undefined;
1457
}
1458
return basename(parentDir);
1459
}
1460
1461
export class Mutex {
1462
private _locked = false;
1463
private readonly _acquireQueue: (() => void)[] = [];
1464
1465
isLocked(): boolean {
1466
return this._locked;
1467
}
1468
1469
// Acquire the lock; resolves with a release function you MUST call.
1470
acquire(token: CancellationToken): Promise<IDisposable | undefined> {
1471
return raceCancellation(new Promise<IDisposable | undefined>(resolve => {
1472
const tryAcquire = () => {
1473
if (token.isCancellationRequested) {
1474
resolve(undefined);
1475
return;
1476
}
1477
if (!this._locked) {
1478
this._locked = true;
1479
resolve(toDisposable(() => this._release()));
1480
} else {
1481
this._acquireQueue.push(tryAcquire);
1482
}
1483
};
1484
tryAcquire();
1485
}), token);
1486
}
1487
1488
private _release(): void {
1489
if (!this._locked) {
1490
// already unlocked
1491
return;
1492
}
1493
this._locked = false;
1494
const next = this._acquireQueue.shift();
1495
if (next) {
1496
next();
1497
}
1498
}
1499
}
1500
1501
export class RefCountedSession extends RefCountedDisposable implements IReference<CopilotCLISession> {
1502
constructor(public readonly object: CopilotCLISession) {
1503
super(object);
1504
}
1505
dispose(): void {
1506
this.release();
1507
}
1508
}
1509
1510