Path: blob/main/extensions/copilot/src/extension/chatSessions/copilotcli/node/copilotcliSessionService.ts
13405 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type { AutoModeSessionManager as SDKAutoModeSessionManager, AutoModeSessionResult, internal, LocalSession, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';6import * as l10n from '@vscode/l10n';7import { createReadStream } from 'node:fs';8import { devNull } from 'node:os';9import { createInterface } from 'node:readline';10import type { ChatCustomAgent, ChatRequest, ChatSessionItem } from 'vscode';11import { IChatDebugFileLoggerService } from '../../../../platform/chat/common/chatDebugFileLoggerService';12import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService';13import { INativeEnvService } from '../../../../platform/env/common/envService';14import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';15import { IFileSystemService } from '../../../../platform/filesystem/common/fileSystemService';16import { RelativePattern } from '../../../../platform/filesystem/common/fileTypes';17import { ILogService } from '../../../../platform/log/common/logService';18import { deriveCopilotCliOTelEnv } from '../../../../platform/otel/common/agentOTelEnv';19import { IOTelService } from '../../../../platform/otel/common/otelService';20import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';21import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';22import { createServiceIdentifier } from '../../../../util/common/services';23import { coalesce } from '../../../../util/vs/base/common/arrays';24import { disposableTimeout, raceCancellation, raceCancellationError, SequencerByKey, ThrottledDelayer } from '../../../../util/vs/base/common/async';25import { CancellationToken } from '../../../../util/vs/base/common/cancellation';26import { Emitter, Event } from '../../../../util/vs/base/common/event';27import { Lazy } from '../../../../util/vs/base/common/lazy';28import { Disposable, DisposableMap, IDisposable, IReference, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';29import { basename, dirname, joinPath } from '../../../../util/vs/base/common/resources';30import { URI } from '../../../../util/vs/base/common/uri';31import { generateUuid } from '../../../../util/vs/base/common/uuid';32import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';33import { ChatRequestTurn2, ChatResponseTurn2, ChatSessionStatus, Uri } from '../../../../vscodeTypes';34import { IPromptVariablesService } from '../../../prompt/node/promptVariablesService';35import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';36import { IChatSessionMetadataStore, RequestDetails, StoredModeInstructions } from '../../common/chatSessionMetadataStore';37import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';38import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';39import { isUntitledSessionId } from '../../common/utils';40import { emptyWorkspaceInfo, getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo';41import { buildChatHistoryFromEvents, RequestIdDetails, stripReminders } from '../common/copilotCLITools';42import { ICustomSessionTitleService } from '../common/customSessionTitleService';43import { IChatDelegationSummaryService } from '../common/delegationSummaryService';44import { SessionIdForCLI } from '../common/utils';45import { getCopilotCLISessionDir } from './cliHelpers';46import { formatModelDetails, getAgentFileNameFromFilePath, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isEnabledForCopilotCLI } from './copilotCli';47import { CopilotCliBridgeSpanProcessor } from './copilotCliBridgeSpanProcessor';48import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession';49import { ICopilotCLISkills } from './copilotCLISkills';50import { ICopilotCLIMCPHandler, McpServerMappings, remapCustomAgentTools } from './mcpHandler';51import { INTEGRATION_ID } from '../../../../platform/endpoint/common/licenseAgreement';525354const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';55const AUTO_MODE_REFRESH_LEAD_TIME_MS = 300 * 1000;5657type SDKPackage = Awaited<ReturnType<ICopilotCLISDK['getPackage']>>;58type AutoModeResolveArgs = Parameters<SDKAutoModeSessionManager['resolve']>[0];59type AutoModeResolveResult = Awaited<ReturnType<SDKAutoModeSessionManager['resolve']>>;60type AutoModeListener = Parameters<SDKAutoModeSessionManager['subscribe']>[0];6162class AutoModeSessionManagerCompat {6364private current: AutoModeSessionResult | undefined;65private previousConcreteModel: string | undefined;66private inflight: Promise<AutoModeResolveResult> | undefined;67private readonly listeners = new Set<AutoModeListener>();6869constructor(private readonly sdkPackage: Pick<SDKPackage, 'AutoModeUnavailableError' | 'AutoModeUnsupportedError' | 'acquireAutoModeSession' | 'isAutoModel' | 'refreshAutoModeSession'>) { }7071recordPreviousConcreteModel(modelId: string | undefined): void {72if (modelId && !this.sdkPackage.isAutoModel(modelId)) {73this.previousConcreteModel = modelId;74}75}7677getLastResolved(): string | undefined {78return this.current?.selectedModel;79}8081getDiscountPercent(): number | undefined {82const discountedCosts = this.current?.discountedCosts;83if (!discountedCosts) {84return undefined;85}8687const selectedModelDiscount = this.current?.selectedModel ? discountedCosts[this.current.selectedModel] : undefined;88if (selectedModelDiscount !== undefined) {89return Math.round(selectedModelDiscount * 100);90}9192const allDiscounts = Object.values(discountedCosts);93if (allDiscounts.length === 0) {94return undefined;95}9697return Math.round((allDiscounts.reduce((sum, discount) => sum + discount, 0) / allDiscounts.length) * 100);98}99100getPreviousConcreteModel(): string | undefined {101return this.previousConcreteModel;102}103104subscribe(listener: AutoModeListener): () => void {105this.listeners.add(listener);106return () => {107this.listeners.delete(listener);108};109}110111async resolve(args: AutoModeResolveArgs): Promise<AutoModeResolveResult> {112if (this.isFresh() && this.current) {113const current = this.current;114this.applySessionToken(args.settings, current.sessionToken);115return { modelId: current.selectedModel, sessionToken: current.sessionToken };116}117118if (this.inflight) {119const resolved = await this.inflight;120if (resolved) {121this.applySessionToken(args.settings, resolved.sessionToken);122}123124return resolved;125}126127this.inflight = this.doResolve(args).finally(() => {128this.inflight = undefined;129});130131return this.inflight;132}133134clear(settings?: AutoModeResolveArgs['settings']): void {135this.current = undefined;136if (settings) {137this.clearSessionToken(settings);138}139this.notify();140}141142handleModelChange(prevModel: string | undefined, nextModel: string, settings?: AutoModeResolveArgs['settings']): void {143if (this.sdkPackage.isAutoModel(nextModel) && !this.sdkPackage.isAutoModel(prevModel)) {144this.recordPreviousConcreteModel(prevModel);145} else if (!this.sdkPackage.isAutoModel(nextModel) && this.sdkPackage.isAutoModel(prevModel)) {146this.clear(settings);147}148}149150private notify(): void {151const resolvedModel = this.current?.selectedModel;152const discountPercent = this.getDiscountPercent();153for (const listener of this.listeners) {154try {155listener(resolvedModel, discountPercent);156} catch {157// Ignore listener failures to mirror the SDK manager behavior.158}159}160}161162private async doResolve(args: AutoModeResolveArgs): Promise<AutoModeResolveResult> {163const { logger, settings } = args;164165if (this.current) {166try {167const refreshed = await this.sdkPackage.refreshAutoModeSession({ ...args, existingToken: this.current.sessionToken });168this.current = refreshed;169this.applySessionToken(settings, refreshed.sessionToken);170this.notify();171return { modelId: refreshed.selectedModel, sessionToken: refreshed.sessionToken };172} catch (error) {173if (this.isUnauthorizedError(error)) {174logger.debug('Auto-mode refresh unauthorized; acquiring a new session');175} else if (error instanceof this.sdkPackage.AutoModeUnsupportedError) {176logger.debug(`Auto-mode refresh unsupported: ${error.message}`);177this.current = undefined;178this.notify();179return undefined;180} else if (error instanceof this.sdkPackage.AutoModeUnavailableError) {181logger.debug(`Auto-mode unavailable during refresh: ${error.message}`);182this.current = undefined;183this.notify();184return undefined;185} else {186logger.debug(`Auto-mode refresh failed; reusing last token until expiry: ${this.formatError(error)}`);187this.applySessionToken(settings, this.current.sessionToken);188return { modelId: this.current.selectedModel, sessionToken: this.current.sessionToken };189}190}191}192193try {194const acquired = await this.sdkPackage.acquireAutoModeSession(args);195this.current = acquired;196this.applySessionToken(settings, acquired.sessionToken);197this.notify();198logger.debug(`Auto-mode session acquired: selected_model=${acquired.selectedModel}${acquired.expiresAt ? ` expires_at=${acquired.expiresAt}` : ''}`);199return { modelId: acquired.selectedModel, sessionToken: acquired.sessionToken };200} catch (error) {201if (error instanceof this.sdkPackage.AutoModeUnsupportedError) {202logger.debug(`Auto-mode unsupported: ${error.message}`);203return undefined;204}205206if (error instanceof this.sdkPackage.AutoModeUnavailableError) {207logger.debug(`Auto-mode unavailable: ${error.message}`);208return undefined;209}210211logger.debug(`Auto-mode acquire failed: ${this.formatError(error)}`);212return undefined;213}214}215216private isFresh(): boolean {217return this.current ? (this.current.expiresAt ? this.current.expiresAt * 1000 - Date.now() > AUTO_MODE_REFRESH_LEAD_TIME_MS : true) : false;218}219220private isUnauthorizedError(error: unknown): error is { kind: 'unauthorized' } {221return typeof error === 'object' && error !== null && 'kind' in error && error.kind === 'unauthorized';222}223224private applySessionToken(settings: AutoModeResolveArgs['settings'], sessionToken: string): void {225if (!settings) {226return;227}228229settings.api ??= {};230settings.api.copilot ??= {};231settings.api.copilot.capiSessionToken = sessionToken;232}233234private clearSessionToken(settings: AutoModeResolveArgs['settings']): void {235if (settings?.api?.copilot) {236delete settings.api.copilot.capiSessionToken;237}238}239240private formatError(error: unknown): string {241return error instanceof Error ? error.message : String(error);242}243}244245export interface ICopilotCLISessionItem {246readonly id: string;247readonly label: string;248readonly timing: ChatSessionItem['timing'];249readonly status?: ChatSessionStatus;250readonly workingDirectory?: Uri;251}252export type ExtendedChatRequest = ChatRequest & { prompt: string };253export type ISessionOptions = {254model?: string;255reasoningEffort?: string;256workspace: IWorkspaceInfo;257agent?: SweCustomAgent;258debugTargetSessionIds?: readonly string[];259mcpServerMappings?: McpServerMappings;260additionalWorkspaces?: IWorkspaceInfo[];261sessionParentId?: string;262};263export type IGetSessionOptions = ISessionOptions & { sessionId: string };264export type ICreateSessionOptions = ISessionOptions & { sessionId?: string };265266export interface ICopilotCLISessionService {267readonly _serviceBrand: undefined;268269/**270* @deprecated Kept only for non-controller API271*/272onDidChangeSessions: Event<void>;273onDidDeleteSession: Event<string>;274onDidChangeSession: Event<ICopilotCLISessionItem>;275onDidCreateSession: Event<ICopilotCLISessionItem>;276277getSessionWorkingDirectory(sessionId: string): Uri | undefined;278279// Session metadata querying280getSessionItem(sessionId: string, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined>;281getSessionTitle(sessionId: string, token: CancellationToken): Promise<string>;282getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]>;283284// SDK session management285createNewSessionId(): string;286isNewSessionId(sessionId: string): boolean;287deleteSession(sessionId: string): Promise<void>;288289// Session rename290renameSession(sessionId: string, title: string): Promise<void>;291updateSessionSummary(sessionId: string, title: string): Promise<void>;292293// Session wrapper tracking294getSession(options: IGetSessionOptions, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;295createSession(options: ICreateSessionOptions, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;296getChatHistory(options: { sessionId: string; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]>;297forkSession(options: { sessionId: string; requestId: string | undefined; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<string>;298tryGetPartialSessionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined>;299}300301export const ICopilotCLISessionService = createServiceIdentifier<ICopilotCLISessionService>('ICopilotCLISessionService');302303export class CopilotCLISessionService extends Disposable implements ICopilotCLISessionService {304declare _serviceBrand: undefined;305306private _sessionManager: Lazy<Promise<internal.LocalSessionManager>>;307private _sessionWrappers = new DisposableMap<string, RefCountedSession>();308private readonly _partialSessionHistories = new Map<string, readonly (ChatRequestTurn2 | ChatResponseTurn2)[]>();309310311private readonly _onDidChangeSessions = this._register(new Emitter<void>());312public readonly onDidChangeSessions = this._onDidChangeSessions.event;313314private readonly _onDidDeleteSession = this._register(new Emitter<string>());315public readonly onDidDeleteSession = this._onDidDeleteSession.event;316317private readonly _onDidChangeSession = this._register(new Emitter<ICopilotCLISessionItem>());318public readonly onDidChangeSession = this._onDidChangeSession.event;319private readonly _onDidCreateSession = this._register(new Emitter<ICopilotCLISessionItem>());320public readonly onDidCreateSession = this._onDidCreateSession.event;321322private readonly _onDidCloseSession = this._register(new Emitter<string>());323324private sessionMutexForGetSession = new Map<string, Mutex>();325326private readonly _sessionTracker: CopilotCLISessionWorkspaceTracker;327private readonly _sessionWorkingDirectories = new Map<string, Uri | undefined>();328private readonly _onDidChangeSessionsThrottler = this._register(new ThrottledDelayer<void>(500));329private readonly _cachedSessionItems = new Map<string, ICopilotCLISessionItem>();330private readonly _sessionsBeingCreatedViaFork = new Set<string>();331private readonly _newSessionIds = new Set<string>();332/** Bridge processor that forwards SDK native OTel spans to the debug panel. */333private _bridgeProcessor: CopilotCliBridgeSpanProcessor | undefined;334/** Whether we've attempted to install the bridge (only try once). */335private _bridgeInstalled = false;336private showExternalSessions: boolean;337constructor(338@ILogService protected readonly logService: ILogService,339@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,340@IInstantiationService protected readonly instantiationService: IInstantiationService,341@INativeEnvService private readonly nativeEnv: INativeEnvService,342@IFileSystemService private readonly fileSystem: IFileSystemService,343@ICopilotCLIMCPHandler private readonly mcpHandler: ICopilotCLIMCPHandler,344@ICopilotCLIAgents private readonly agents: ICopilotCLIAgents,345@IWorkspaceService private readonly workspaceService: IWorkspaceService,346@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,347@IConfigurationService private readonly configurationService: IConfigurationService,348@ICopilotCLISkills private readonly copilotCLISkills: ICopilotCLISkills,349@IChatDelegationSummaryService private readonly _delegationSummaryService: IChatDelegationSummaryService,350@IChatSessionMetadataStore private readonly _chatSessionMetadataStore: IChatSessionMetadataStore,351@IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace,352@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,353@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,354@IOTelService private readonly _otelService: IOTelService,355@IPromptVariablesService private readonly _promptVariablesService: IPromptVariablesService,356@IChatDebugFileLoggerService private readonly _debugFileLogger: IChatDebugFileLoggerService,357@IPromptsService private readonly _promptsService: IPromptsService,358@ICopilotCLIModels private readonly _copilotCLIModels: ICopilotCLIModels,359) {360super();361this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions);362this._register(this.configurationService.onDidChangeConfiguration(e => {363if (e.affectsConfiguration(ConfigKey.Advanced.CLIShowExternalSessions.fullyQualifiedId)) {364this.showExternalSessions = this.configurationService.getConfig(ConfigKey.Advanced.CLIShowExternalSessions);365}366}));367this.monitorSessionFiles();368this._sessionManager = new Lazy<Promise<internal.LocalSessionManager>>(async () => {369try {370const sdkPackage = await this.getSDKPackage();371const { internal, createLocalFeatureFlagService } = sdkPackage;372// Always enable SDK OTel so the debug panel receives native spans via the bridge.373// When user OTel is disabled, we force file exporter to /dev/null so the SDK374// creates OtelSessionTracker (for debug panel) but doesn't export to any collector.375if (!process.env['COPILOT_OTEL_ENABLED']) {376process.env['COPILOT_OTEL_ENABLED'] = 'true';377}378// Default content capture to 'true' for the debug panel. When user OTel379// is enabled, their captureContent setting overrides this default below.380// When user OTel is disabled, the default gives debug panel content.381// If the user explicitly set the env var, respect their choice.382if (!process.env['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT']) {383process.env['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = 'true';384}385if (this._otelService.config.enabled) {386const otelEnv = deriveCopilotCliOTelEnv(this._otelService.config);387for (const [key, value] of Object.entries(otelEnv)) {388process.env[key] = value;389}390// When user OTel is enabled, their captureContent config takes391// precedence over the debug-panel default set above.392process.env['OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT'] = String(this._otelService.config.captureContent);393} else {394// User OTel disabled: ensure SDK doesn't export to any external collector.395// Use file exporter to /dev/null so the SDK creates OtelSessionTracker396// (for debug panel) but writes spans nowhere.397process.env['COPILOT_OTEL_EXPORTER_TYPE'] = 'file';398process.env['COPILOT_OTEL_FILE_EXPORTER_PATH'] = devNull;399}400return new internal.LocalSessionManager({401featureFlagService: createLocalFeatureFlagService(),402telemetryService: new internal.NoopTelemetryService(),403autoModeManager: this.createAutoModeManager(sdkPackage),404}, { flushDebounceMs: undefined, settings: undefined, version: undefined });405}406catch (error) {407this.logService.error(`Failed to initialize Copilot CLI Session Manager: ${error}`);408throw error;409}410});411this._sessionTracker = this.instantiationService.createInstance(CopilotCLISessionWorkspaceTracker);412}413414private async getSDKPackage(): Promise<SDKPackage> {415return this.copilotCLISDK.getPackage();416}417418private createAutoModeManager(sdkPackage: SDKPackage): SDKAutoModeSessionManager {419if (typeof sdkPackage.AutoModeSessionManager === 'function') {420try {421return new sdkPackage.AutoModeSessionManager();422} catch (error) {423if (!(error instanceof TypeError)) {424throw error;425}426}427}428429this.logService.warn('Failed to construct SDK AutoModeSessionManager, using compatibility fallback.');430return new AutoModeSessionManagerCompat(sdkPackage) as unknown as SDKAutoModeSessionManager;431}432433getSessionWorkingDirectory(sessionId: string): Uri | undefined {434return this._sessionWorkingDirectories.get(sessionId);435}436437private triggerSessionsChangeEvent() {438// If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.439if (this._isGettingSessions > 0) {440return;441}442443this._onDidChangeSessionsThrottler.trigger(() => Promise.resolve(this._onDidChangeSessions.fire()));444}445446public createNewSessionId(): string {447const sessionId = generateUuid();448this._newSessionIds.add(sessionId);449return sessionId;450}451452public isNewSessionId(sessionId: string): boolean {453return this._newSessionIds.has(sessionId);454}455456protected monitorSessionFiles() {457try {458const sessionDir = joinPath(this.nativeEnv.userHome, '.copilot', 'session-state');459const watcher = this._register(this.fileSystem.createFileSystemWatcher(new RelativePattern(sessionDir, '**/*.jsonl')));460this._register(watcher.onDidCreate(async (e) => {461const sessionId = extractSessionIdFromEventPath(sessionDir, e);462if (sessionId && this._sessionsBeingCreatedViaFork.has(sessionId)) {463return;464}465this.triggerSessionsChangeEvent();466const sessionItem = sessionId ? await this.getSessionItemImpl(sessionId, 'disk', CancellationToken.None) : undefined;467if (sessionItem) {468this._onDidChangeSession.fire(sessionItem);469}470}));471this._register(watcher.onDidDelete(e => {472const sessionId = extractSessionIdFromEventPath(sessionDir, e);473if (sessionId) {474this._cachedSessionItems.delete(sessionId);475this._onDidDeleteSession.fire(sessionId);476}477this.triggerSessionsChangeEvent();478}));479this._register(watcher.onDidChange((e) => {480// If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.481if (this._isGettingSessions > 0) {482return;483}484485const sessionId = extractSessionIdFromEventPath(sessionDir, e);486if (sessionId && this._sessionsBeingCreatedViaFork.has(sessionId)) {487return;488}489490// If we're already working on a session that we're aware of then no need to trigger a refresh.491if (Array.from(this._sessionWrappers.keys()).some(sessionId => e.path.includes(sessionId))) {492return;493}494if (sessionId) {495this.triggerOnDidChangeSessionItem(sessionId, 'fileSystemChange');496}497this.triggerSessionsChangeEvent();498}));499} catch (error) {500this.logService.error(`Failed to monitor Copilot CLI session files: ${error}`);501}502}503async getSessionManager() {504return this._sessionManager.value;505}506507private _sessionChangeNotifierByKey = new SequencerByKey<string>();508private triggerOnDidChangeSessionItem(sessionId: string, reason: 'fileSystemChange' | 'statusChange') {509this._sessionChangeNotifierByKey.queue(sessionId, async () => {510// lets wait for 500ms, as we could get a lot of change events in a short period of time.511// E.g. if you have a session running in integrated terminal, then its possible we will see a lot of updates.512// In such cases its best to just delay (throttle) by 500ms (we get that via the sequncer and this delay)513if (reason === 'fileSystemChange') {514await new Promise<void>(resolve => disposableTimeout(resolve, 500, this._store));515// If already getting all sessions, no point in triggering individual change event.516if (this._isGettingSessions > 0) {517return;518}519}520521const sessionItem = await this.getSessionItemImpl(sessionId, reason === 'statusChange' ? 'inMemorySession' : 'disk', CancellationToken.None);522if (sessionItem) {523this._onDidChangeSession.fire(sessionItem);524}525}).catch(error => {526this.logService.error(`Failed to trigger session change event for session ${sessionId}: ${error}`);527});528}529530/**531* This can be very expensive, as this involves loading all of the sessions.532* TODO @DonJayamanne We need to try to use SDK to open a session and get the details.533*/534public async getSessionItem(sessionId: string, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {535return this.getSessionItemImpl(sessionId, 'inMemorySession', token);536}537538public async getSessionItemImpl(sessionId: string, source: 'inMemorySession' | 'disk', token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {539const wrappedSession = this._sessionWrappers.get(sessionId);540// Give preference to the session we have in memory, as this contains the latest information.541if (wrappedSession && (source === 'inMemorySession' || wrappedSession.object.status === ChatSessionStatus.InProgress)) {542const item = await this.constructSessionItemFromWrappedSession(wrappedSession, token);543if (item) {544return item;545}546}547548// // We can get the item from cache, as the ICopilotCLISessionItem doesn't store anything that changes.549// // Except the title550// let item = this._cachedSessionItems.get(sessionId);551// if (item) {552// // Since this was a change event for an existing session, we must get the latest title.553// const label = await this.getSessionTitle(sessionId, CancellationToken.None);554// const sessionItem = Object.assign({}, item, { label });555// return sessionItem;556// }557558const sessionManager = await raceCancellation(this.getSessionManager(), token);559const metadata = sessionManager ? await raceCancellationError(sessionManager.getSessionMetadata({ sessionId }), token) : undefined;560if (!metadata || token.isCancellationRequested) {561return;562}563await this._sessionTracker.initialize();564return await this.constructSessionItem(metadata, token);565}566567public async getSessionTitle(sessionId: string, token: CancellationToken): Promise<string> {568const sessionManager = await this.getSessionManager();569const metadata = await sessionManager.getSessionMetadata({ sessionId });570return this.getSessionTitleImpl(sessionId, metadata, token);571}572573/**574* Single source of truth for both `getSessionTitle()` (editor/header) and575* `_getAllSessions()` (sidebar list) so the two surfaces never diverge.576*577* Precedence:578* 1. Explicit renamed title — active wrapper title, SDK `name`, or legacy custom title.579* 2. Cached derived label in `_sessionLabels` (from a previous history scan).580* 3. Clean metadata `summary` (rejected if it looks truncated).581* 4. First user message from session history (cached on success).582* 5. Raw metadata `summary` as a display-only last resort (not cached).583*584* Pending prompts are intentionally excluded here for established sessions.585* They are only used for brand-new sessions that have not been persisted yet586* via the wrapper-only fallback in `_getAllSessions()` / `constructSessionItemFromWrappedSession()`.587*/588private async getSessionTitleImpl(sessionId: string, metadata: LocalSessionMetadata | undefined, token: CancellationToken): Promise<string> {589const explicitTitle =590this._sessionWrappers.get(sessionId)?.object.title ??591metadata?.name ??592await this.customSessionTitleService.getCustomSessionTitle(sessionId);593if (explicitTitle) {594return explicitTitle;595}596597const cached = this._sessionLabels.get(sessionId);598if (cached) {599return cached;600}601602const summarizedTitle = labelFromPrompt(metadata?.summary ?? '');603if (summarizedTitle && !summarizedTitle.endsWith('...') && !summarizedTitle.includes('<')) {604return summarizedTitle;605}606607const firstUserMessage = await this.getFirstUserMessageFromSession(sessionId, token);608const fromHistory = labelFromPrompt(firstUserMessage ?? '');609if (fromHistory) {610this._sessionLabels.set(sessionId, fromHistory);611return fromHistory;612}613614return metadata?.summary ?? '';615}616617618private _getAllSessionsProgress: Promise<readonly ICopilotCLISessionItem[]> | undefined;619private _isGettingSessions: number = 0;620async getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {621if (!this._getAllSessionsProgress) {622this._getAllSessionsProgress = this._getAllSessions(token);623}624return this._getAllSessionsProgress.finally(() => {625this._getAllSessionsProgress = undefined;626});627}628629private _sessionLabels: Map<string, string> = new Map();630631async _getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {632this._isGettingSessions++;633try {634const sessionManager = await raceCancellationError(this.getSessionManager(), token);635const sessionMetadataList = await raceCancellationError(sessionManager.listSessions(), token);636637await this._sessionTracker.initialize();638639// Convert SessionMetadata to ICopilotCLISession640const diskSessions: ICopilotCLISessionItem[] = coalesce(await Promise.all(641sessionMetadataList.map(async (metadata): Promise<ICopilotCLISessionItem | undefined> => {642const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;643this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);644if (!await this.shouldShowSession(metadata.sessionId, metadata.context)) {645return;646}647const id = metadata.sessionId;648const startTime = metadata.startTime.getTime();649const endTime = metadata.modifiedTime.getTime();650const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token);651if (!label) {652return;653}654return {655id,656label,657timing: { created: startTime, startTime, endTime },658workingDirectory659};660})661));662663const diskSessionIds = new Set(diskSessions.map(s => s.id));664// If we have a new session that has started, then return that as well.665// Possible SDK has not yet persisted it to disk.666const newSessions = coalesce(await Promise.all(Array.from(this._sessionWrappers.values())667.filter(session => !diskSessionIds.has(session.object.sessionId))668.filter(session => session.object.status === ChatSessionStatus.InProgress)669.map(async (session): Promise<ICopilotCLISessionItem | undefined> => {670const label = session.object.title ?? await this.customSessionTitleService.getCustomSessionTitle(session.object.sessionId) ?? labelFromPrompt(session.object.pendingPrompt ?? '');671if (!label) {672return;673}674675const createTime = Date.now();676return {677id: session.object.sessionId,678label,679status: session.object.status,680timing: { created: createTime, startTime: createTime },681};682})));683684// Merge with cached sessions (new sessions not yet persisted by SDK)685const allSessions = diskSessions686.map((session): ICopilotCLISessionItem => {687return {688...session,689status: this._sessionWrappers.get(session.id)?.object?.status690};691}).concat(newSessions);692693allSessions.forEach(session => this._cachedSessionItems.set(session.id, session));694return allSessions;695} catch (error) {696this.logService.error(`Failed to get all sessions: ${error}`);697throw error;698} finally {699this._isGettingSessions--;700}701}702703private async constructSessionItem(metadata: LocalSessionMetadata, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {704const sessionItem = await this.constructSessionItemImpl(metadata, token);705if (sessionItem) {706this._cachedSessionItems.set(metadata.sessionId, sessionItem);707}708return sessionItem;709}710711private async constructSessionItemFromWrappedSession(session: RefCountedSession, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {712const label = (await this.getSessionTitle(session.object.sessionId, token)) || this._cachedSessionItems.get(session.object.sessionId)?.label || labelFromPrompt(session.object.pendingPrompt ?? '');713const createTime = Date.now();714return {715id: session.object.sessionId,716label,717status: session.object.status,718timing: this._cachedSessionItems.get(session.object.sessionId)?.timing ?? { created: createTime, startTime: createTime },719};720}721722private async constructSessionItemImpl(metadata: LocalSessionMetadata, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {723const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;724this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);725const shouldShowSession = await this.shouldShowSession(metadata.sessionId, metadata.context);726if (!shouldShowSession) {727return undefined;728}729730const id = metadata.sessionId;731const startTime = metadata.startTime.getTime();732const endTime = metadata.modifiedTime.getTime();733const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token) ?? labelFromPrompt(metadata.summary ?? '');734735if (label) {736return {737id,738label,739timing: { created: startTime, startTime, endTime },740workingDirectory,741status: this._sessionWrappers.get(id)?.object?.status742};743}744}745746public async createSession(options: ICreateSessionOptions, token: CancellationToken): Promise<RefCountedSession> {747const resource = options.sessionId ? SessionIdForCLI.getResource(options.sessionId) : URI.from({ scheme: 'copilot-cli', path: `mcp-gateway-${generateUuid()}` });748const { mcpConfig: mcpServers, disposable: mcpGateway } = await this.mcpHandler.loadMcpConfig(resource);749try {750const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers });751const sessionManager = await raceCancellationError(this.getSessionManager(), token);752const sdkSession = await sessionManager.createSession({ ...sessionOptions, sessionId: options.sessionId });753const wasNewSession = this._newSessionIds.delete(sdkSession.sessionId);754// After the first session creation, the SDK's OTel TracerProvider is755// initialized. Install the bridge processor so SDK-native spans flow756// to the debug panel.757this._installBridgeIfNeeded();758759const promises: Promise<unknown>[] = [];760if (wasNewSession) {761promises.push(this.customSessionTitleService.getCustomSessionTitle(sdkSession.sessionId).then(stagedTitle => {762if (stagedTitle) {763sdkSession.updateSessionSummary(stagedTitle);764}765}));766}767promises.push(sessionManager.loadDeferredRepoHooks(sdkSession));768await Promise.all(promises);769770if (sessionOptions.copilotUrl) {771sdkSession.setAuthInfo({772type: 'hmac',773hmac: 'empty',774host: 'https://github.com',775copilotUser: {776endpoints: {777api: sessionOptions.copilotUrl778}779}780});781}782this.logService.trace(`[CopilotCLISession] Created new CopilotCLI session ${sdkSession.sessionId}.`);783784const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager);785session.object.add(mcpGateway);786787// Set origin788void this._chatSessionMetadataStore.setSessionOrigin(session.object.sessionId);789790// Set session parent id791if (options.sessionParentId) {792void this._chatSessionMetadataStore.setSessionParentId(session.object.sessionId, options.sessionParentId);793}794795return session;796}797catch (error) {798mcpGateway.dispose();799throw error;800}801}802803/**804* Install the bridge SpanProcessor on the SDK's global TracerProvider.805* Called once after the first session creation (when the SDK provider is ready).806*/807private _installBridgeIfNeeded(): void {808if (this._bridgeInstalled) {809return;810}811this._bridgeInstalled = true;812813try {814// The SDK registered its BasicTracerProvider as the global provider.815// In OTel SDK v2, addSpanProcessor() was removed from BasicTracerProvider.816// We access the internal MultiSpanProcessor._spanProcessors array to inject817// our bridge. This is the same pattern the SDK itself uses in forceFlush().818const api = require('@opentelemetry/api') as typeof import('@opentelemetry/api');819const globalProvider = api.trace.getTracerProvider();820821// Navigate: ProxyTracerProvider._delegate → BasicTracerProvider._activeSpanProcessor → MultiSpanProcessor._spanProcessors822const delegate = (globalProvider as unknown as Record<string, unknown>)._delegate ?? globalProvider;823const activeProcessor = (delegate as unknown as Record<string, unknown>)._activeSpanProcessor as Record<string, unknown> | undefined;824const processorArray = activeProcessor?._spanProcessors;825826if (Array.isArray(processorArray)) {827this._bridgeProcessor = new CopilotCliBridgeSpanProcessor(this._otelService);828processorArray.push(this._bridgeProcessor);829this.logService.info('[CopilotCLISession] Bridge SpanProcessor installed on SDK TracerProvider');830} else {831this.logService.warn('[CopilotCLISession] Could not access SDK TracerProvider internals — debug panel will not show SDK spans');832}833} catch (err) {834this.logService.warn(`[CopilotCLISession] Failed to install bridge SpanProcessor: ${err}`);835}836}837838private async shouldShowSession(sessionId: string, context?: SessionContext): Promise<boolean> {839if (isUntitledSessionId(sessionId)) {840return true;841}842843if (!this.showExternalSessions) {844const sessionOrigin = await this._chatSessionMetadataStore.getSessionOrigin(sessionId);845if (sessionOrigin !== 'vscode') {846return false;847}848}849// If we're in an empty workspace then show all sessions.850if (this.workspaceService.getWorkspaceFolders().length === 0) {851return true;852}853if (this._agentSessionsWorkspace.isAgentSessionsWorkspace) {854return true;855}856// This session was started from a specified workspace (e.g. multiroot, untitled or other), hence continue showing it.857const sessionTrackerVisibility = this._sessionTracker.shouldShowSession(sessionId);858if (sessionTrackerVisibility.isWorkspaceSession) {859return true;860}861// Possible we have the workspace info in cli metadata.862if (context && (863(context.cwd && this.workspaceService.getWorkspaceFolder(URI.file(context.cwd))) ||864(context.gitRoot && this.workspaceService.getWorkspaceFolder(URI.file(context.gitRoot)))865)) {866return true;867}868// If we have a workspace folder for this and the workspace folder belongs to one of the open workspace folders, show it.869const workspaceFolder = await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);870if (workspaceFolder && this.workspaceService.getWorkspaceFolder(workspaceFolder)) {871return true;872}873// If we have a git worktree and the worktree's repo belongs to one of the workspace folders, show it.874const worktree = await this.worktreeManager.getWorktreeProperties(sessionId);875if (worktree && this.workspaceService.getWorkspaceFolder(URI.file(worktree.repositoryPath))) {876return true;877}878// If this is an old global session, show it if we don't have specific data to exclude it.879if (sessionTrackerVisibility.isOldGlobalSession && !workspaceFolder && !worktree && (this.workspaceService.getWorkspaceFolders().length === 0 || this._agentSessionsWorkspace.isAgentSessionsWorkspace)) {880return true;881}882return false;883}884885protected async createSessionsOptions(options: ICreateSessionOptions & { mcpServers?: SessionOptions['mcpServers'] }): Promise<Readonly<SessionOptions>> {886const [agentInfos, skillLocations] = await Promise.all([887this.agents.getAgents(),888this.copilotCLISkills.getSkillsLocations(CancellationToken.None),889]);890const customAgents = agentInfos.map(i => i.agent);891const variablesContext = this._promptVariablesService.buildTemplateVariablesContext(options.sessionId, options.debugTargetSessionIds);892const systemMessage = variablesContext ? { mode: 'append' as const, content: variablesContext } : undefined;893894const allOptions: SessionOptions = {895clientName: 'vscode',896integrationId: INTEGRATION_ID897};898899const workingDirectory = getWorkingDirectory(options.workspace);900if (workingDirectory) {901allOptions.workingDirectory = workingDirectory.fsPath;902}903if (options.model) {904allOptions.model = options.model as unknown as SessionOptions['model'];905}906if (options.mcpServers && Object.keys(options.mcpServers).length > 0) {907allOptions.mcpServers = options.mcpServers;908this.logService.info(`[CopilotCLISession] Passing ${Object.keys(options.mcpServers).length} MCP server(s) to SDK: [${Object.keys(options.mcpServers).join(', ')}]`);909for (const [id, cfg] of Object.entries(options.mcpServers)) {910this.logService.info(`[CopilotCLISession] ${id}: type=${cfg.type}`);911}912} else {913this.logService.info('[CopilotCLISession] No MCP servers to pass to SDK');914}915if (skillLocations.length > 0) {916allOptions.skillDirectories = skillLocations.map(uri => uri.fsPath);917}918if (options.mcpServerMappings?.size && customAgents && options.mcpServers) {919remapCustomAgentTools(customAgents, options.mcpServerMappings, options.mcpServers, options.agent);920}921if (options.agent) {922allOptions.selectedCustomAgent = options.agent;923}924if (customAgents.length > 0) {925allOptions.customAgents = customAgents;926}927allOptions.enableStreaming = true;928const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;929if (copilotUrl) {930allOptions.copilotUrl = copilotUrl;931}932if (systemMessage) {933allOptions.systemMessage = systemMessage;934}935allOptions.sessionCapabilities = new Set(['plan-mode', 'memory', 'cli-documentation', 'ask-user', 'interactive-mode', 'system-notifications']);936if (options.reasoningEffort && this.configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled)) {937allOptions.reasoningEffort = options.reasoningEffort;938}939940return allOptions as Readonly<SessionOptions>;941}942943public async getSession(options: IGetSessionOptions, token: CancellationToken): Promise<RefCountedSession | undefined> {944// https://github.com/microsoft/vscode/issues/276573945const lock = this.sessionMutexForGetSession.get(options.sessionId) ?? new Mutex();946this.sessionMutexForGetSession.set(options.sessionId, lock);947const lockDisposable = await lock.acquire(token);948try {949{950const session = this._sessionWrappers.get(options.sessionId);951if (session) {952this.logService.trace(`[CopilotCLISession] Reusing CopilotCLI session ${options.sessionId}.`);953this._partialSessionHistories.delete(options.sessionId);954session.acquire();955if (options.agent) {956await session.object.sdkSession.selectCustomAgent(options.agent.name);957} else {958session.object.sdkSession.clearCustomAgent();959}960return session;961}962}963964const [sessionManager, { mcpConfig: mcpServers, disposable: mcpGateway }] = await Promise.all([965raceCancellationError(this.getSessionManager(), token),966this.mcpHandler.loadMcpConfig(SessionIdForCLI.getResource(options.sessionId)),967]);968try {969const sessionOptions = await this.createSessionsOptions({ ...options, mcpServers });970971const sdkSession = await sessionManager.getSession({ ...sessionOptions, sessionId: options.sessionId }, true);972if (!sdkSession) {973this.logService.error(`[CopilotCLISession] CopilotCLI failed to get session ${options.sessionId}.`);974return undefined;975}976await sessionManager.loadDeferredRepoHooks(sdkSession);977const session = this.createCopilotSession(sdkSession, options.workspace, options.agent?.name, sessionManager);978session.object.add(mcpGateway);979return session;980}981catch (error) {982mcpGateway.dispose();983throw error;984}985} finally {986lockDisposable?.dispose();987}988}989public async getChatHistory({ sessionId, workspace }: { sessionId: string; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<(ChatRequestTurn2 | ChatResponseTurn2)[]> {990const { history } = await this.getChatHistoryImpl({ sessionId, workspace }, token);991return history;992}993994private async getChatHistoryImpl({ sessionId, workspace }: { sessionId: string; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<{ history: (ChatRequestTurn2 | ChatResponseTurn2)[]; events: readonly SessionEvent[] }> {995const requestDetailsPromise = this._chatSessionMetadataStore.getRequestDetails(sessionId);996const agentIdPromise = this._chatSessionMetadataStore.getSessionAgent(sessionId);997const sessionManager = await raceCancellation(this.getSessionManager(), token);998999if (!sessionManager || token.isCancellationRequested) {1000requestDetailsPromise.catch(error => {/** */ });1001agentIdPromise.catch(error => {/** */ });1002return { history: [], events: [] };1003}10041005let events: readonly SessionEvent[] = [];1006let modelId: string | undefined = undefined;10071008// Try to shutdown session as soon as possible.1009const existingSession = this._sessionWrappers.get(sessionId)?.object?.sdkSession;1010if (existingSession) {1011modelId = await existingSession.getSelectedModel();1012events = existingSession.getEvents();1013} else {1014let shutdown = Promise.resolve();1015try {1016const session = await sessionManager.getSession({ sessionId }, false);1017if (!session) {1018return { history: [], events: [] };1019}1020modelId = await session.getSelectedModel();1021events = session.getEvents();1022shutdown = sessionManager.closeSession(sessionId).catch(error => {1023this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after fetching chat history: ${error}`);1024});1025} finally {1026await shutdown;1027}1028}10291030const [agentId, storedDetails] = await Promise.all([agentIdPromise, requestDetailsPromise]);10311032// Build lookup from copilotRequestId → RequestDetails for the callback1033const customAgentLookup = await this.createCustomAgentLookup();1034const legacyMappings: RequestDetails[] = [];1035const detailsByCopilotId = new Map<string, RequestIdDetails>();1036const defaultModeInstructions = agentId ? await this.resolveAgentModeInstructions(agentId, customAgentLookup) : undefined;10371038for (const d of storedDetails) {1039if (d.copilotRequestId) {1040const modeInstructions = d.modeInstructions ?? await this.resolveAgentModeInstructions(d.agentId, customAgentLookup) ?? defaultModeInstructions;1041detailsByCopilotId.set(d.copilotRequestId, { requestId: d.vscodeRequestId, toolIdEditMap: d.toolIdEditMap, modeInstructions });1042}1043}10441045const getVSCodeRequestId = (sdkRequestId: string) => {1046const stored = detailsByCopilotId.get(sdkRequestId);1047if (stored) {1048return stored;1049}1050const mapping = this.copilotCLISDK.getRequestId(sdkRequestId);1051if (mapping) {1052detailsByCopilotId.set(sdkRequestId, mapping);1053legacyMappings.push({1054copilotRequestId: sdkRequestId,1055vscodeRequestId: mapping.requestId,1056toolIdEditMap: mapping.toolIdEditMap,1057});1058}1059return mapping;1060};10611062const lastResponseDetails = await this.getModelDetailsString(modelId);1063const history = buildChatHistoryFromEvents(sessionId, modelId, events, getVSCodeRequestId, this._delegationSummaryService, this.logService, getWorkingDirectory(workspace), defaultModeInstructions, lastResponseDetails);10641065if (legacyMappings.length > 0) {1066void this._chatSessionMetadataStore.updateRequestDetails(sessionId, legacyMappings).catch(error => {1067this.logService.error(`[CopilotCLISession] Failed to update chat session metadata store with legacy mappings for session ${sessionId}`, error);1068});1069}10701071return { history, events };1072}10731074private async createCustomAgentLookup(): Promise<Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>> {1075const agents = await this._promptsService.getCustomAgents(CancellationToken.None);1076const lookup = new Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>();1077for (const agent of agents) {1078if (!agent.enabled || !isEnabledForCopilotCLI(agent)) {1079continue;1080}1081const lazyContent = new Lazy(() => this._promptsService.parseFile(agent.uri, CancellationToken.None).then(parsed => parsed.body?.getContent() ?? ''));1082const keys = [1083agent.name?.trim(),1084agent.uri.toString(),1085getAgentFileNameFromFilePath(agent.uri),1086];1087for (const key of keys) {1088if (key && !lookup.has(key)) {1089lookup.set(key, [agent, lazyContent]);1090}1091}1092}1093return lookup;1094}10951096private async resolveAgentModeInstructions(agentId: string | undefined, customAgentLookup: Map<string, [ChatCustomAgent, Lazy<Promise<string>>]>): Promise<StoredModeInstructions | undefined> {1097if (!agentId) {1098return undefined;1099}1100const agentEntry = customAgentLookup.get(agentId);1101if (!agentEntry) {1102return undefined;1103}1104const [agent, lazyContent] = agentEntry;1105return {1106uri: agent.uri.toString(),1107name: agent.name?.trim() || agentId,1108content: await lazyContent.value,1109};1110}11111112private async getModelDetailsString(modelId: string | undefined): Promise<string | undefined> {1113if (!modelId) {1114return undefined;1115}1116const models = await this._copilotCLIModels.getModels().catch(ex => {1117this.logService.error(ex, 'Failed to get models');1118return [];1119});1120const modelInfo = models.find(m => m.id === modelId);1121return modelInfo ? formatModelDetails(modelInfo) : undefined;1122}112311241125/**1126* Fork an existing session using the SDK's `forkSession` API.1127*1128* The SDK handles copying the event log and (optionally) truncating to a boundary event.1129* This method additionally stores VS Code-specific workspace metadata and custom title.1130*1131* Returns the id of the forked session.1132*/1133public async forkSession({ sessionId, requestId, workspace }: { sessionId: string; requestId: string | undefined; workspace: IWorkspaceInfo }, token: CancellationToken): Promise<string> {1134// Resolve the SDK event ID boundary for truncation BEFORE forking.1135// We need the source session's history and request details to translate the VS Code requestId1136// into the SDK event ID that the SDK's forkSession accepts.1137const [sessionManager, title, { history, events: originalSessionEvents }] = await Promise.all([1138raceCancellationError(this.getSessionManager(), token),1139this.getSessionTitle(sessionId, token),1140requestId ? this.getChatHistoryImpl({ sessionId, workspace }, token) : Promise.resolve({ history: [], events: [] }),1141]);11421143let toEventId: string | undefined;1144if (requestId) {1145const requestToTruncateTo = history.find(event => event instanceof ChatRequestTurn2 && event.id === requestId);1146if (requestToTruncateTo) {1147const storedDetails = await this._chatSessionMetadataStore.getRequestDetails(sessionId);1148const translatedSDKEvent = storedDetails.find(d => d.vscodeRequestId === requestToTruncateTo.id || d.copilotRequestId === requestToTruncateTo.id)?.copilotRequestId;1149const sdkEvent = originalSessionEvents.find(e => e.type === 'user.message' && e.id === requestToTruncateTo.id)?.id;1150toEventId = translatedSDKEvent ?? sdkEvent;1151if (!toEventId) {1152this.logService.warn(`[CopilotCLISession] Cannot find SDK event id for request id ${requestId} in session ${sessionId}. Will fork without truncation.`);1153}1154} else {1155this.logService.warn(`[CopilotCLISession] Failed to find request ${requestId} in session ${sessionId} history. Will fork without truncation.`);1156}1157}11581159const { sessionId: newSessionId } = await sessionManager.forkSession(sessionId, toEventId);1160this._sessionsBeingCreatedViaFork.add(newSessionId);1161try {1162const forkedTitlePrefix = l10n.t("Forked: ");1163const customTitle = title.startsWith(forkedTitlePrefix) ? title : l10n.t("Forked: {0}", title);1164await this._chatSessionMetadataStore.storeForkedSessionMetadata(sessionId, newSessionId, customTitle);11651166this._onDidChangeSessions.fire();1167this._onDidCreateSession.fire({1168id: newSessionId,1169label: customTitle,1170timing: { created: Date.now(), startTime: Date.now() },1171workingDirectory: getWorkingDirectory(workspace)1172});11731174return newSessionId;1175} finally {1176this._sessionsBeingCreatedViaFork.delete(newSessionId);1177}1178}1179public async tryGetPartialSessionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined> {1180const cached = this._partialSessionHistories.get(sessionId);1181if (cached) {1182return cached;1183}11841185try {1186const events = await readSessionEventsFile(sessionId);11871188const sessionStartEvent = events.find((event): event is Extract<SessionEvent, { type: 'session.start' }> => event.type === 'session.start');1189const workingDirectory = sessionStartEvent?.data.context?.cwd;1190if (workingDirectory) {1191this._sessionWorkingDirectories.set(sessionId, URI.file(workingDirectory));1192}11931194const history = buildChatHistoryFromEvents(sessionId, undefined, events, () => undefined, this._delegationSummaryService, this.logService, workingDirectory ? URI.file(workingDirectory) : undefined);1195this._partialSessionHistories.set(sessionId, history);1196return history;1197} catch (error) {1198this.logService.warn(`[CopilotCLISession] Failed to reconstruct partial session ${sessionId}: ${error}`);1199return undefined;1200}1201}12021203private async getFirstUserMessageFromSession(sessionId: string, token: CancellationToken): Promise<string | undefined> {1204const cached = await this._chatSessionMetadataStore.getSessionFirstUserMessage(sessionId);1205if (typeof cached === 'string') {1206return cached;1207}12081209let firstUserMessage: string | undefined;1210try {1211const events = await raceCancellation(readSessionEventsFile(sessionId, 'user.message'), token);1212if (events?.length) {1213// Find the first user message and use that as the title.1214firstUserMessage = events.find((msg: SessionEvent) => msg.type === 'user.message')?.data.content;1215}1216} catch (error) {1217this.logService.warn(`[CopilotCLISession] Failed to get session title for session ${sessionId}: ${error}`);1218}12191220if (!firstUserMessage) {1221try {1222const { events } = await this.getChatHistoryImpl({ sessionId, workspace: emptyWorkspaceInfo() }, token);1223firstUserMessage = events.find((msg: SessionEvent) => msg.type === 'user.message')?.data.content;1224} catch (error) {1225this.logService.warn(`[CopilotCLISession] Failed to load session for first user message ${sessionId}: ${error}`);1226}1227}12281229this._chatSessionMetadataStore.setSessionFirstUserMessage(sessionId, firstUserMessage ?? '').catch(err => {1230this.logService.warn(`[CopilotCLISession] Failed to store first user message for session ${sessionId}: ${err}`);1231});12321233return firstUserMessage;1234}12351236private createCopilotSession(sdkSession: Session, workspaceInfo: IWorkspaceInfo, agentName: string | undefined, sessionManager: internal.LocalSessionManager): RefCountedSession {1237sdkSession.setPermissionsRequired(true);1238const session = this.instantiationService.createInstance(CopilotCLISession, workspaceInfo, agentName, sdkSession, []);1239this._debugFileLogger.startSession(session.sessionId).catch(err => {1240this.logService.error('[CopilotCLISession] Failed to start debug log session', err);1241});1242session.add(toDisposable(() => {1243this._debugFileLogger.endSession(session.sessionId).catch(err => {1244this.logService.error('[CopilotCLISession] Failed to end debug log session', err);1245});1246}));1247// Wire the bridge processor so the session can register traceId → sessionId mappings1248session.setBridgeProcessor(this._bridgeProcessor);1249// Wire SDK trace context updater so the session can propagate traceparent to SDK spans1250const otelLifecycle = sessionManager.otel;1251if (otelLifecycle) {1252session.setSdkTraceContextUpdater((traceparent, tracestate) =>1253otelLifecycle.updateParentTraceContext(sdkSession.sessionId, traceparent, tracestate));1254}1255session.add(session.onDidChangeStatus(() => {1256this.triggerOnDidChangeSessionItem(sdkSession.sessionId, 'statusChange');1257this._onDidChangeSessions.fire();1258}));1259session.add(toDisposable(() => {1260this._sessionWrappers.deleteAndLeak(sdkSession.sessionId);1261this.sessionMutexForGetSession.delete(sdkSession.sessionId);1262(async () => {1263if (sdkSession.isAbortable()) {1264await sdkSession.abort().catch(error => {1265this.logService.error(`Failed to abort session ${sdkSession.sessionId}: ${error}`);1266});1267}1268await sessionManager.closeSession(sdkSession.sessionId).catch(error => {1269this.logService.error(`Failed to close session ${sdkSession.sessionId}: ${error}`);1270});1271this._onDidCloseSession.fire(sdkSession.sessionId);1272})();1273}));12741275const refCountedSession = new RefCountedSession(session);1276this._sessionWrappers.set(sdkSession.sessionId, refCountedSession);1277return refCountedSession;1278}12791280public async deleteSession(sessionId: string): Promise<void> {1281this._sessionLabels.delete(sessionId);1282this._partialSessionHistories.delete(sessionId);1283this._sessionWorkingDirectories.delete(sessionId);1284try {1285{1286const session = this._sessionWrappers.get(sessionId);1287if (session) {1288session.dispose();1289this.logService.warn(`Delete an active session ${sessionId}.`);1290}1291}12921293// Delete from session manager first1294const sessionManager = await this.getSessionManager();1295await sessionManager.deleteSession(sessionId);12961297} catch (error) {1298this.logService.error(`Failed to delete session ${sessionId}: ${error}`);1299} finally {1300this._sessionWrappers.deleteAndLeak(sessionId);1301// Possible the session was deleted in another vscode session or the like.1302this._onDidChangeSessions.fire();1303this._onDidDeleteSession.fire(sessionId);1304}1305}13061307private async updateSdkSessionMetadata(sessionId: string, title: string, operation: (sdkSession: LocalSession) => Promise<void>): Promise<void> {1308let sessionManager: internal.LocalSessionManager | undefined;1309let shouldCloseSession = false;1310const sdkSession = (this._sessionWrappers.get(sessionId)?.object.sdkSession as LocalSession | undefined) ?? await (async () => {1311sessionManager = await this.getSessionManager();1312const session = await sessionManager.getSession({ sessionId }, true) as LocalSession | undefined;1313shouldCloseSession = !!session;1314return session;1315})();13161317if (!sdkSession) {1318// SDK session not yet materialized (e.g. brand-new VS Code sessionId).1319// Stage locally; `createSession` syncs it into the SDK once the session is created.1320await this.customSessionTitleService.setCustomSessionTitle(sessionId, title);1321return;1322}13231324try {1325await operation(sdkSession);1326} finally {1327if (shouldCloseSession && sessionManager) {1328await sessionManager.closeSession(sessionId).catch(error => {1329this.logService.error(`[CopilotCLISession] Failed to close session ${sessionId} after updating title metadata: ${error}`);1330});1331}1332}1333}13341335public async renameSession(sessionId: string, title: string): Promise<void> {1336await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.renameSession(title));1337this._sessionLabels.delete(sessionId);1338this._onDidChangeSessions.fire();1339}13401341public async updateSessionSummary(sessionId: string, title: string): Promise<void> {1342await this.updateSdkSessionMetadata(sessionId, title, sdkSession => sdkSession.updateSessionSummary(title));1343// Invalidate the derived-label cache so a subsequent title resolution1344// can pick up the freshly-written summary instead of returning a stale1345// label that was extracted from session history on a prior pass.1346this._sessionLabels.delete(sessionId);1347this._onDidChangeSessions.fire();1348}1349}13501351async function readSessionEventsFile(sessionId: string, findFirstEventType?: string): Promise<SessionEvent[]> {1352const sessionDirPath = getCopilotCLISessionDir(sessionId);1353const sessionDir = URI.file(sessionDirPath);1354const eventsFile = joinPath(sessionDir, 'events.jsonl');13551356const events: SessionEvent[] = [];1357const stream = createReadStream(eventsFile.fsPath, { encoding: 'utf8' });1358const reader = createInterface({1359input: stream,1360crlfDelay: Infinity,1361});1362try {1363for await (const line of reader) {1364if (line.trim().length === 0) {1365continue;1366}1367const sessionEvent = JSON.parse(line) as SessionEvent;1368events.push(sessionEvent);1369if (findFirstEventType && sessionEvent.type === findFirstEventType) {1370break;1371}1372}1373} finally {1374reader.close();1375stream.close();1376}13771378return events;1379}13801381export class CopilotCLISessionWorkspaceTracker {1382private readonly _initializeSessionStorageFiles: Lazy<Promise<{ global: Uri; workspace: Uri }>>;1383private _oldGlobalSessions?: Set<string>;1384private readonly _workspaceSessions = new Set<string>();1385constructor(1386@IFileSystemService private readonly fileSystem: IFileSystemService,1387@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,1388@IWorkspaceService private readonly workspaceService: IWorkspaceService,1389) {1390this._initializeSessionStorageFiles = new Lazy<Promise<{ global: Uri; workspace: Uri }>>(async () => {1391const globalFile = joinPath(this.context.globalStorageUri, 'copilot.cli.oldGlobalSessions.json');1392let workspaceFile = joinPath(this.context.globalStorageUri, 'copilot.cli.workspaceSessions.json');1393// If we have workspace folders, track workspace sessions separately. Otherwise treat them as global sessions.1394if (this.workspaceService.getWorkspaceFolders().length) {1395let workspaceFileName = this.context.workspaceState.get<string | undefined>(COPILOT_CLI_WORKSPACE_JSON_FILE_KEY);1396if (!workspaceFileName) {1397workspaceFileName = `copilot.cli.workspaceSessions.${generateUuid()}.json`;1398await this.context.workspaceState.update(COPILOT_CLI_WORKSPACE_JSON_FILE_KEY, workspaceFileName);1399}1400workspaceFile = joinPath(this.context.globalStorageUri, workspaceFileName);1401}14021403await Promise.all([1404// Load old sessions1405(async () => {1406const oldSessions = await this.fileSystem.readFile(globalFile).then(c => new TextDecoder().decode(c).split(',')).catch(() => undefined);1407if (oldSessions) {1408this._oldGlobalSessions = new Set<string>(oldSessions);1409}1410})(),1411// Load workspace sessions1412(async () => {1413const workspaceSessions = this.workspaceService.getWorkspaceFolders().length ?1414await this.fileSystem.readFile(workspaceFile).then(c => new TextDecoder().decode(c).split(',')).catch(() => []) : [];1415workspaceSessions.forEach(s => this._workspaceSessions.add(s));1416})(),1417]);14181419return { global: globalFile, workspace: workspaceFile };1420});1421void this._initializeSessionStorageFiles.value;1422}14231424public async initialize(): Promise<void> {1425await this._initializeSessionStorageFiles.value;1426}14271428/**1429* InitializeOldSessions should have been called before this.1430*/1431public shouldShowSession(sessionId: string): { isOldGlobalSession?: boolean; isWorkspaceSession?: boolean } {1432return {1433isOldGlobalSession: this._oldGlobalSessions?.has(sessionId),1434isWorkspaceSession: this._workspaceSessions.has(sessionId),1435};1436}1437}14381439function labelFromPrompt(prompt: string): string {1440// Strip system reminders from the prompt1441return stripReminders(prompt);1442}14431444/**1445* Extracts the session ID from a deleted events.jsonl file path.1446* Expected path format: <sessionDir>/<sessionId>/events.jsonl1447*/1448function extractSessionIdFromEventPath(sessionDir: URI, deletedFileUri: URI): string | undefined {1449if (basename(deletedFileUri) !== 'events.jsonl') {1450return undefined;1451}1452const parentDir = dirname(deletedFileUri);1453const parentOfParent = dirname(parentDir);1454if (parentOfParent.path !== sessionDir.path) {1455return undefined;1456}1457return basename(parentDir);1458}14591460export class Mutex {1461private _locked = false;1462private readonly _acquireQueue: (() => void)[] = [];14631464isLocked(): boolean {1465return this._locked;1466}14671468// Acquire the lock; resolves with a release function you MUST call.1469acquire(token: CancellationToken): Promise<IDisposable | undefined> {1470return raceCancellation(new Promise<IDisposable | undefined>(resolve => {1471const tryAcquire = () => {1472if (token.isCancellationRequested) {1473resolve(undefined);1474return;1475}1476if (!this._locked) {1477this._locked = true;1478resolve(toDisposable(() => this._release()));1479} else {1480this._acquireQueue.push(tryAcquire);1481}1482};1483tryAcquire();1484}), token);1485}14861487private _release(): void {1488if (!this._locked) {1489// already unlocked1490return;1491}1492this._locked = false;1493const next = this._acquireQueue.shift();1494if (next) {1495next();1496}1497}1498}14991500export class RefCountedSession extends RefCountedDisposable implements IReference<CopilotCLISession> {1501constructor(public readonly object: CopilotCLISession) {1502super(object);1503}1504dispose(): void {1505this.release();1506}1507}150815091510