feat: Update usage of Controller API (#4353)

* feat: Introduce AgentSessionsWorkspace udpate usage of Controller API

* Addressed review comments
This commit is contained in:
Don Jayamanne
2026-03-11 19:31:46 +11:00
committed by GitHub
parent 8bf0354979
commit 56de4298dc
7 changed files with 625 additions and 121 deletions
@@ -0,0 +1,13 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { createServiceIdentifier } from '../../../util/common/services';
export const IAgentSessionsWorkspace = createServiceIdentifier<IAgentSessionsWorkspace>('IAgentSessionsWorkspace');
export interface IAgentSessionsWorkspace {
readonly _serviceBrand: undefined;
readonly isAgentSessionsWorkspace: boolean;
}
@@ -3,7 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { internal, Session, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
import type { internal, LocalSessionMetadata, Session, SessionContext, SessionEvent, SessionOptions, SweCustomAgent } from '@github/copilot/sdk';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import type { ChatRequest, ChatSessionItem, Uri } from 'vscode';
@@ -16,16 +16,21 @@ import { ILogService } from '../../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../../util/common/services';
import { coalesce } from '../../../../util/vs/base/common/arrays';
import { disposableTimeout, raceCancellation, raceCancellationError, ThrottledDelayer } from '../../../../util/vs/base/common/async';
import { AsyncIterableProducer, disposableTimeout, raceCancellation, raceCancellationError, SequencerByKey, ThrottledDelayer } from '../../../../util/vs/base/common/async';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { Emitter, Event } from '../../../../util/vs/base/common/event';
import { Lazy } from '../../../../util/vs/base/common/lazy';
import { Disposable, DisposableMap, IDisposable, IReference, RefCountedDisposable, toDisposable } from '../../../../util/vs/base/common/lifecycle';
import { joinPath } from '../../../../util/vs/base/common/resources';
import { basename, dirname, joinPath } from '../../../../util/vs/base/common/resources';
import { URI } from '../../../../util/vs/base/common/uri';
import { generateUuid } from '../../../../util/vs/base/common/uuid';
import { IInstantiationService } from '../../../../util/vs/platform/instantiation/common/instantiation';
import { ChatRequestTurn2, ChatResponseTurn2, ChatSessionStatus } from '../../../../vscodeTypes';
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
import { isUntitledSessionId } from '../../common/utils';
import { emptyWorkspaceInfo, IWorkspaceInfo } from '../../common/workspaceInfo';
import { buildChatHistoryFromEvents, stripReminders } from '../common/copilotCLITools';
import { ICustomSessionTitleService } from '../common/customSessionTitleService';
@@ -35,7 +40,6 @@ import { CopilotCLISessionOptions, ICopilotCLIAgents, ICopilotCLISDK } from './c
import { CopilotCLISession, ICopilotCLISession } from './copilotcliSession';
import { ICopilotCLISkills } from './copilotCLISkills';
import { ICopilotCLIMCPHandler } from './mcpHandler';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
const COPILOT_CLI_WORKSPACE_JSON_FILE_KEY = 'github.copilot.cli.workspaceSessionFile';
@@ -53,11 +57,16 @@ export interface ICopilotCLISessionService {
readonly _serviceBrand: undefined;
onDidChangeSessions: Event<void>;
onDidDeleteSession: Event<string>;
onDidChangeSession: Event<ICopilotCLISessionItem>;
onDidCreateSession: Event<ICopilotCLISessionItem>;
getSessionWorkingDirectory(sessionId: string): Uri | undefined;
// Session metadata querying
getAllSessions(filter: (sessionId: string) => boolean | undefined | Promise<boolean | undefined>, token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]>;
getSessionItem(sessionId: string, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined>;
getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]>;
getAllSessionsIterable(token: CancellationToken): AsyncIterable<ICopilotCLISessionItem>;
// SDK session management
deleteSession(sessionId: string): Promise<void>;
@@ -67,7 +76,7 @@ export interface ICopilotCLISessionService {
// Session wrapper tracking
getSession(sessionId: string, options: { model?: string; workspaceInfo: IWorkspaceInfo; readonly: boolean; agent?: SweCustomAgent }, token: CancellationToken): Promise<IReference<ICopilotCLISession> | undefined>;
createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent }, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
createSession(options: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string }, token: CancellationToken): Promise<IReference<ICopilotCLISession>>;
tryGetPartialSesionHistory(sessionId: string): Promise<readonly (ChatRequestTurn2 | ChatResponseTurn2)[] | undefined>;
}
@@ -87,6 +96,14 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
private readonly _onDidChangeSessions = this._register(new Emitter<void>());
public readonly onDidChangeSessions = this._onDidChangeSessions.event;
private readonly _onDidDeleteSession = this._register(new Emitter<string>());
public readonly onDidDeleteSession = this._onDidDeleteSession.event;
private readonly _onDidChangeSession = this._register(new Emitter<ICopilotCLISessionItem>());
public readonly onDidChangeSession = this._onDidChangeSession.event;
private readonly _onDidCreateSession = this._register(new Emitter<ICopilotCLISessionItem>());
public readonly onDidCreateSession = this._onDidCreateSession.event;
private readonly sessionTerminators = new DisposableMap<string, IDisposable>();
private sessionMutexForGetSession = new Map<string, Mutex>();
@@ -94,6 +111,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
private readonly _sessionTracker: CopilotCLISessionWorkspaceTracker;
private readonly _sessionWorkingDirectories = new Map<string, Uri | undefined>();
private readonly _onDidChangeSessionsThrottler = this._register(new ThrottledDelayer<void>(500));
private readonly _cachedSessionItems = new Map<string, ICopilotCLISessionItem>();
constructor(
@ILogService protected readonly logService: ILogService,
@ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK,
@@ -108,6 +126,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
@ICopilotCLISkills private readonly copilotCLISkills: ICopilotCLISkills,
@IChatDelegationSummaryService private readonly _delegationSummaryService: IChatDelegationSummaryService,
@IChatSessionMetadataStore private readonly _chatSessionMetadataStore: IChatSessionMetadataStore,
@IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace,
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,
) {
super();
this.monitorSessionFiles();
@@ -146,8 +167,22 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
try {
const sessionDir = joinPath(this.nativeEnv.userHome, '.copilot', 'session-state');
const watcher = this._register(this.fileSystem.createFileSystemWatcher(new RelativePattern(sessionDir, '**/*.jsonl')));
this._register(watcher.onDidCreate((e) => this.triggerSessionsChangeEvent()));
this._register(watcher.onDidDelete((e) => this.triggerSessionsChangeEvent()));
this._register(watcher.onDidCreate(async (e) => {
this.triggerSessionsChangeEvent();
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
const sessionItem = sessionId ? await this.getSessionItem(sessionId, CancellationToken.None) : undefined;
if (sessionItem) {
this._onDidChangeSession.fire(sessionItem);
}
}));
this._register(watcher.onDidDelete(e => {
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
if (sessionId) {
this._cachedSessionItems.delete(sessionId);
this._onDidDeleteSession.fire(sessionId);
}
this.triggerSessionsChangeEvent();
}));
this._register(watcher.onDidChange((e) => {
// If we're busy fetching sessions, then do not trigger change event as we'll trigger one after we're done fetching sessions.
if (this._isGettingSessions > 0) {
@@ -165,6 +200,10 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
if (Array.from(this._sessionWrappersStillBeingClosed).some(([sessionId,]) => e.path.includes(sessionId))) {
return;
}
const sessionId = extractSessionIdFromEventPath(sessionDir, e);
if (sessionId) {
this.triggerOnDidChangeSessionItem(sessionId);
}
this.triggerSessionsChangeEvent();
}));
} catch (error) {
@@ -175,11 +214,87 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
return this._sessionManager.value;
}
private _sessionChangeNotifierByKey = new SequencerByKey<string>();
private async triggerOnDidChangeSessionItem(sessionId: string) {
return this._sessionChangeNotifierByKey.queue(sessionId, async () => {
// lets wait for 500ms, as we could get a lot of change events in a short period of time.
// E.g. if you have a session running in integrated terminal, then its possible we will see a lot of updates.
// In such cases its best to just delay (throttle) by 500ms (we get that via the sequncer and this delay)
await new Promise<void>(resolve => disposableTimeout(resolve, 500, this._store));
// If already getting all sessions, no point in triggering individual change event.
if (this._isGettingSessions > 0) {
return;
}
const sessionItem = await this.getSessionItem(sessionId, CancellationToken.None);
if (sessionItem) {
this._onDidChangeSession.fire(sessionItem);
}
});
}
public async getSessionItem(sessionId: string, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
// We can get the item from cache, as the ICopilotCLISessionItem doesn't store anything that changes.
// Except the title
let item = this._cachedSessionItems.get(sessionId);
if (!item) {
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
const sessionMetadataList = await raceCancellationError(sessionManager.listSessions(), token);
await this._sessionTracker.initialize();
const metadata = sessionMetadataList.find(s => s.sessionId === sessionId);
if (!metadata) {
return;
}
item = await this.constructSessionItem(metadata, token);
}
if (!item) {
return;
}
// Since this was a change event for an existing session, we must get the latest title.
const label = await this.getSessionTitle(sessionId, CancellationToken.None);
const sessionItem = Object.assign({}, item, { label });
return sessionItem;
}
public async getSessionTitle(sessionId: string, token: CancellationToken): Promise<string | undefined> {
return this.getSessionTitleImpl(sessionId, undefined, token);
}
/**
* Gets the session title.
* Always give preference to label defined by user, then title from CLI session object.
* If we have the metadata then use that over extracting label ourselves or using any cache.
*/
private async getSessionTitleImpl(sessionId: string, metadata: LocalSessionMetadata | undefined, token: CancellationToken): Promise<string | undefined> {
// Always give preference to label defined by user, then title from CLI and finally label from prompt summary. This is to ensure that if user has renamed the session, we do not override that with title from CLI or label from prompt.
const accurateTitle = this.customSessionTitleService.getCustomSessionTitle(sessionId) ??
labelFromPrompt(this._sessionWrappers.get(sessionId)?.object.pendingPrompt ?? '') ??
this._sessionWrappers.get(sessionId)?.object.title;
if (accurateTitle) {
return accurateTitle;
}
const summarizedTitle = labelFromPrompt(metadata?.summary ?? '');
if (summarizedTitle) {
if (summarizedTitle.endsWith('...')) {
// If the SDK is going to just give us a truncated version of the first user message as the summary, then we might as well extract the label ourselves from the first user message instead of using the truncated summary.
} else {
return summarizedTitle;
}
}
const firstUserMessage = await this.getFirstUserMessageFromSession(sessionId, token);
return labelFromPrompt(firstUserMessage ?? '');
}
private _getAllSessionsProgress: Promise<readonly ICopilotCLISessionItem[]> | undefined;
private _isGettingSessions: number = 0;
async getAllSessions(filter: (sessionId: string) => boolean | undefined | Promise<boolean | undefined>, token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
async getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
if (!this._getAllSessionsProgress) {
this._getAllSessionsProgress = this._getAllSessions(filter, token);
this._getAllSessionsProgress = this._getAllSessions(token);
}
return this._getAllSessionsProgress.finally(() => {
this._getAllSessionsProgress = undefined;
@@ -188,7 +303,7 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
private _sessionLabels: Map<string, string> = new Map();
async _getAllSessions(filter: (sessionId: string) => boolean | undefined | Promise<boolean | undefined>, token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
async _getAllSessions(token: CancellationToken): Promise<readonly ICopilotCLISessionItem[]> {
this._isGettingSessions++;
try {
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
@@ -198,38 +313,9 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
// Convert SessionMetadata to ICopilotCLISession
const diskSessions: ICopilotCLISessionItem[] = coalesce(await Promise.all(
sessionMetadataList.map(async (metadata): Promise<ICopilotCLISessionItem | undefined> => {
let showSession: boolean = false;
const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;
this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);
if (this.workspaceService.getWorkspaceFolders().length === 0) {
// If we're in empty workspace then show all sessions.
showSession = true;
} else {
const sessionFilterResult = await filter(metadata.sessionId);
const sessionTrackerVisibility = this._sessionTracker.shouldShowSession(metadata.sessionId);
// This session was started from a specified workspace (e.g. multiroot, untitled or other), hence continue showing it.
if (sessionTrackerVisibility.isWorkspaceSession) {
showSession = true;
}
if (!showSession && sessionFilterResult === true) {
showSession = true;
}
// If this is an old global session, then show it as well.
if (!showSession && sessionTrackerVisibility.isOldGlobalSession) {
// But if not required to be displayed, do not show it.
if (typeof sessionFilterResult === 'undefined') {
showSession = true;
}
}
// Possible we have the workspace info in cli metadata.
if (!showSession && metadata.context && (
(metadata.context.cwd && this.workspaceService.getWorkspaceFolder(URI.file(metadata.context.cwd))) ||
(metadata.context.gitRoot && this.workspaceService.getWorkspaceFolder(URI.file(metadata.context.gitRoot)))
)) {
showSession = true;
}
}
if (!showSession) {
if (!await this.shouldShowSession(metadata.sessionId, metadata.context)) {
return;
}
const id = metadata.sessionId;
@@ -306,13 +392,96 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
}
public async createSession({ model, workspaceInfo, agent }: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent }, token: CancellationToken): Promise<RefCountedSession> {
getAllSessionsIterable(token: CancellationToken): AsyncIterable<ICopilotCLISessionItem> {
return this._getAllSessionsIterable(token);
}
private _getAllSessionsIterable(token: CancellationToken): AsyncIterable<ICopilotCLISessionItem> {
this._isGettingSessions++;
return new AsyncIterableProducer<ICopilotCLISessionItem>(async (emitter) => {
try {
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
const sessionMetadataList = await raceCancellationError(sessionManager.listSessions(), token);
await this._sessionTracker.initialize();
const diskSessionIds = new Set<string>(sessionMetadataList.map(s => s.sessionId));
// Emit new in-memory sessions not yet persisted by SDK first.
const liveSessions = Promise.all(Array.from(this._sessionWrappers.values()).map(async session => {
if (diskSessionIds.has(session.object.sessionId)) {
return;
}
if (session.object.status !== ChatSessionStatus.InProgress) {
return;
}
const label = await this.getSessionTitle(session.object.sessionId, token);
if (!label) {
return;
}
const createTime = Date.now();
emitter.emitOne({
id: session.object.sessionId,
label,
status: session.object.status,
timing: { created: createTime, startTime: createTime },
});
}));
const diskSessions = Promise.all(sessionMetadataList.map(async metadata => {
const sessionItem = await this.constructSessionItem(metadata, token);
if (sessionItem) {
emitter.emitOne(sessionItem);
}
}));
await raceCancellation(Promise.allSettled([liveSessions, diskSessions]), token);
} catch (error) {
this.logService.error(`Failed to get all sessions: ${error}`);
throw error;
} finally {
this._isGettingSessions--;
}
});
}
private async constructSessionItem(metadata: LocalSessionMetadata, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
const sessionItem = await this.constructSessionItemImpl(metadata, token);
if (sessionItem) {
this._cachedSessionItems.set(metadata.sessionId, sessionItem);
}
return sessionItem;
}
private async constructSessionItemImpl(metadata: LocalSessionMetadata, token: CancellationToken): Promise<ICopilotCLISessionItem | undefined> {
const workingDirectory = metadata.context?.cwd ? URI.file(metadata.context.cwd) : undefined;
this._sessionWorkingDirectories.set(metadata.sessionId, workingDirectory);
const shouldShowSession = await this.shouldShowSession(metadata.sessionId, metadata.context);
if (!shouldShowSession) {
return undefined;
}
const id = metadata.sessionId;
const startTime = metadata.startTime.getTime();
const endTime = metadata.modifiedTime.getTime();
const label = await this.getSessionTitleImpl(metadata.sessionId, metadata, token) ?? labelFromPrompt(metadata.summary ?? '');
if (label) {
return {
id,
label,
timing: { created: startTime, startTime, endTime },
workingDirectory,
status: this._sessionWrappers.get(id)?.object?.status
};
}
}
public async createSession({ model, workspaceInfo, agent, sessionId }: { model?: string; workspaceInfo: IWorkspaceInfo; agent?: SweCustomAgent; sessionId?: string }, token: CancellationToken): Promise<RefCountedSession> {
const { mcpConfig: mcpServers, disposable: mcpGateway } = await this.mcpHandler.loadMcpConfig();
try {
const copilotUrl = this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl) || undefined;
const options = await this.createSessionsOptions({ model, workspaceInfo, mcpServers, agent, copilotUrl });
const sessionManager = await raceCancellationError(this.getSessionManager(), token);
const sdkSession = await sessionManager.createSession(options.toSessionOptions());
const sdkSession = await sessionManager.createSession({ ...options.toSessionOptions(), sessionId });
if (copilotUrl) {
sdkSession.setAuthInfo({
type: 'hmac',
@@ -338,6 +507,46 @@ export class CopilotCLISessionService extends Disposable implements ICopilotCLIS
}
}
private async shouldShowSession(sessionId: string, context?: SessionContext): Promise<boolean> {
if (isUntitledSessionId(sessionId)) {
return true;
}
// If we're in an empty workspace then show all sessions.
if (this.workspaceService.getWorkspaceFolders().length === 0) {
return true;
}
if (this._agentSessionsWorkspace.isAgentSessionsWorkspace) {
return true;
}
// This session was started from a specified workspace (e.g. multiroot, untitled or other), hence continue showing it.
const sessionTrackerVisibility = this._sessionTracker.shouldShowSession(sessionId);
if (sessionTrackerVisibility.isWorkspaceSession) {
return true;
}
// Possible we have the workspace info in cli metadata.
if (context && (
(context.cwd && this.workspaceService.getWorkspaceFolder(URI.file(context.cwd))) ||
(context.gitRoot && this.workspaceService.getWorkspaceFolder(URI.file(context.gitRoot)))
)) {
return true;
}
// If we have a workspace folder for this and the workspace folder belongs to one of the open workspace folders, show it.
const workspaceFolder = await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);
if (workspaceFolder && this.workspaceService.getWorkspaceFolder(workspaceFolder)) {
return true;
}
// If we have a git worktree and the worktree's repo belongs to one of the workspace folders, show it.
const worktree = await this.worktreeManager.getWorktreeProperties(sessionId);
if (worktree && this.workspaceService.getWorkspaceFolder(URI.file(worktree.repositoryPath))) {
return true;
}
// If this is an old global session, show it if we don't have specific data to exclude it.
if (sessionTrackerVisibility.isOldGlobalSession && !workspaceFolder && !worktree && (this.workspaceService.getWorkspaceFolders().length === 0 || this._agentSessionsWorkspace.isAgentSessionsWorkspace)) {
return true;
}
return false;
}
protected async createSessionsOptions(options: { model?: string; workspaceInfo: IWorkspaceInfo; mcpServers?: SessionOptions['mcpServers']; agent: SweCustomAgent | undefined; copilotUrl?: string }, readonly?: boolean): Promise<CopilotCLISessionOptions> {
const [customAgents, skillLocations] = await Promise.all([
this.agents.getAgents(),
@@ -657,6 +866,22 @@ function labelFromPrompt(prompt: string): string {
return stripReminders(prompt);
}
/**
* Extracts the session ID from a deleted events.jsonl file path.
* Expected path format: <sessionDir>/<sessionId>/events.jsonl
*/
function extractSessionIdFromEventPath(sessionDir: URI, deletedFileUri: URI): string | undefined {
if (basename(deletedFileUri) !== 'events.jsonl') {
return undefined;
}
const parentDir = dirname(deletedFileUri);
const parentOfParent = dirname(parentDir);
if (parentOfParent.path !== sessionDir.path) {
return undefined;
}
return basename(parentDir);
}
export class Mutex {
private _locked = false;
private readonly _acquireQueue: (() => void)[] = [];
@@ -26,6 +26,9 @@ import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } fr
import { URI } from '../../../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation';
import { createExtensionUnitTestingServices } from '../../../../test/node/services';
import { IAgentSessionsWorkspace } from '../../../common/agentSessionsWorkspace';
import { IChatSessionWorkspaceFolderService } from '../../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../../../common/chatSessionWorktreeService';
import { MockChatSessionMetadataStore } from '../../../common/test/mockChatSessionMetadataStore';
import { IWorkspaceInfo } from '../../../common/workspaceInfo';
import { FakeToolsService } from '../../common/copilotCLITools';
@@ -139,6 +142,25 @@ export class NullCopilotCLIMCPHandler implements ICopilotCLIMCPHandler {
}
}
class NullAgentSessionsWorkspace implements IAgentSessionsWorkspace {
_serviceBrand: undefined;
readonly isAgentSessionsWorkspace = false;
}
class NullChatSessionWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
override getRecentFolders = vi.fn(async () => []);
override deleteRecentFolder = vi.fn(async () => { });
override deleteTrackedWorkspaceFolder = vi.fn(async () => { });
override trackSessionWorkspaceFolder = vi.fn(async () => { });
override getSessionWorkspaceFolder = vi.fn(async () => undefined);
override handleRequestCompleted = vi.fn(async () => { });
override getWorkspaceChanges = vi.fn(async () => undefined);
}
class NullChatSessionWorktreeService extends mock<IChatSessionWorktreeService>() {
override getWorktreeProperties: IChatSessionWorktreeService['getWorktreeProperties'] = vi.fn(async () => undefined);
}
function workspaceInfoFor(workingDirectory: Uri | undefined): IWorkspaceInfo {
return {
folder: workingDirectory,
@@ -154,6 +176,14 @@ function sessionOptionsFor(workingDirectory?: Uri) {
};
}
async function collectIterable<T>(iterable: AsyncIterable<T>): Promise<T[]> {
const items: T[] = [];
for await (const item of iterable) {
items.push(item);
}
return items;
}
describe('CopilotCLISessionService', () => {
const disposables = new DisposableStore();
let logService: ILogService;
@@ -213,7 +243,7 @@ describe('CopilotCLISessionService', () => {
const configurationService = accessor.get(IConfigurationService);
const nullMcpServer = disposables.add(new NullMcpService());
const titleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext);
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore()));
service = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), new MockFileSystemService(), new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), cliAgents, workspaceService, titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
manager = await service.getSessionManager() as unknown as MockCliSdkSessionManager;
});
@@ -372,7 +402,7 @@ describe('CopilotCLISessionService', () => {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
await mkdir(sessionDir.fsPath, { recursive: true });
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
@@ -407,7 +437,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
await mkdir(sessionDir.fsPath, { recursive: true });
const eventsFilePath = join(sessionDir.fsPath, 'events.jsonl');
@@ -446,7 +476,7 @@ describe('CopilotCLISessionService', () => {
s1.events.push({ type: 'user.message', data: { content: 'a'.repeat(100) }, timestamp: '2024-01-01T00:00:00.000Z' });
manager.sessions.set(s1.sessionId, s1);
const result = await service.getAllSessions(() => true, CancellationToken.None);
const result = await service.getAllSessions(CancellationToken.None);
expect(result.length).toBe(1);
const item = result[0];
@@ -476,7 +506,7 @@ describe('CopilotCLISessionService', () => {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
@@ -492,14 +522,14 @@ describe('CopilotCLISessionService', () => {
JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'Use fallback history', attachments: [] } }),
].join('\n'));
const sessions = await partialService.getAllSessions(() => true, CancellationToken.None);
const sessions = await partialService.getAllSessions(CancellationToken.None);
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(sessionId);
expect(sessions[0].label).toBe('Use fallback history');
});
it('falls back to metadata summary as label when partial history has no user turns', async () => {
it('does not emit session when summary is truncated and no user turns exist', async () => {
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
process.env.XDG_STATE_HOME = tempStateHome;
const sessionId = 'no-user-turns-session';
@@ -518,7 +548,7 @@ describe('CopilotCLISessionService', () => {
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore()));
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
// Session has a summary with '<' (which forces the session-load fallback path)
@@ -536,7 +566,7 @@ describe('CopilotCLISessionService', () => {
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),
].join('\n'));
const sessions = await partialService.getAllSessions(() => true, CancellationToken.None);
const sessions = await partialService.getAllSessions(CancellationToken.None);
// Session still appears, using the metadata summary as a best-effort label
expect(sessions).toHaveLength(1);
@@ -545,6 +575,109 @@ describe('CopilotCLISessionService', () => {
});
});
describe('CopilotCLISessionService.getAllSessionsIterable', () => {
it('will not list created sessions', async () => {
const session = await service.createSession({ model: 'gpt-test', ...sessionOptionsFor(URI.file('/tmp')) }, CancellationToken.None);
disposables.add(session);
const s1 = new MockCliSdkSession('s1', new Date(0));
s1.messages.push({ role: 'user', content: 'a'.repeat(100) });
s1.events.push({ type: 'user.message', data: { content: 'a'.repeat(100) }, timestamp: '2024-01-01T00:00:00.000Z' });
manager.sessions.set(s1.sessionId, s1);
const result = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
expect(result.length).toBe(1);
const item = result[0];
expect(item.id).toBe('s1');
});
it('falls back to partial session data when getSession fails with an unknown event type', async () => {
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
process.env.XDG_STATE_HOME = tempStateHome;
const sessionId = 'invalid-session-iterable';
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
const fileSystem = new MockFileSystemService();
const sdk = {
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession }))
} as unknown as ICopilotCLISDK;
const services = createExtensionUnitTestingServices();
disposables.add(services);
const accessor = services.createTestingAccessor();
const configurationService = accessor.get(IConfigurationService);
const authService = {
getCopilotToken: vi.fn(async () => ({ token: 'test-token' })),
} as unknown as IAuthenticationService;
const nullMcpServer = disposables.add(new NullMcpService());
const titleServce = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext);
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined {
return undefined;
}
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleServce, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
session.summary = 'Broken summary <current_dateti...';
partialManager.sessions.set(sessionId, session);
partialManager.getSession = vi.fn(async () => {
throw new Error('Failed to load session. Unknown event type: custom.unknown.');
}) as unknown as typeof partialManager.getSession;
await mkdir(sessionDir.fsPath, { recursive: true });
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),
JSON.stringify({ id: '2', type: 'user.message', timestamp: '2024-01-01T00:00:01.000Z', parentId: '1', data: { content: 'Use fallback history', attachments: [] } }),
].join('\n'));
const sessions = await collectIterable(partialService.getAllSessionsIterable(CancellationToken.None));
expect(sessions).toHaveLength(1);
expect(sessions[0].id).toBe(sessionId);
expect(sessions[0].label).toBe('Use fallback history');
});
it('falls back to metadata summary as label when partial history has no user turns', async () => {
tempStateHome = await mkdtemp(join(tmpdir(), 'copilot-cli-session-service-'));
process.env.XDG_STATE_HOME = tempStateHome;
const sessionId = 'no-user-turns-session-iterable';
const sessionDir = URI.file(getCopilotCLISessionDir(sessionId));
const fileSystem = new MockFileSystemService();
const sdk = {
getPackage: vi.fn(async () => ({ internal: { LocalSessionManager: MockCliSdkSessionManager, NoopTelemetryService: class { } }, LocalSession: MockLocalSession }))
} as unknown as ICopilotCLISDK;
const services = createExtensionUnitTestingServices();
disposables.add(services);
const accessor = services.createTestingAccessor();
const configurationService = accessor.get(IConfigurationService);
const authService = { getCopilotToken: vi.fn(async () => ({ token: 'test-token' })) } as unknown as IAuthenticationService;
const nullMcpServer = disposables.add(new NullMcpService());
const titleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext);
const delegationService = new class extends mock<IChatDelegationSummaryService>() {
override extractPrompt(): { prompt: string; reference: never } | undefined { return undefined; }
}();
const partialService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, new CopilotCLIMCPHandler(logService, authService, configurationService, nullMcpServer), new NullCopilotCLIAgents(), new NullWorkspaceService(), titleService, configurationService, new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), new NullAgentSessionsWorkspace(), new NullChatSessionWorkspaceFolderService(), new NullChatSessionWorktreeService()));
const partialManager = await partialService.getSessionManager() as unknown as MockCliSdkSessionManager;
const session = new MockCliSdkSession(sessionId, new Date('2024-01-01T00:00:00.000Z'));
session.summary = 'Summary without user turns <current_dateti...';
partialManager.sessions.set(sessionId, session);
partialManager.getSession = vi.fn(async () => {
throw new Error('Failed to load session. Unknown event type: custom.unknown.');
}) as unknown as typeof partialManager.getSession;
await mkdir(sessionDir.fsPath, { recursive: true });
await writeNodeFile(join(sessionDir.fsPath, 'events.jsonl'), [
JSON.stringify({ id: '1', type: 'session.start', timestamp: '2024-01-01T00:00:00.000Z', parentId: null, data: { sessionId, startTime: '2024-01-01T00:00:00.000Z', selectedModel: 'gpt-test', version: 1, producer: 'test', copilotVersion: '1.0.0', context: { cwd: URI.file('/workspace/project').fsPath } } }),
].join('\n'));
const sessions = await collectIterable(partialService.getAllSessionsIterable(CancellationToken.None));
expect(sessions).toHaveLength(0);
});
});
describe('CopilotCLISessionService.deleteSession', () => {
it('disposes active wrapper, removes from manager and fires change event', async () => {
const session = await service.createSession({ ...sessionOptionsFor() }, CancellationToken.None);
@@ -609,7 +742,7 @@ describe('CopilotCLISessionService', () => {
s.events.push({ type: 'user.message', data: { content: 'Line1\nLine2' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
const sessions = await service.getAllSessions(() => true, CancellationToken.None);
const sessions = await service.getAllSessions(CancellationToken.None);
const item = sessions.find(i => i.id === 'lab1');
expect(item?.label).includes('Line1');
expect(item?.label).includes('Line2');
@@ -622,7 +755,7 @@ describe('CopilotCLISessionService', () => {
manager.sessions.set(s.sessionId, s);
const getSessionSpy = vi.spyOn(manager, 'getSession');
const sessions = await service.getAllSessions(() => true, CancellationToken.None);
const sessions = await service.getAllSessions(CancellationToken.None);
const item = sessions.find(i => i.id === 'summary1');
expect(item?.label).toBe('Fix the login bug');
@@ -637,7 +770,7 @@ describe('CopilotCLISessionService', () => {
manager.sessions.set(s.sessionId, s);
const getSessionSpy = vi.spyOn(manager, 'getSession');
const sessions = await service.getAllSessions(() => true, CancellationToken.None);
const sessions = await service.getAllSessions(CancellationToken.None);
const item = sessions.find(i => i.id === 'truncated1');
expect(item?.label).toBe('Fix the bug in the parser');
@@ -652,7 +785,7 @@ describe('CopilotCLISessionService', () => {
manager.sessions.set(s.sessionId, s);
// First call - loads session and caches the label
const sessions1 = await service.getAllSessions(() => true, CancellationToken.None);
const sessions1 = await service.getAllSessions(CancellationToken.None);
const item1 = sessions1.find(i => i.id === 'cache1');
expect(item1?.label).toBe('Refactor the tests');
@@ -660,27 +793,27 @@ describe('CopilotCLISessionService', () => {
const getSessionSpy = vi.spyOn(manager, 'getSession');
// Second call - should use cached label
const sessions2 = await service.getAllSessions(() => true, CancellationToken.None);
const sessions2 = await service.getAllSessions(CancellationToken.None);
const item2 = sessions2.find(i => i.id === 'cache1');
expect(item2?.label).toBe('Refactor the tests');
// Should not have loaded the full session on second call
expect(getSessionSpy).not.toHaveBeenCalled();
});
it('cached label takes priority over metadata summary', async () => {
it('uses metadata summary over stale internal label cache', async () => {
const s = new MockCliSdkSession('priority1', new Date());
// No summary initially - forces session load and caching
s.events.push({ type: 'user.message', data: { content: 'Original label from events' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
// First call caches label from events
const sessions1 = await service.getAllSessions(() => true, CancellationToken.None);
const sessions1 = await service.getAllSessions(CancellationToken.None);
expect(sessions1.find(i => i.id === 'priority1')?.label).toBe('Original label from events');
// Now add a summary to the metadata - the cached label should still be used
s.summary = 'Different summary label';
const sessions2 = await service.getAllSessions(() => true, CancellationToken.None);
const sessions2 = await service.getAllSessions(CancellationToken.None);
expect(sessions2.find(i => i.id === 'priority1')?.label).toBe('Original label from events');
});
@@ -689,7 +822,7 @@ describe('CopilotCLISessionService', () => {
s.events.push({ type: 'user.message', data: { content: 'Add unit tests for auth' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
await service.getAllSessions(() => true, CancellationToken.None);
await service.getAllSessions(CancellationToken.None);
// Verify the internal cache was populated
const labelCache = (service as any)._sessionLabels as Map<string, string>;
@@ -701,7 +834,7 @@ describe('CopilotCLISessionService', () => {
s.summary = 'Clean summary without brackets';
manager.sessions.set(s.sessionId, s);
await service.getAllSessions(() => true, CancellationToken.None);
await service.getAllSessions(CancellationToken.None);
// The cache should not have an entry since the summary was used directly
const labelCache = (service as any)._sessionLabels as Map<string, string>;
@@ -709,6 +842,113 @@ describe('CopilotCLISessionService', () => {
});
});
describe('CopilotCLISessionService.getAllSessionsIterable label generation', () => {
it('uses first user message line when present', async () => {
const s = new MockCliSdkSession('lab1-iter', new Date());
s.messages.push({ role: 'user', content: 'Line1\nLine2' });
s.events.push({ type: 'user.message', data: { content: 'Line1\nLine2' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
const sessions = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
const item = sessions.find(i => i.id === 'lab1-iter');
expect(item?.label).includes('Line1');
expect(item?.label).includes('Line2');
});
it('uses clean summary from metadata without loading the full session', async () => {
const s = new MockCliSdkSession('summary1-iter', new Date());
s.summary = 'Fix the login bug';
s.events.push({ type: 'user.message', data: { content: 'Fix the login bug in auth.ts' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
const getSessionSpy = vi.spyOn(manager, 'getSession');
const sessions = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
const item = sessions.find(i => i.id === 'summary1-iter');
expect(item?.label).toBe('Fix the login bug');
// Should not have loaded the full session since summary was clean
expect(getSessionSpy).not.toHaveBeenCalled();
});
it('falls through to session load when summary contains angle bracket', async () => {
const s = new MockCliSdkSession('truncated1-iter', new Date());
s.summary = 'Fix the bug... <current_dateti...';
s.events.push({ type: 'user.message', data: { content: 'Fix the bug in the parser' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
const getSessionSpy = vi.spyOn(manager, 'getSession');
const sessions = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
const item = sessions.find(i => i.id === 'truncated1-iter');
expect(item?.label).toBe('Fix the bug in the parser');
// Should have loaded the full session because summary had '<'
expect(getSessionSpy).toHaveBeenCalled();
});
it('uses cached label on second call without loading session again', async () => {
const s = new MockCliSdkSession('cache1-iter', new Date());
// No summary forces session load on first call
s.events.push({ type: 'user.message', data: { content: 'Refactor the tests' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
// First call - loads session and caches the label
const sessions1 = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
const item1 = sessions1.find(i => i.id === 'cache1-iter');
expect(item1?.label).toBe('Refactor the tests');
// Now spy on getSession for the second call
const getSessionSpy = vi.spyOn(manager, 'getSession');
// Second call - should use cached label
const sessions2 = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
const item2 = sessions2.find(i => i.id === 'cache1-iter');
expect(item2?.label).toBe('Refactor the tests');
// Should not have loaded the full session on second call
expect(getSessionSpy).not.toHaveBeenCalled();
});
it('cached label takes priority over metadata summary', async () => {
const s = new MockCliSdkSession('priority1-iter', new Date());
// No summary initially - forces session load and caching
s.events.push({ type: 'user.message', data: { content: 'Original label from events' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
// First call caches label from events
const sessions1 = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
expect(sessions1.find(i => i.id === 'priority1-iter')?.label).toBe('Original label from events');
// Now add a summary to the metadata - the cached label should still be used
s.summary = 'Different summary label';
const sessions2 = await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
expect(sessions2.find(i => i.id === 'priority1-iter')?.label).toBe('Different summary label');
});
it('does not populate legacy label cache after loading session for label', async () => {
const s = new MockCliSdkSession('populate1-iter', new Date());
s.events.push({ type: 'user.message', data: { content: 'Add unit tests for auth' }, timestamp: Date.now().toString() });
manager.sessions.set(s.sessionId, s);
await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
// Iterable path uses metadata-derived titles and no longer writes to legacy _sessionLabels cache.
const labelCache = (service as any)._sessionLabels as Map<string, string>;
expect(labelCache.has('populate1-iter')).toBe(false);
});
it('does not cache when using clean summary from metadata directly', async () => {
const s = new MockCliSdkSession('nocache1-iter', new Date());
s.summary = 'Clean summary without brackets';
manager.sessions.set(s.sessionId, s);
await collectIterable(service.getAllSessionsIterable(CancellationToken.None));
// The cache should not have an entry since the summary was used directly
const labelCache = (service as any)._sessionLabels as Map<string, string>;
expect(labelCache.has('nocache1-iter')).toBe(false);
});
});
describe('CopilotCLISessionService.auto disposal timeout', () => {
it.skip('disposes session after completion timeout and aborts underlying sdk session', async () => {
vi.useFakeTimers();
@@ -0,0 +1,14 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { workspace } from 'vscode';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
export class AgentSessionsWorkspace implements IAgentSessionsWorkspace {
declare _serviceBrand: undefined;
get isAgentSessionsWorkspace(): boolean {
return workspace.isAgentSessionsWorkspace;
}
}
@@ -30,6 +30,7 @@ import { ClaudeSessionStateService, IClaudeSessionStateService } from '../claude
import { ClaudeSessionTitleService, IClaudeSessionTitleService } from '../claude/node/claudeSessionTitleService';
import { ClaudeCodeSessionService, IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCodeSessionService';
import { ClaudeSlashCommandService, IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
@@ -47,6 +48,7 @@ import { IUserQuestionHandler } from '../copilotcli/node/userInputHelpers';
import { CopilotCLIContrib, getServices } from '../copilotcli/vscode-node/contribution';
import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';
import { GHPR_EXTENSION_ID } from '../vscode/chatSessionsUriHandler';
import { AgentSessionsWorkspace } from './agentSessionsWorkspace';
import { UserQuestionHandler } from './askUserQuestionHandler';
import { ChatSessionMetadataStore } from './chatSessionMetadataStoreImpl';
import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderServiceImpl';
@@ -130,6 +132,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const cloudSessionProvider = this.registerCopilotCloudAgent();
const copilotcliAgentInstaService = instantiationService.createChild(
new ServiceCollection(
[IAgentSessionsWorkspace, new SyncDescriptor(AgentSessionsWorkspace)],
[ICopilotCLIImageSupport, new SyncDescriptor(CopilotCLIImageSupport)],
[ICopilotCLISessionService, new SyncDescriptor(CopilotCLISessionService)],
[IChatDelegationSummaryService, delegationSummary],
@@ -9,8 +9,8 @@ import * as vscode from 'vscode';
import { ChatExtendedRequestHandler, ChatSessionProviderOptionItem, Uri } from 'vscode';
import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { INativeEnvService } from '../../../platform/env/common/envService';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
import { IGitService, RepoContext } from '../../../platform/git/common/gitService';
@@ -28,7 +28,9 @@ import { Disposable, DisposableStore, IDisposable, IReference, toDisposable } fr
import { relative } from '../../../util/vs/base/common/path';
import { basename, dirname, extUri, isEqual } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { ChatVariablesCollection, isPromptFile } from '../../prompt/common/chatVariablesCollection';
import { ChatTitleProvider } from '../../prompt/node/title';
import { IToolsService } from '../../tools/common/toolsService';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
@@ -38,8 +40,6 @@ import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspace
import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
import { ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK } from '../copilotcli/node/copilotCli';
import { ChatTitleProvider } from '../../prompt/node/title';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver';
import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
@@ -117,7 +117,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
@ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration,
@IChatSessionWorktreeService private readonly worktreeManager: IChatSessionWorktreeService,
@IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
@IGitService private readonly gitService: IGitService,
@@ -128,10 +127,34 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
this.useController = configurationService.getConfig(ConfigKey.Advanced.CLISessionController);
if (this.useController) {
this.controller = this._register(vscode.chat.createChatSessionItemController(
const controller = this.controller = this._register(vscode.chat.createChatSessionItemController(
'copilotcli',
() => this.refreshControllerItems()
async () => {
const ctx = new vscode.CancellationTokenSource();
try {
const insertSession = async (session: ICopilotCLISessionItem) => {
const item = await this._toChatSessionItem(session);
controller.items.add(item);
};
for await (const session of this.copilotcliSessionService.getAllSessionsIterable(ctx.token)) {
void insertSession(session);
}
} finally {
ctx.dispose();
}
}
));
this._register(this.copilotcliSessionService.onDidDeleteSession(async (e) => {
controller.items.delete(SessionIdForCLI.getResource(e));
}));
this._register(this.copilotcliSessionService.onDidChangeSession(async (e) => {
const item = await this._toChatSessionItem(e);
controller.items.add(item);
}));
this._register(this.copilotcliSessionService.onDidCreateSession(async (e) => {
const item = await this._toChatSessionItem(e);
controller.items.add(item);
}));
}
this._register(this.copilotcliSessionService.onDidChangeSessions(() => {
@@ -140,25 +163,30 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
}
public notifySessionsChange(): void {
if (this.useController) {
void this.refreshControllerItems();
this._onDidChangeChatSessionItems.fire();
}
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'delete'; sessionId: string }): Promise<void> {
if (refreshOptions.reason === 'delete') {
const uri = SessionIdForCLI.getResource(refreshOptions.sessionId);
this.controller!.items.delete(uri);
} else {
this._onDidChangeChatSessionItems.fire();
const item = await this.copilotcliSessionService.getSessionItem(refreshOptions.sessionId, CancellationToken.None);
if (item) {
const chatSessionItem = await this._toChatSessionItem(item);
this.controller!.items.add(chatSessionItem);
}
}
}
public swap(original: vscode.ChatSessionItem, modified: vscode.ChatSessionItem): void {
if (this.useController) {
this.controller!.items.delete(original.resource);
const item = this.controller!.createChatSessionItem(modified.resource, modified.label);
this.controller!.items.add(item);
} else {
if (!this.useController) {
this._onDidCommitChatSessionItem.fire({ original, modified });
}
}
public async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {
const sessions = await this.copilotcliSessionService.getAllSessions(this.shouldShowSession.bind(this), token);
const sessions = await this.copilotcliSessionService.getAllSessions(token);
const diskSessions = await Promise.all(sessions.map(async session => this._toChatSessionItem(session)));
const count = diskSessions.length;
@@ -167,41 +195,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
return diskSessions;
}
private async refreshControllerItems(): Promise<void> {
const ctx = new vscode.CancellationTokenSource();
try {
const sessions = await this.provideChatSessionItems(ctx.token);
this.controller!.items.replace(sessions);
} finally {
ctx.dispose();
}
}
private async shouldShowSession(sessionId: string): Promise<boolean | undefined> {
if (
isUntitledSessionId(sessionId) || // always show untitled sessions
vscode.workspace.isAgentSessionsWorkspace // always all sessions in agent sessions workspace
) {
return true;
}
// If we have a workspace folder for this and the workspace folder belongs to one of the open workspace folders, show it.
const workspaceFolder = await this.workspaceFolderService.getSessionWorkspaceFolder(sessionId);
if (workspaceFolder && this.workspaceService.getWorkspaceFolders().length) {
return !!this.workspaceService.getWorkspaceFolder(workspaceFolder);
}
// If we have a git worktree and the worktree's repo belongs to one of the workspace folders, show it.
const worktree = await this.worktreeManager.getWorktreeProperties(sessionId);
if (worktree && this.workspaceService.getWorkspaceFolders().length) {
// If we have a repository path, then its easy to tell whether this should be displayed or hidden.
return !!this.workspaceService.getWorkspaceFolder(URI.file(worktree.repositoryPath));
}
// Unless we are in an empty window, exclude sessions without workspace folder or git repo association.
if (this.workspaceService.getWorkspaceFolders().length) {
return false;
}
return undefined;
}
private shouldShowBadge(): boolean {
const repositories = this.gitService.repositories
.filter(repository => repository.kind !== 'worktree');
@@ -1083,7 +1076,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
void this.generateAndStoreSessionTitle(request, context, token);
}
const [modelId, agent] = await Promise.all([
const [model, agent] = await Promise.all([
this.getModelId(request, token),
this.getAgent(id, request, token),
]);
@@ -1092,7 +1085,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
this.contentProvider.notifySessionOptionsChange(resource, changes);
}
const sessionResult = await this.getOrCreateSession(request, chatSessionContext, modelId, agent, stream, disposables, token);
const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent }, disposables, token);
const session = sessionResult.session;
if (session) {
disposables.add(session);
@@ -1125,22 +1118,22 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
// This is a request that was created in createCLISessionAndSubmitRequest with attachments already resolved.
const { prompt, attachments } = contextForRequest;
this.contextForRequest.delete(session.object.sessionId);
await session.object.handleRequest(request, { prompt }, attachments, modelId, authInfo, token);
await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token);
await this.commitWorktreeChangesIfNeeded(session.object, token);
} else if (request.command && !request.prompt && !isUntitled) {
const input = (copilotCLICommands as readonly string[]).includes(request.command)
? { command: request.command as CopilotCLICommand }
: { prompt: `/${request.command}` };
await session.object.handleRequest(request, input, [], modelId, authInfo, token);
await session.object.handleRequest(request, input, [], model, authInfo, token);
await this.commitWorktreeChangesIfNeeded(session.object, token);
} else if (request.prompt && Object.values(builtinSlashSCommands).includes(request.prompt)) {
await session.object.handleRequest(request, { prompt: request.prompt }, [], modelId, authInfo, token);
await session.object.handleRequest(request, { prompt: request.prompt }, [], model, authInfo, token);
await this.commitWorktreeChangesIfNeeded(session.object, token);
} else {
// Construct the full prompt with references to be sent to CLI.
const plan = request.modeInstructions2 ? isCopilotCLIPlanAgent(request.modeInstructions2) : false;
const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token);
await session.object.handleRequest(request, { prompt, plan }, attachments, modelId, authInfo, token);
await session.object.handleRequest(request, { prompt, plan }, attachments, model, authInfo, token);
await this.commitWorktreeChangesIfNeeded(session.object, token);
}
@@ -1278,6 +1271,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
changes: undefined,
});
this.sessionItemProvider.notifySessionsChange();
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: session.sessionId });
}
} catch (error) {
this.logService.error(`Failed to persist pull request metadata: ${error instanceof Error ? error.message : String(error)}`);
@@ -1332,7 +1326,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
}
}
private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, model: string | undefined, agent: SweCustomAgent | undefined, stream: vscode.ChatResponseStream, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; trusted: boolean }> {
private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: string | undefined; agent: SweCustomAgent | undefined }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference<ICopilotCLISession> | undefined; trusted: boolean }> {
const { resource } = chatSessionContext.chatSessionItem;
const existingSessionId = this.sessionItemProvider.untitledSessionIdMapping.get(SessionIdForCLI.parse(resource));
const id = existingSessionId ?? SessionIdForCLI.parse(resource);
@@ -1345,10 +1339,14 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
return { session: undefined, trusted };
}
const model = options.model;
const agent = options.agent;
const session = isNewSession ?
await this.sessionService.createSession({ model, workspaceInfo, agent }, token) :
await this.sessionService.getSession(id, { model, workspaceInfo, readonly: false, agent }, token);
this.sessionItemProvider.notifySessionsChange();
// TODO @DonJayamanne We need to refresh to add this new session, but we need a label.
// So when creating a session we need a dummy label (or an initial prompt).
if (!session) {
stream.warning(l10n.t('Chat session not found.'));
@@ -1497,6 +1495,8 @@ export class CopilotCLIChatSessionParticipant extends Disposable {
try {
this.contextForRequest.set(session.object.sessionId, { prompt, attachments });
this.sessionItemProvider.notifySessionsChange();
// TODO @DonJayamanne I don't think we need to refresh the list of session here just yet, or perhaps we do,
// Same as getOrCreate session, we need a dummy title or the initial prompt to show in the sessions list.
await vscode.commands.executeCommand('workbench.action.chat.openSessionWithPrompt.copilotcli', {
resource: SessionIdForCLI.getResource(session.object.sessionId),
prompt: userPrompt || request.prompt,
@@ -1601,6 +1601,7 @@ export function registerCLIChatCommands(
}
copilotcliSessionItemProvider.notifySessionsChange();
await copilotcliSessionItemProvider.refreshSession({ reason: 'delete', sessionId: id });
}
}
}));
@@ -1629,6 +1630,7 @@ export function registerCLIChatCommands(
if (trimmedTitle) {
await copilotCLISessionService.renameSession(id, trimmedTitle);
copilotcliSessionItemProvider.notifySessionsChange();
await copilotcliSessionItemProvider.refreshSession({ reason: 'update', sessionId: id });
}
}
}));
@@ -1870,6 +1872,7 @@ export function registerCLIChatCommands(
// Pick up new git state
copilotcliSessionItemProvider.notifySessionsChange();
await copilotcliSessionItemProvider.refreshSession({ reason: 'update', sessionId });
} catch (error) {
vscode.window.showErrorMessage(l10n.t('Failed to apply changes to the current workspace. Please stage or commit your changes in the current workspace and try again.'), { modal: true });
}
@@ -1894,6 +1897,7 @@ export function registerCLIChatCommands(
// Pick up new git state
copilotcliSessionItemProvider.notifySessionsChange();
await copilotcliSessionItemProvider.refreshSession({ reason: 'update', sessionId });
} catch (error) {
vscode.window.showErrorMessage(l10n.t('Failed to merge worktree branch into the base branch. Please resolve any conflicts and try again.'), { modal: true });
}
@@ -1923,6 +1927,7 @@ export function registerCLIChatCommands(
// Pick up new git state
copilotcliSessionItemProvider.notifySessionsChange();
await copilotcliSessionItemProvider.refreshSession({ reason: 'update', sessionId });
} catch (error) {
vscode.window.showErrorMessage(l10n.t('Failed to update worktree branch. Please resolve any conflicts and try again.'), { modal: true });
}
@@ -2014,6 +2019,9 @@ export function registerCLIChatCommands(
logService.trace('[commitToWorktree] Notifying sessions change');
copilotcliSessionItemProvider.notifySessionsChange();
if (sessionId) {
await copilotcliSessionItemProvider.refreshSession({ reason: 'update', sessionId });
}
} catch (error) {
const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string };
const normalizedStdout = stdout.toLowerCase();
@@ -33,8 +33,10 @@ import { createExtensionUnitTestingServices } from '../../../test/node/services'
import { MockChatResponseStream, TestChatRequest } from '../../../test/node/testHelpers';
import { type IToolsService } from '../../../tools/common/toolsService';
import { mockLanguageModelChat } from '../../../tools/node/test/searchToolTestUtils';
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService, type ChatSessionWorktreeFile, type ChatSessionWorktreeProperties } from '../../common/chatSessionWorktreeService';
import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore';
import { getWorkingDirectory, IWorkspaceInfo } from '../../common/workspaceInfo';
import { IChatDelegationSummaryService } from '../../copilotcli/common/delegationSummaryService';
import { type CopilotCLIModelInfo, type ICopilotCLIModels, type ICopilotCLISDK } from '../../copilotcli/node/copilotCli';
@@ -48,7 +50,6 @@ import { IUserQuestionHandler, UserInputRequest, UserInputResponse } from '../..
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant } from '../copilotCLIChatSessionsContribution';
import { CopilotCloudSessionsProvider } from '../copilotCloudSessionsProvider';
import { CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';
import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore';
// Mock terminal integration to avoid importing PowerShell asset (.ps1) which Vite cannot parse during tests
vi.mock('../copilotCLITerminalIntegration', () => {
@@ -332,7 +333,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
}
} as unknown as IInstantiationService;
customSessionTitleService = new CustomSessionTitleService(new MockExtensionContext() as unknown as IVSCodeExtensionContext);
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore()));
sessionService = disposables.add(new CopilotCLISessionService(logService, sdk, instantiationService, new NullNativeEnvService(), fileSystem, mcpHandler, new NullCopilotCLIAgents(), workspaceService, customSessionTitleService, accessor.get(IConfigurationService), new MockSkillLocations(), delegationService, new MockChatSessionMetadataStore(), { _serviceBrand: undefined, isAgentSessionsWorkspace: false } as IAgentSessionsWorkspace, workspaceFolderService, worktree));
manager = await sessionService.getSessionManager() as unknown as MockCliSdkSessionManager;
contentProvider = new class extends mock<CopilotCLIChatSessionContentProvider>() {