Refactor chat session worktree handling and remove unused components (#312109)

* Refactor chat session worktree handling and remove unused components

Co-authored-by: Copilot <copilot@github.com>

* Updates

* Updates

* Updates

* Updats

Co-authored-by: Copilot <copilot@github.com>

* Updates

* Fixes

* Refactor session working directory management to use chat session metadata store

Co-authored-by: Copilot <copilot@github.com>

* Updates

Co-authored-by: Copilot <copilot@github.com>

---------

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Don Jayamanne
2026-04-24 00:31:06 +10:00
committed by GitHub
parent f36a976926
commit 677ee01878
16 changed files with 257 additions and 966 deletions
@@ -4,7 +4,6 @@
*--------------------------------------------------------------------------------------------*/
import type * as vscode from 'vscode';
import type { Uri } from 'vscode';
import { createServiceIdentifier } from '../../../util/common/services';
import { ChatSessionWorktreeProperties } from './chatSessionWorktreeService';
import type { IWorkspaceInfo } from './workspaceInfo';
@@ -118,9 +117,7 @@ export interface IChatSessionMetadataStore {
storeWorkspaceFolderInfo(sessionId: string, entry: WorkspaceFolderEntry): Promise<void>;
storeRepositoryProperties(sessionId: string, properties: RepositoryProperties): Promise<void>;
getRepositoryProperties(sessionId: string): Promise<RepositoryProperties | undefined>;
getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined>;
getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined>;
getWorktreeProperties(folder: Uri): Promise<ChatSessionWorktreeProperties | undefined>;
getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined>;
getSessionWorkspaceFolderEntry(sessionId: string): Promise<WorkspaceFolderEntry | undefined>;
getAdditionalWorkspaces(sessionId: string): Promise<IWorkspaceInfo[]>;
@@ -147,4 +144,13 @@ export interface IChatSessionMetadataStore {
* on demand. Concurrent calls collapse: at most one in-flight + one pending.
*/
refresh(): Promise<void>;
/**
* Returns session IDs whose working directory (worktree path or workspace folder)
* matches the given folder URI.
*/
getSessionIdsForFolder(folder: vscode.Uri): string[];
/**
* Returns session IDs that have a worktree whose path matches the given folder URI.
*/
getWorktreeSessions(folder: vscode.Uri): string[];
}
@@ -63,7 +63,6 @@ export interface IChatSessionWorktreeService {
createWorktree(repositoryPath: vscode.Uri, stream?: vscode.ChatResponseStream, baseBranch?: string, branchName?: string): Promise<ChatSessionWorktreeProperties | undefined>;
getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined>;
getWorktreeProperties(folder: vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined>;
setWorktreeProperties(sessionId: string, properties: string | ChatSessionWorktreeProperties): Promise<void>;
getWorktreeRepository(sessionId: string): Promise<RepoContext | undefined>;
@@ -71,8 +70,6 @@ export interface IChatSessionWorktreeService {
applyWorktreeChanges(sessionId: string): Promise<void>;
getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined>;
getWorktreeChanges(sessionId: string): Promise<readonly vscode.ChatSessionChangedFile[] | undefined>;
hasCachedChanges(sessionId: string): Promise<boolean>;
@@ -54,11 +54,8 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
return undefined;
}
async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> {
if (typeof sessionIdOrFolder === 'string') {
return this._worktreeProperties.get(sessionIdOrFolder);
}
return undefined;
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
return this._worktreeProperties.get(sessionId);
}
async getSessionWorkspaceFolder(_sessionId: string): Promise<vscode.Uri | undefined> {
@@ -155,4 +152,31 @@ export class MockChatSessionMetadataStore implements IChatSessionMetadataStore {
getSessionParentId(_sessionId: string): Promise<string | undefined> {
return Promise.resolve(undefined);
}
getSessionIdsForFolder(folder: vscode.Uri): string[] {
const folderPath = folder.fsPath;
const sessionIds: string[] = [];
for (const [sessionId, props] of this._worktreeProperties) {
if (props.worktreePath === folderPath) {
sessionIds.push(sessionId);
}
}
for (const [sessionId, entry] of this._workspaceFolders) {
if (entry.folderPath === folderPath && !sessionIds.includes(sessionId)) {
sessionIds.push(sessionId);
}
}
return sessionIds;
}
getWorktreeSessions(folder: vscode.Uri): string[] {
const folderPath = folder.fsPath;
const sessionIds: string[] = [];
for (const [sessionId, props] of this._worktreeProperties) {
if (props.worktreePath === folderPath) {
sessionIds.push(sessionId);
}
}
return sessionIds;
}
}
@@ -44,13 +44,3 @@ export function getCopilotCLIWorkspaceFile(sessionId: string) {
export function getCopilotBulkMetadataFile(): string {
return join(getCopilotHome(), 'vscode.session.metadata.cache.json');
}
/**
* Path of the shared worktree-sessions JSONL index. Append-only, one
* {@link WorktreeSessionEntry} per line.
* Used as a worktree folder → session-id fallback
* when an entry has been evicted from the bulk cache.
*/
export function getCopilotWorktreeSessionsFile(): string {
return join(getCopilotHome(), 'vscode.session.worktree.jsonl');
}
@@ -11,22 +11,19 @@ import { ILogService } from '../../../platform/log/common/logService';
import { findLast } from '../../../util/vs/base/common/arraysFind';
import { SequencerByKey, ThrottledDelayer } from '../../../util/vs/base/common/async';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { dirname, isEqual } from '../../../util/vs/base/common/resources';
import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry, WorktreeSessionEntry } from '../common/chatSessionMetadataStore';
import { dirname } from '../../../util/vs/base/common/resources';
import { ChatSessionMetadataFile, IChatSessionMetadataStore, RepositoryProperties, RequestDetails, WorkspaceFolderEntry } from '../common/chatSessionMetadataStore';
import { ChatSessionWorktreeProperties } from '../common/chatSessionWorktreeService';
import { isUntitledSessionId } from '../common/utils';
import { IWorkspaceInfo } from '../common/workspaceInfo';
import { getCopilotBulkMetadataFile, getCopilotCLISessionDir, getCopilotCLISessionStateDir, getCopilotWorktreeSessionsFile } from '../copilotcli/node/cliHelpers';
import { getCopilotBulkMetadataFile, getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers';
import { ICopilotCLIAgents } from '../copilotcli/node/copilotCli';
import { WorktreeSessionIndex } from './worktreeSessionIndex';
// const WORKSPACE_FOLDER_MEMENTO_KEY = 'github.copilot.cli.sessionWorkspaceFolders';
// const WORKTREE_MEMENTO_KEY = 'github.copilot.cli.sessionWorktrees';
const LEGACY_BULK_METADATA_FILENAME = 'copilotcli.session.metadata.json';
const LEGACY_BULK_MIGRATED_KEY = 'github.copilot.cli.legacyBulkMigrated';
const JSONL_SCAN_DONE_KEY = 'github.copilot.cli.events.jsonl.scaned';
const REQUEST_MAPPING_FILENAME = 'vscode.requests.metadata.json';
const SESSION_SCAN_BATCH_SIZE = 20;
/**
* Maximum number of sessions kept in the shared bulk metadata cache file
@@ -49,8 +46,10 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
*/
private _cache: Record<string, ChatSessionMetadataFile> = {};
/** Maps session idJSONL entry and folder path → session id. Owns JSONL file persistence. */
private readonly _worktreeSessions: WorktreeSessionIndex;
/** Session IDindexed path and kind, for reverse-lookup cleanup. */
private readonly _sessionFolderEntry = new Map<string, { path: string; kind: 'worktree' | 'folder' }>();
/** Folder path → set of session IDs (worktree path or workspace folder path). */
private readonly _folderToSessions = new Map<string, Set<string>>();
/** Path of the shared bulk metadata cache file in `~/.copilot/`. */
private readonly _cacheFile = Uri.file(getCopilotBulkMetadataFile());
@@ -77,12 +76,6 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
) {
super();
this._worktreeSessions = new WorktreeSessionIndex(
this.fileSystemService,
this.logService,
getCopilotWorktreeSessionsFile(),
);
this._ready = this.initializeStorage();
this._ready.catch(error => {
this.logService.error('[ChatSessionMetadataStore] Initialization failed: ', error);
@@ -97,6 +90,65 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
return this._ready;
}
public getSessionIdsForFolder(folder: vscode.Uri): string[] {
return Array.from(this._folderToSessions.get(folder.fsPath) ?? []);
}
public getWorktreeSessions(folder: vscode.Uri): string[] {
const sessions = this._folderToSessions.get(folder.fsPath);
if (!sessions) {
return [];
}
const result: string[] = [];
for (const sessionId of sessions) {
if (this._sessionFolderEntry.get(sessionId)?.kind === 'worktree') {
result.push(sessionId);
}
}
return result;
}
/**
* Maintains {@link _sessionFolderEntry} and {@link _folderToSessions} so
* that {@link getSessionIdsForFolder} and {@link getWorktreeSessions}
* are O(1) lookups instead of full-cache scans.
*/
private _updateFolderIndex(sessionId: string, metadata: ChatSessionMetadataFile | undefined): void {
// Remove old entry
const old = this._sessionFolderEntry.get(sessionId);
if (old) {
const set = this._folderToSessions.get(old.path);
if (set) {
set.delete(sessionId);
if (set.size === 0) {
this._folderToSessions.delete(old.path);
}
}
this._sessionFolderEntry.delete(sessionId);
}
if (!metadata) {
return;
}
// Prefer worktree path over workspace folder path
const worktreePath = metadata.worktreeProperties?.worktreePath;
const folderPath = metadata.workspaceFolder?.folderPath;
const path = worktreePath ?? folderPath;
if (!path) {
return;
}
const kind: 'worktree' | 'folder' = worktreePath ? 'worktree' : 'folder';
this._sessionFolderEntry.set(sessionId, { path, kind });
let set = this._folderToSessions.get(path);
if (!set) {
set = new Set();
this._folderToSessions.set(path, set);
}
set.add(sessionId);
}
private async initializeStorage(): Promise<void> {
// One-time migration from the legacy per-install bulk file in
// globalStorageUri to the shared `~/.copilot/` location.
@@ -115,14 +167,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
}
}
// Build folder index from the cleaned cache.
for (const [sessionId, metadata] of Object.entries(this._cache)) {
this._updateFolderIndex(sessionId, metadata);
}
// this.extensionContext.globalState.update(WORKTREE_MEMENTO_KEY, undefined);
// this.extensionContext.globalState.update(WORKSPACE_FOLDER_MEMENTO_KEY, undefined);
// Ensure every cached session with a worktreePath has a JSONL
// entry. Only appends entries that are missing; falls back to a full rewrite when
// the load detected duplicates or malformed lines.
await this.topUpJsonlIndexFromCache();
}
public getMetadataFileUri(sessionId: string): vscode.Uri {
@@ -137,13 +188,13 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
await this._ready;
if (sessionId in this._cache) {
delete this._cache[sessionId];
this._updateFolderIndex(sessionId, undefined);
const data = await this.getGlobalStorageData().catch(() => ({} as Record<string, ChatSessionMetadataFile>));
delete data[sessionId];
await this.writeToGlobalStorage(data);
}
try {
await Promise.allSettled([
this._worktreeSessions.removeAndWriteToDisk(sessionId),
this.fileSystemService.delete(this.getMetadataFileUri(sessionId)),
this.fileSystemService.delete(this.getRequestMappingFileUri(sessionId))
]);
@@ -163,6 +214,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
// cannot stomp fields written by other processes (Step 3b: stale-cache fix).
const existing = this._cache[sessionId] ?? {};
this._cache[sessionId] = { ...existing, ...fields };
this._updateFolderIndex(sessionId, this._cache[sessionId]);
await this.updateSessionMetadata(sessionId, fields);
this.updateGlobalStorage();
}
@@ -184,47 +236,10 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
return metadata?.repositoryProperties;
}
getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined>;
getWorktreeProperties(folder: Uri): Promise<ChatSessionWorktreeProperties | undefined>;
async getWorktreeProperties(sessionId: string | Uri): Promise<ChatSessionWorktreeProperties | undefined> {
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
await this._ready;
if (typeof sessionId === 'string') {
const metadata = await this.getSessionMetadata(sessionId);
return metadata?.worktreeProperties;
}
const folder = sessionId;
// First check the in-memory cache.
for (const metadata of Object.values(this._cache)) {
if (metadata.worktreeProperties?.worktreePath && isEqual(Uri.file(metadata.worktreeProperties.worktreePath), folder)) {
return metadata.worktreeProperties;
}
}
// Fallback to the JSONL worktree index → hydrate from the per-session file.
const id = await this.findSessionIdForWorktree(folder);
if (id) {
const metadata = await this.getSessionMetadata(id);
return metadata?.worktreeProperties;
}
return undefined;
}
async getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined> {
await this._ready;
for (const [sessionId, value] of Object.entries(this._cache)) {
if (value.worktreeProperties?.worktreePath && isEqual(vscode.Uri.file(value.worktreeProperties.worktreePath), folder)) {
return sessionId;
}
}
return this.findSessionIdForWorktree(folder);
}
/** Looks up a session id for a worktree folder via the JSONL index, with a throttled disk reload. */
private async findSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined> {
const cached = this._worktreeSessions.getSessionIdForFolder(folder);
if (cached) {
return cached;
}
await this._worktreeSessions.reloadIfStale();
return this._worktreeSessions.getSessionIdForFolder(folder);
const metadata = await this.getSessionMetadata(sessionId);
return metadata?.worktreeProperties;
}
async getSessionWorkspaceFolder(sessionId: string): Promise<vscode.Uri | undefined> {
@@ -397,6 +412,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
const metadata = await this.readSessionMetadataFile(sessionId);
if (metadata) {
this._cache[sessionId] = metadata;
this._updateFolderIndex(sessionId, metadata);
return metadata;
}
@@ -446,6 +462,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
if (!createDirectoryIfNotFound) {
// Lets not delete the session from our storage, but mark it as written to session state so that we won't try to write to session state again and again.
this._cache[sessionId] = { ...updates, writtenToDisc: true };
this._updateFolderIndex(sessionId, this._cache[sessionId]);
this.updateGlobalStorage();
return;
}
@@ -475,31 +492,11 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
merged.created = now;
}
const promises: Promise<unknown>[] = [];
// Maintain the JSONL worktree index based on the post-merge worktreePath:
// - new entry → append a line and remember it
// - changed path → rewrite the file (rare)
// - cleared path → remove via rewrite
const worktreePath = merged.worktreeProperties?.worktreePath;
const indexed = this._worktreeSessions.getSessionEntry(sessionId);
if (worktreePath) {
if (!indexed) {
promises.push(this._worktreeSessions.appendBatchToDisk([{ id: sessionId, path: worktreePath, created: merged.created }]));
} else if (indexed.path !== worktreePath && !merged.kind) {
this._worktreeSessions.addEntry({ id: sessionId, path: worktreePath, created: indexed.created });
promises.push(this._worktreeSessions.writeToDisk());
}
} else if (indexed) {
promises.push(this._worktreeSessions.removeAndWriteToDisk(sessionId));
}
const content = new TextEncoder().encode(JSON.stringify(merged, null, 2));
promises.push(this.fileSystemService.writeFile(fileUri, content));
await Promise.all(promises);
await this.fileSystemService.writeFile(fileUri, content);
this._cache[sessionId] = { ...merged, writtenToDisc: true };
this._updateFolderIndex(sessionId, this._cache[sessionId]);
this.updateGlobalStorage();
this.logService.trace(`[ChatSessionMetadataStore] Wrote metadata for session ${sessionId}`);
});
@@ -529,6 +526,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
if (!local) {
data[sessionId] = diskEntry;
this._cache[sessionId] = diskEntry;
this._updateFolderIndex(sessionId, diskEntry);
continue;
}
const localModified = local.modified ?? 0;
@@ -536,6 +534,7 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
if (diskModified > localModified) {
data[sessionId] = diskEntry;
this._cache[sessionId] = diskEntry;
this._updateFolderIndex(sessionId, diskEntry);
}
}
} catch {
@@ -591,12 +590,14 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
const local = this._cache[id];
if (!local) {
this._cache[id] = diskEntry;
this._updateFolderIndex(id, diskEntry);
continue;
}
const localModified = local.modified ?? 0;
const diskModified = diskEntry.modified ?? 0;
if (diskModified > localModified) {
this._cache[id] = diskEntry;
this._updateFolderIndex(id, diskEntry);
}
}
});
@@ -668,106 +669,4 @@ export class ChatSessionMetadataStore extends Disposable implements IChatSession
this.logService.error('[ChatSessionMetadataStore] Failed to migrate legacy bulk file: ', err);
}
}
/**
* For every cached session with a `worktreePath`, ensure a JSONL entry exists.
*/
private async topUpJsonlIndexFromCache(): Promise<void> {
// Load the JSONL worktree index from disk first so the scan below can
// tell which entries already exist and avoid re-appending duplicates.
let { rewriteNeeded } = await this._worktreeSessions.loadFromDisk();
const toAppend: WorktreeSessionEntry[] = [];
for (const [id, metadata] of Object.entries(this._cache)) {
const path = metadata.worktreeProperties?.worktreePath;
if (!path || metadata.kind) {
continue;
}
const existing = this._worktreeSessions.getSessionEntry(id);
if (existing && existing.path === path) {
continue;
}
const entry: WorktreeSessionEntry = { id, path, created: existing?.created ?? metadata.created ?? Date.now() };
this._worktreeSessions.addEntry(entry);
if (existing) {
// Path changed — a full rewrite is needed.
rewriteNeeded = true;
} else {
toAppend.push(entry);
}
}
if (rewriteNeeded) {
await this._worktreeSessions.writeToDisk();
} else if (toAppend.length > 0) {
await this._worktreeSessions.appendBatchToDisk(toAppend);
}
// One-time full scan of ~/.copilot/session-state/ to discover worktree
// sessions that were never recorded in the JSONL (e.g. sessions created
// before the JSONL index existed, or evicted from the bulk cache).
await this.scanSessionStateDirForWorktrees();
}
/**
* One-time scan of `~/.copilot/session-state/` to discover worktree sessions
* not yet in the JSONL index. Reads per-session metadata files in batches of
* {@link SESSION_SCAN_BATCH_SIZE} to avoid saturating I/O. Gated by a memento
* so it only runs once per install.
*/
private async scanSessionStateDirForWorktrees(): Promise<void> {
if (this.extensionContext.globalState.get<boolean>(JSONL_SCAN_DONE_KEY)) {
return;
}
const sessionStateDir = Uri.file(getCopilotCLISessionStateDir());
let entries: [string, number][];
try {
entries = await this.fileSystemService.readDirectory(sessionStateDir);
} catch {
// Directory doesn't exist — nothing to scan.
await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true);
return;
}
// Collect session IDs we don't already know about.
const unknownIds: string[] = [];
for (const [name] of entries) {
if (name in this._cache || this._worktreeSessions.has(name)) {
continue;
}
unknownIds.push(name);
}
if (unknownIds.length === 0) {
await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true);
return;
}
// Read metadata files in batches.
let discovered = false;
for (let i = 0; i < unknownIds.length; i += SESSION_SCAN_BATCH_SIZE) {
const batch = unknownIds.slice(i, i + SESSION_SCAN_BATCH_SIZE);
const results = await Promise.all(batch.map(async id => {
const metadata = await this.readSessionMetadataFile(id);
return { id, metadata };
}));
for (const { id, metadata } of results) {
if (!metadata?.worktreeProperties?.worktreePath || metadata.kind) {
continue;
}
const path = metadata.worktreeProperties.worktreePath;
if (!this._worktreeSessions.has(id)) {
this._worktreeSessions.addEntry({ id, path, created: metadata.created ?? Date.now() });
discovered = true;
}
}
}
if (discovered) {
await this._worktreeSessions.writeToDisk();
}
await this.extensionContext.globalState.update(JSONL_SCAN_DONE_KEY, true);
this.logService.info(`[ChatSessionMetadataStore] Session-state scan complete: checked ${unknownIds.length} unknown session(s)`);
}
}
@@ -10,6 +10,7 @@ import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspa
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions';
import { IGitService } from '../../../platform/git/common/gitService';
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
export class ChatSessionRepositoryTracker extends Disposable {
private readonly repositories = new DisposableResourceMap();
@@ -19,7 +20,8 @@ export class ChatSessionRepositoryTracker extends Disposable {
@IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService,
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IGitService private readonly gitService: IGitService,
@ILogService private readonly logService: ILogService
@ILogService private readonly logService: ILogService,
@IChatSessionMetadataStore private readonly metadataStore: IChatSessionMetadataStore
) {
super();
@@ -69,33 +71,21 @@ export class ChatSessionRepositoryTracker extends Disposable {
private async onDidChangeRepositoryState(uri: vscode.Uri): Promise<void> {
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Repository state changed for ${uri.toString()}. Updating session properties.`);
const worktreeSessionId = await this.worktreeService.getSessionIdForWorktree(uri);
const sessionIds = await this.metadataStore.getSessionIdsForFolder(uri);
const workspaceSessionIds = this.workspaceFolderService.clearWorkspaceChanges(uri);
if (worktreeSessionId) {
sessionIds.push(...workspaceSessionIds);
await Promise.all(Array.from(new Set(sessionIds)).map(async sessionId => {
// Worktree
const worktreeProperties = await this.worktreeService.getWorktreeProperties(worktreeSessionId);
if (!worktreeProperties) {
return;
const worktreeProperties = await this.worktreeService.getWorktreeProperties(sessionId);
if (worktreeProperties) {
await this.worktreeService.setWorktreeProperties(sessionId, {
...worktreeProperties,
changes: undefined
});
}
await this.worktreeService.setWorktreeProperties(worktreeSessionId, {
...worktreeProperties,
changes: undefined
});
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: worktreeSessionId });
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`);
} else if (workspaceSessionIds.length > 0) {
// Workspace
// This is still using the old ChatSessionItem API so there is no need to refresh each session
// associated with the workspace folder. When the new controller API is fully adopted we will
// have to refresh each session.
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds: workspaceSessionIds });
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for workspace ${uri.toString()}.`);
} else {
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] No session associated with workspace ${uri.toString()}.`);
}
}));
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds });
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangeRepositoryState] Updated session properties for worktree ${uri.toString()}.`);
}
private disposeRepositoryWatcher(uri: vscode.Uri): void {
@@ -18,7 +18,6 @@ import { ILogService } from '../../../platform/log/common/logService';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import * as path from '../../../util/vs/base/common/path';
import { isEqual } from '../../../util/vs/base/common/resources';
import { generateUuid } from '../../../util/vs/base/common/uuid';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
@@ -178,28 +177,13 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
return branch;
}
getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined>;
getWorktreeProperties(folder: vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined>;
async getWorktreeProperties(sessionIdOrFolder: string | vscode.Uri): Promise<ChatSessionWorktreeProperties | undefined> {
if (typeof sessionIdOrFolder === 'string') {
const properties = this._sessionWorktrees.get(sessionIdOrFolder);
if (properties !== undefined) {
return typeof properties === 'string' ? undefined : properties;
}
// Fall back to metadata store (file-based)
return this.metadataStore.getWorktreeProperties(sessionIdOrFolder);
} else {
for (const [_, value] of this._sessionWorktrees.entries()) {
if (typeof value === 'string') {
continue;
}
if (isEqual(vscode.Uri.file(value.worktreePath), sessionIdOrFolder)) {
return value;
}
}
// Fall back to metadata store (file-based)
return this.metadataStore.getWorktreeProperties(sessionIdOrFolder);
async getWorktreeProperties(sessionId: string): Promise<ChatSessionWorktreeProperties | undefined> {
const properties = this._sessionWorktrees.get(sessionId);
if (properties !== undefined) {
return typeof properties === 'string' ? undefined : properties;
}
// Fall back to metadata store (file-based)
return this.metadataStore.getWorktreeProperties(sessionId);
}
async setWorktreeProperties(sessionId: string, properties: ChatSessionWorktreeProperties): Promise<void> {
@@ -397,18 +381,6 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi
}
}
async getSessionIdForWorktree(folder: vscode.Uri): Promise<string | undefined> {
for (const [sessionId, value] of this._sessionWorktrees.entries()) {
if (typeof value === 'string') {
continue;
}
if (isEqual(vscode.Uri.file(value.worktreePath), folder)) {
return sessionId;
}
}
return this.metadataStore.getSessionIdForWorktree(folder);
}
async handleRequestCompleted(sessionId: string): Promise<void> {
const worktreeProperties = await this.getWorktreeProperties(sessionId);
if (!worktreeProperties) {
@@ -131,7 +131,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
private readonly controller: vscode.ChatSessionItemController;
private readonly newSessions = new ResourceMap<vscode.ChatSessionItem>();
constructor(
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
@IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService,
@@ -308,6 +307,10 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
this._register(this._workspaceService.onDidChangeWorkspaceFolders(refreshActiveInputState));
}
public getAssociatedSessions(folder: Uri): string[] {
return this._metadataStore.getSessionIdsForFolder(folder);
}
public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise<void> {
await this._optionGroupBuilder.rebuildInputState(inputState, folderUri);
}
@@ -341,7 +344,6 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
let worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token);
const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath)
: session.workingDirectory;
if (token.isCancellationRequested) {
return item;
@@ -352,7 +354,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
// `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
if (options?.includeChanges || ((await this.canBuildChangesFast(session.id, worktreeProperties)))) {
if (options?.includeChanges || ((await this.hasCachedChanges(session.id, worktreeProperties)))) {
const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
if (token.isCancellationRequested) {
return item;
@@ -407,19 +409,15 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
return badge;
}
private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
return true;
}
if (!worktreeProperties?.repositoryPath) {
return false;
}
const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)),
const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
this.copilotCLIWorktreeManagerService.hasCachedChanges(sessionId),
this._workspaceFolderService.hasCachedChanges(sessionId)
]);
return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges);
return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;
}
private async buildChanges(
@@ -1700,21 +1698,19 @@ export function registerCLIChatCommands(
logService.trace('[commitToWorktree] Commit successful');
// Clear the worktree changes cache so getWorktreeChanges() recomputes
const sessionId = await copilotCLIWorktreeManagerService.getSessionIdForWorktree(worktreeUri);
if (sessionId) {
const sessionIds = await contentProvider.getAssociatedSessions(worktreeUri);
await Promise.all(sessionIds.map(async sessionId => {
const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
if (props) {
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined });
} else {
logService.error('[commitToWorktree] No worktree properties found for session:', sessionId);
}
} else {
logService.error('[commitToWorktree] No session found for worktree:', worktreeUri.toString());
}
}));
logService.trace('[commitToWorktree] Notifying sessions change');
if (sessionId) {
await contentProvider.refreshSession({ reason: 'update', sessionId });
if (sessionIds.length) {
await contentProvider.refreshSession({ reason: 'update', sessionIds });
}
} catch (error) {
const { stdout = '', stderr = '', gitErrorCode } = error as { stdout?: string; stderr?: string; gitErrorCode?: string };
@@ -171,7 +171,6 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event;
public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise<vscode.ChatSessionItem | undefined>;
constructor(
@@ -217,6 +216,10 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
}));
}
public getAssociatedSessions(folder: Uri): string[] {
return this.chatSessionMetadataStore.getSessionIdsForFolder(folder);
}
/**
* We should remove this or move this to CopilotCLISessionService
*/
@@ -297,7 +300,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
// eager pass and let `resolveChatSessionItem` fill it in lazily for visible items.
// But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass.
let changes: vscode.ChatSessionChangedFile[] | undefined;
if (!token.isCancellationRequested && (options?.includeChanges || (await this.canBuildChangesFast(session.id, worktreeProperties)))) {
if (!token.isCancellationRequested && (options?.includeChanges || (await this.hasCachedChanges(session.id, worktreeProperties)))) {
changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token);
// We need to get an updated version of worktree properties here because when the
@@ -406,19 +409,15 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
} satisfies vscode.ChatSessionItem;
}
private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
private async hasCachedChanges(sessionId: string, worktreeProperties: Awaited<ReturnType<IChatSessionWorktreeService['getWorktreeProperties']>>): Promise<boolean> {
if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) {
return true;
}
if (!worktreeProperties?.repositoryPath) {
return false;
}
const [trusted, hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)),
const [hasCachedWorktreeChanges, hasCachedWorkspaceChanges] = await Promise.all([
this.worktreeManager.hasCachedChanges(sessionId),
this.workspaceFolderService.hasCachedChanges(sessionId)
]);
return trusted && (hasCachedWorktreeChanges || hasCachedWorkspaceChanges);
return hasCachedWorktreeChanges || hasCachedWorkspaceChanges;
}
@@ -2727,17 +2726,15 @@ export function registerCLIChatCommands(
logService.trace('[commitToWorktree] Commit successful');
// Clear the worktree changes cache so getWorktreeChanges() recomputes
const sessionId = await copilotCLIWorktreeManagerService.getSessionIdForWorktree(worktreeUri);
if (sessionId) {
const sessionIds = await copilotcliSessionItemProvider.getAssociatedSessions(worktreeUri);
await Promise.all(sessionIds.map(async sessionId => {
const props = await copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId);
if (props) {
await copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { ...props, changes: undefined });
} else {
logService.error('[commitToWorktree] No worktree properties found for session:', sessionId);
}
} else {
logService.error('[commitToWorktree] No session found for worktree:', worktreeUri.toString());
}
}));
logService.trace('[commitToWorktree] Notifying sessions change');
copilotcliSessionItemProvider.notifySessionsChange();
@@ -16,7 +16,7 @@ import { ResourceSet } from '../../../util/vs/base/common/map';
import { isEqual } from '../../../util/vs/base/common/resources';
import { createTimeout } from '../../inlineEdits/common/common';
import { IToolsService } from '../../tools/common/toolsService';
import { RepositoryProperties } from '../common/chatSessionMetadataStore';
import { RepositoryProperties, IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import {
@@ -67,6 +67,7 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol
protected readonly workspaceService: IWorkspaceService,
protected readonly logService: ILogService,
protected readonly toolsService: IToolsService,
protected readonly metadataStore: IChatSessionMetadataStore
) {
super();
@@ -211,7 +212,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol
// If we're in a single folder workspace, possible the user has opened the worktree folder directly.
if (sessionId && folderUri) {
worktreeProperties = await this.worktreeService.getWorktreeProperties(folderUri);
const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri);
worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined;
worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined;
repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri;
}
@@ -239,7 +241,8 @@ export abstract class FolderRepositoryManager extends Disposable implements IFol
// If we're in a single folder workspace, possible the user has opened the worktree folder directly.
if (sessionId && folderUri) {
worktreeProperties = await this.worktreeService.getWorktreeProperties(folderUri);
const worktreeSessionIds = this.metadataStore.getWorktreeSessions(folderUri);
worktreeProperties = worktreeSessionIds.length ? await this.worktreeService.getWorktreeProperties(worktreeSessionIds[0]) : undefined;
worktree = worktreeProperties ? vscode.Uri.file(worktreeProperties.worktreePath) : undefined;
repositoryUri = worktreeProperties ? vscode.Uri.file(worktreeProperties.repositoryPath) : repositoryUri;
}
@@ -855,9 +858,10 @@ export class CopilotCLIFolderRepositoryManager extends FolderRepositoryManager {
@IWorkspaceService workspaceService: IWorkspaceService,
@ILogService logService: ILogService,
@IToolsService toolsService: IToolsService,
@IFileSystemService private readonly fileSystem: IFileSystemService
@IFileSystemService private readonly fileSystem: IFileSystemService,
@IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore
) {
super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService);
super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore);
}
/**
@@ -898,9 +902,10 @@ export class ClaudeFolderRepositoryManager extends FolderRepositoryManager {
@ILogService logService: ILogService,
@IToolsService toolsService: IToolsService,
@IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService,
@IFileSystemService private readonly fileSystem: IFileSystemService
@IFileSystemService private readonly fileSystem: IFileSystemService,
@IChatSessionMetadataStore metadataStore: IChatSessionMetadataStore
) {
super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService);
super(worktreeService, workspaceFolderService, gitService, workspaceService, logService, toolsService, metadataStore);
}
/**
@@ -372,95 +372,8 @@ describe('ChatSessionMetadataStore', () => {
store.dispose();
});
it('should return worktree properties when looked up by folder Uri', async () => {
const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/my-wt').fsPath });
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-wt': { worktreeProperties: props },
}));
const store = await createStore();
const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/my-wt'));
expect(wt).toBeDefined();
expect(wt!.branchName).toBe(props.branchName);
store.dispose();
});
it('should return undefined when folder Uri does not match any worktree', async () => {
const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt-a').fsPath });
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-wt': { worktreeProperties: props },
}));
const store = await createStore();
const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/wt-b'));
expect(wt).toBeUndefined();
store.dispose();
});
it('should skip entries without worktreePath when looking up by folder Uri', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-folder': { workspaceFolder: { folderPath: Uri.file('/workspace/a').fsPath, timestamp: 100 } },
'session-wt': { worktreeProperties: makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt').fsPath }) },
}));
const store = await createStore();
const wt = await store.getWorktreeProperties(Uri.file('/repo/.worktrees/wt'));
expect(wt).toBeDefined();
store.dispose();
});
});
// ──────────────────────────────────────────────────────────────────────────
// getSessionIdForWorktree
// ──────────────────────────────────────────────────────────────────────────
describe('getSessionIdForWorktree', () => {
it('should return session id when worktree folder matches', async () => {
const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/my-wt').fsPath });
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-abc': { worktreeProperties: props },
}));
const store = await createStore();
const sessionId = await store.getSessionIdForWorktree(Uri.file('/repo/.worktrees/my-wt'));
expect(sessionId).toBe('session-abc');
store.dispose();
});
it('should return undefined when no worktree matches the folder', async () => {
const props = makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt-a').fsPath });
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-abc': { worktreeProperties: props },
}));
const store = await createStore();
const sessionId = await store.getSessionIdForWorktree(Uri.file('/some/other/path'));
expect(sessionId).toBeUndefined();
store.dispose();
});
it('should return undefined when cache has no worktree entries', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-folder': { workspaceFolder: { folderPath: Uri.file('/a').fsPath, timestamp: 1 } },
}));
const store = await createStore();
const sessionId = await store.getSessionIdForWorktree(Uri.file('/a'));
expect(sessionId).toBeUndefined();
store.dispose();
});
it('should find correct session among multiple worktree entries', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'session-1': { worktreeProperties: makeWorktreeV1Props({ worktreePath: Uri.file('/repo/.worktrees/wt1').fsPath }) },
'session-2': { worktreeProperties: makeWorktreeV2Props({ worktreePath: Uri.file('/repo/.worktrees/wt2').fsPath }) },
}));
const store = await createStore();
const sessionId = await store.getSessionIdForWorktree(Uri.file('/repo/.worktrees/wt2'));
expect(sessionId).toBe('session-2');
store.dispose();
});
});
// ──────────────────────────────────────────────────────────────────────────
// getSessionWorkspaceFolder
@@ -1548,83 +1461,6 @@ describe('ChatSessionMetadataStore', () => {
});
});
describe('JSONL worktree index', () => {
const jsonlUri = () => Uri.file(jsonlPathHolder.get());
async function readJsonl(): Promise<Array<Record<string, unknown>>> {
try {
const bytes = await mockFs.readFile(jsonlUri());
const raw = new TextDecoder().decode(bytes);
return raw.split('\n').filter(Boolean).map(l => JSON.parse(l));
} catch {
return [];
}
}
it('appends one line per worktree session and reads it back via getSessionIdForWorktree', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
const store = await createStore();
const folder = Uri.file('/repo/.worktrees/wt-A');
await store.storeWorktreeInfo('wt-session-A', makeWorktreeV1Props({ worktreePath: folder.fsPath }));
const lines = await readJsonl();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({ id: 'wt-session-A', path: folder.fsPath });
// Lookup by folder works via the in-memory map.
expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-A');
store.dispose();
});
it('falls back to JSONL on disk for getSessionIdForWorktree when in-memory cache is cold', async () => {
// Pre-seed JSONL in mock fs before the store starts — simulates an entry written by another process.
const folder = Uri.file('/repo/.worktrees/wt-cold');
mockFs.mockFile(
jsonlUri(),
JSON.stringify({ id: 'wt-session-cold', path: folder.fsPath, created: 100 }) + '\n',
);
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
const store = await createStore();
expect(await store.getSessionIdForWorktree(folder)).toBe('wt-session-cold');
store.dispose();
});
it('compacts duplicate JSONL lines for the same id on next rewrite', async () => {
// Two entries for the same id — last write wins, file should be rewritten.
const folder = Uri.file('/repo/.worktrees/dup');
mockFs.mockFile(
jsonlUri(),
JSON.stringify({ id: 'dup-id', path: '/old/path', created: 1 }) + '\n' +
JSON.stringify({ id: 'dup-id', path: folder.fsPath, created: 2 }) + '\n',
);
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
const store = await createStore();
// Initialization should have detected the duplicate and rewritten the file.
const lines = await readJsonl();
expect(lines).toHaveLength(1);
expect(lines[0]).toMatchObject({ id: 'dup-id', path: folder.fsPath });
expect(await store.getSessionIdForWorktree(folder)).toBe('dup-id');
store.dispose();
});
it('removes the JSONL entry when a session is deleted', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
const store = await createStore();
const folder = Uri.file('/repo/.worktrees/to-delete');
await store.storeWorktreeInfo('to-delete', makeWorktreeV1Props({ worktreePath: folder.fsPath }));
expect(await readJsonl()).toHaveLength(1);
await store.deleteSessionMetadata('to-delete');
expect(await readJsonl()).toHaveLength(0);
expect(await store.getSessionIdForWorktree(folder)).toBeUndefined();
store.dispose();
});
});
describe('top-N trim (MAX_BULK_STORAGE_ENTRIES = 1000)', () => {
it('writes at most 1000 entries to the bulk file but keeps everything in memory', async () => {
// Pre-seed a bulk file with 1100 entries with varying `modified` timestamps.
@@ -1711,69 +1547,4 @@ describe('ChatSessionMetadataStore', () => {
store.dispose();
});
});
describe('session-state directory scan', () => {
const sessionStateDir = Uri.file('/mock/session-state');
it('discovers worktree sessions from per-session files not in cache or JSONL', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
// Simulate a per-session file on disk that is NOT in the bulk cache.
const folder = Uri.file('/repo/.worktrees/discovered');
await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'orphan-session'));
mockFs.mockFile(
sessionMetadataFileUri('orphan-session'),
JSON.stringify({ worktreeProperties: makeWorktreeV1Props({ worktreePath: folder.fsPath }) }),
);
// readDirectory returns the session dir entries.
mockFs.mockDirectory(sessionStateDir, [['orphan-session', 2 /* Directory */]]);
const store = await createStore();
expect(await store.getSessionIdForWorktree(folder)).toBe('orphan-session');
store.dispose();
});
it('skips session IDs already known from the bulk cache', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({
'known-session': { workspaceFolder: { folderPath: Uri.file('/known').fsPath, timestamp: 1 } },
}));
mockFs.mockDirectory(sessionStateDir, [['known-session', 2]]);
const readSpy = vi.spyOn(mockFs, 'readFile');
const store = await createStore();
// Per-session file for known-session should NOT have been read by the scan.
const scanReads = readSpy.mock.calls.filter(
c => c[0].toString().includes('/mock/session-state/known-session/vscode.metadata.json'),
);
expect(scanReads).toHaveLength(0);
store.dispose();
});
it('sets memento flag so the scan does not re-run on next startup', async () => {
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
mockFs.mockDirectory(sessionStateDir, []);
await createStore();
expect(extensionContext.globalState.get('github.copilot.cli.events.jsonl.scaned')).toBe(true);
});
it('skips scan when memento flag is already set', async () => {
extensionContext.globalState.seed('github.copilot.cli.events.jsonl.scaned', true);
mockFs.mockFile(BULK_METADATA_FILE, JSON.stringify({}));
// Even with a discoverable session, scan should be skipped.
await mockFs.createDirectory(Uri.joinPath(sessionStateDir, 'should-skip'));
mockFs.mockFile(
sessionMetadataFileUri('should-skip'),
JSON.stringify({ worktreeProperties: makeWorktreeV1Props() }),
);
mockFs.mockDirectory(sessionStateDir, [['should-skip', 2]]);
const store = await createStore();
expect(await store.getSessionIdForWorktree(Uri.file(makeWorktreeV1Props().worktreePath))).toBeUndefined();
store.dispose();
});
});
});
@@ -414,7 +414,8 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
workspaceService,
logService,
tools,
fileSystem
fileSystem,
new MockChatSessionMetadataStore()
);
instantiationService = accessor.get(IInstantiationService);
@@ -22,6 +22,7 @@ import { ChatSessionWorktreeFile, ChatSessionWorktreeProperties, IChatSessionWor
import { IFolderRepositoryManager } from '../../common/folderRepositoryManager';
import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';
import { MockChatSessionMetadataStore } from '../../common/test/mockChatSessionMetadataStore';
import type { IClaudeSessionStateService } from '../../claude/common/claudeSessionStateService';
import type { ClaudeFolderInfo } from '../../claude/common/claudeFolderInfo';
@@ -317,7 +318,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
workspaceService,
logService,
toolsService,
fileSystem
fileSystem,
new MockChatSessionMetadataStore()
);
});
@@ -551,7 +553,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
workspaceService,
logService,
toolsService,
new MockFileSystemService()
new MockFileSystemService(),
new MockChatSessionMetadataStore()
);
manager.setNewSessionFolder(sessionId, folderUri);
@@ -713,7 +716,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
workspaceService,
logService,
toolsService,
new MockFileSystemService()
new MockFileSystemService(),
new MockChatSessionMetadataStore()
);
const token = disposables.add(new CancellationTokenSource()).token;
const stream = new MockChatResponseStream();
@@ -733,8 +737,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
describe('worktree folder opened as workspace folder', () => {
const mockToolInvocationToken = {} as vscode.ChatParticipantToolToken;
const worktreeFolderPath = '/repo-worktree';
const originalRepoPath = '/original-repo';
const worktreeFolderPath = vscode.Uri.file('/repo-worktree').fsPath;
const originalRepoPath = vscode.Uri.file('/original-repo').fsPath;
const defaultWorktreeProps: ChatSessionWorktreeProperties = {
autoCommit: true,
baseCommit: 'abc123',
@@ -745,6 +749,15 @@ describe('CopilotCLIFolderRepositoryManager', () => {
};
describe('initializeFolderRepository', () => {
function createMetadataStoreWithWorktree(): MockChatSessionMetadataStore {
const store = new MockChatSessionMetadataStore();
// Register a session whose worktree path matches worktreeFolderPath so that
// getWorktreeSessions(folderUri) returns a session ID that the worktreeService
// can resolve via getWorktreeProperties.
void store.storeWorktreeInfo(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);
return store;
}
it('skips worktree creation when single workspace folder is already a tracked worktree', async () => {
workspaceService = new MockWorkspaceService([URI.file(worktreeFolderPath)]);
gitService.setTestActiveRepository({
@@ -755,7 +768,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService()
new MockFileSystemService(),
createMetadataStoreWithWorktree()
);
const sessionId = 'untitled:wt-test-1';
@@ -773,6 +787,12 @@ describe('CopilotCLIFolderRepositoryManager', () => {
it('skips worktree creation when explicitly selected folder is a tracked worktree', async () => {
worktreeService.setTestWorktreeProperties(vscode.Uri.file(worktreeFolderPath).fsPath, defaultWorktreeProps);
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService(),
createMetadataStoreWithWorktree()
);
const sessionId = 'untitled:wt-test-2';
const token = disposables.add(new CancellationTokenSource()).token;
@@ -801,7 +821,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService()
new MockFileSystemService(),
createMetadataStoreWithWorktree()
);
const sessionId = 'untitled:wt-test-3';
@@ -822,6 +843,12 @@ describe('CopilotCLIFolderRepositoryManager', () => {
remotes: [] as string[],
changes: { indexChanges: [{ path: 'file.ts' }], workingTree: [{ path: 'other.ts' }], mergeChanges: [], untrackedChanges: [] }
} as unknown as RepoContext);
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService(),
createMetadataStoreWithWorktree()
);
const sessionId = 'untitled:wt-test-4';
const token = disposables.add(new CancellationTokenSource()).token;
@@ -844,6 +871,12 @@ describe('CopilotCLIFolderRepositoryManager', () => {
kind: 'repository',
remotes: [] as string[],
} as RepoContext);
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService(),
createMetadataStoreWithWorktree()
);
const sessionId = 'untitled:wt-test-5';
const token = disposables.add(new CancellationTokenSource()).token;
@@ -869,7 +902,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService()
new MockFileSystemService(),
createMetadataStoreWithWorktree()
);
const sessionId = 'untitled:wt-test-6';
@@ -895,7 +929,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
manager = new CopilotCLIFolderRepositoryManager(
worktreeService, workspaceFolderService, sessionService,
gitService, workspaceService, logService, toolsService,
new MockFileSystemService()
new MockFileSystemService(),
new MockChatSessionMetadataStore()
);
(worktreeService.createWorktree as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
@@ -1030,7 +1065,8 @@ describe('CopilotCLIFolderRepositoryManager', () => {
workspaceService,
logService,
toolsService,
new MockFileSystemService()
new MockFileSystemService(),
new MockChatSessionMetadataStore()
);
const sessionId = 'untitled:empty-test';
@@ -1099,7 +1135,8 @@ describe('ClaudeFolderRepositoryManager', () => {
logService,
toolsService,
sessionStateService,
fileSystem
fileSystem,
new MockChatSessionMetadataStore()
);
});
@@ -1,190 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Uri } from 'vscode';
import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService';
import { ILogService } from '../../../../platform/log/common/logService';
import { mock } from '../../../../util/common/test/simpleMock';
import { WorktreeSessionIndex } from '../worktreeSessionIndex';
class MockLogService extends mock<ILogService>() {
override trace = vi.fn();
override info = vi.fn();
override warn = vi.fn();
override error = vi.fn();
override debug = vi.fn();
}
const JSONL_PATH = '/mock/copilot-home/worktree.jsonl';
const JSONL_URI = Uri.file(JSONL_PATH);
describe('WorktreeSessionIndex', () => {
let mockFs: MockFileSystemService;
let logService: MockLogService;
function createIndex(): WorktreeSessionIndex {
return new WorktreeSessionIndex(mockFs, logService, JSONL_PATH);
}
afterEach(() => {
vi.restoreAllMocks();
});
// In-memory tests don't need mockFs/logService, but the constructor requires them.
describe('in-memory operations', () => {
it('adds and retrieves an entry by session id', () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
expect(index.getSessionEntry('s1')).toMatchObject({ id: 's1', path: '/a' });
expect(index.has('s1')).toBe(true);
expect(index.has('s2')).toBe(false);
});
it('looks up session id by folder Uri', () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
expect(index.getSessionIdForFolder(Uri.file('/a'))).toBe('s1');
expect(index.getSessionIdForFolder(Uri.file('/b'))).toBeUndefined();
});
it('deletes an entry and cleans up the folder mapping', () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
index.deleteEntry('s1');
expect(index.has('s1')).toBe(false);
expect(index.getSessionIdForFolder(Uri.file('/a'))).toBeUndefined();
});
it('clear() removes everything', () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
index.addEntry({ id: 's2', path: '/b', created: 2 });
index.clear();
expect(index.has('s1')).toBe(false);
expect(index.getEntries()).toHaveLength(0);
});
it('getEntries() returns all entries', () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
index.addEntry({ id: 's2', path: '/b', created: 2 });
const entries = index.getEntries();
expect(entries).toHaveLength(2);
expect(entries.map(e => e.id).sort()).toEqual(['s1', 's2']);
});
it('updating an entry with a new path removes the old path mapping', () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/old', created: 1 });
expect(index.getSessionIdForFolder(Uri.file('/old'))).toBe('s1');
index.addEntry({ id: 's1', path: '/new', created: 1 });
expect(index.getSessionIdForFolder(Uri.file('/new'))).toBe('s1');
expect(index.getSessionIdForFolder(Uri.file('/old'))).toBeUndefined();
});
});
describe('JSONL persistence', () => {
it('loadFromDisk populates the index from a JSONL file', async () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
mockFs.mockFile(JSONL_URI,
JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' +
JSON.stringify({ id: 's2', path: '/b', created: 2 }) + '\n',
);
const index = createIndex();
await index.loadFromDisk();
expect(index.has('s1')).toBe(true);
expect(index.has('s2')).toBe(true);
expect(index.size).toBe(2);
});
it('loadFromDisk returns rewriteNeeded for duplicates', async () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
mockFs.mockFile(JSONL_URI,
JSON.stringify({ id: 's1', path: '/a', created: 1 }) + '\n' +
JSON.stringify({ id: 's1', path: '/b', created: 2 }) + '\n',
);
const index = createIndex();
const { rewriteNeeded } = await index.loadFromDisk();
expect(rewriteNeeded).toBe(true);
expect(index.size).toBe(1);
});
it('writeToDisk writes all entries to the JSONL file', async () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
index.addEntry({ id: 's2', path: '/b', created: 2 });
await index.writeToDisk();
const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
const lines = raw.split('\n').filter(Boolean);
expect(lines).toHaveLength(2);
});
it('appendBatchToDisk adds a single entry', async () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
await index.appendBatchToDisk([{ id: 's1', path: '/a', created: 1 }]);
expect(index.has('s1')).toBe(true);
const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
expect(raw.split('\n').filter(Boolean)).toHaveLength(1);
});
it('appendBatchToDisk adds multiple entries in one write', async () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
await index.appendBatchToDisk([
{ id: 's1', path: '/a', created: 1 },
{ id: 's2', path: '/b', created: 2 },
]);
expect(index.has('s1')).toBe(true);
expect(index.has('s2')).toBe(true);
const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
expect(raw.split('\n').filter(Boolean)).toHaveLength(2);
});
it('removeAndWriteToDisk removes the entry and rewrites', async () => {
mockFs = new MockFileSystemService();
logService = new MockLogService();
const index = createIndex();
index.addEntry({ id: 's1', path: '/a', created: 1 });
index.addEntry({ id: 's2', path: '/b', created: 2 });
await index.removeAndWriteToDisk('s1');
expect(index.has('s1')).toBe(false);
const raw = new TextDecoder().decode(await mockFs.readFile(JSONL_URI));
expect(raw.split('\n').filter(Boolean)).toHaveLength(1);
expect(raw).toContain('s2');
});
});
});
@@ -1,221 +0,0 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { Uri } from 'vscode';
import { createDirectoryIfNotExists, IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
import { ILogService } from '../../../platform/log/common/logService';
import { Sequencer } from '../../../util/vs/base/common/async';
import { ResourceMap } from '../../../util/vs/base/common/map';
import { dirname } from '../../../util/vs/base/common/resources';
import { WorktreeSessionEntry } from '../common/chatSessionMetadataStore';
/**
* In-memory index that maps session ids to {@link WorktreeSessionEntry} and
* worktree folder URIs to session ids, with JSONL file persistence.
*
* When multiple sessions share the same folder, the first-registered session
* keeps the folder session-id mapping.
*
* All file writes are serialized through an internal {@link Sequencer} so
* concurrent appends and rewrites cannot race.
*/
export class WorktreeSessionIndex {
/** Session id → entry. */
private readonly _byId = new Map<string, WorktreeSessionEntry>();
/** Worktree folder URI → session id. Uses URI-aware comparison so path casing is handled correctly. */
private readonly _byFolder = new ResourceMap<string>();
/** Serializes all JSONL file writes to prevent read-modify-write races. */
private readonly _writeSequencer = new Sequencer();
/** Timestamp of the last {@link loadFromDisk} call; used by {@link reloadIfStale}. */
private _lastLoadAt = 0;
constructor(
private readonly _fileSystemService: IFileSystemService,
private readonly _logService: ILogService,
private readonly _jsonlPath: string,
) { }
getSessionEntry(sessionId: string): WorktreeSessionEntry | undefined {
return this._byId.get(sessionId);
}
getSessionIdForFolder(folder: Uri): string | undefined {
return this._byFolder.get(folder);
}
has(sessionId: string): boolean {
return this._byId.has(sessionId);
}
get size(): number {
return this._byId.size;
}
getAllSessionIds(): string[] {
return Array.from(this._byId.keys());
}
/**
* Adds or updates an entry. When the same folder path is already mapped to
* a different session, the existing mapping is preserved.
*/
addEntry(entry: WorktreeSessionEntry): void {
const folderUri = Uri.file(entry.path);
// If this session already has an entry with a different path, clean up
// the old folder → session-id mapping before recording the new one.
const previousEntry = this._byId.get(entry.id);
if (previousEntry && previousEntry.path !== entry.path) {
const prevUri = Uri.file(previousEntry.path);
if (this._byFolder.get(prevUri) === entry.id) {
this._byFolder.delete(prevUri);
}
}
this._byId.set(entry.id, entry);
const existingIdForFolder = this._byFolder.get(folderUri);
if (!existingIdForFolder) {
this._byFolder.set(folderUri, entry.id);
return;
}
if (existingIdForFolder === entry.id) {
return;
}
const existingEntry = this._byId.get(existingIdForFolder);
if (existingEntry) {
return;
}
this._byFolder.set(folderUri, entry.id);
}
deleteEntry(sessionId: string): void {
const entry = this._byId.get(sessionId);
if (!entry) {
return;
}
this._byId.delete(sessionId);
const folderUri = Uri.file(entry.path);
if (this._byFolder.get(folderUri) === sessionId) {
this._byFolder.delete(folderUri);
for (const candidate of this._byId.values()) {
if (candidate.path === entry.path) {
this._byFolder.set(folderUri, candidate.id);
break;
}
}
}
}
clear(): void {
this._byId.clear();
this._byFolder.clear();
}
getEntries(): WorktreeSessionEntry[] {
return Array.from(this._byId.values());
}
/**
* Loads the JSONL worktree index from disk into the in-memory maps.
* Returns `rewriteNeeded` if the file contained malformed lines or
* duplicates that should be compacted via {@link writeToDisk}.
*/
async loadFromDisk(): Promise<{ rewriteNeeded: boolean }> {
let rewriteNeeded = false;
let raw: string;
try {
const bytes = await this._fileSystemService.readFile(Uri.file(this._jsonlPath));
raw = new TextDecoder().decode(bytes);
} catch {
this._lastLoadAt = Date.now();
return { rewriteNeeded: false };
}
this.clear();
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
const entry = JSON.parse(trimmed) as WorktreeSessionEntry;
if (!entry?.id || !entry.path) {
rewriteNeeded = true;
continue;
}
if (this._byId.has(entry.id)) {
rewriteNeeded = true;
}
this.addEntry(entry);
} catch {
rewriteNeeded = true;
}
}
this._lastLoadAt = Date.now();
return { rewriteNeeded };
}
/** Reloads from disk only if more than 1 second has passed since the last load. */
async reloadIfStale(): Promise<void> {
if (Date.now() - this._lastLoadAt < 1000) {
return;
}
await this.loadFromDisk();
}
/** Writes the entire in-memory index to the JSONL file, replacing its contents. */
async writeToDisk(): Promise<void> {
return this._writeSequencer.queue(async () => {
try {
const jsonlUri = Uri.file(this._jsonlPath);
await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri));
const lines = this._byId.size > 0
? Array.from(this._byId.values()).map(e => JSON.stringify(e)).join('\n') + '\n'
: '';
await this._fileSystemService.writeFile(jsonlUri, new TextEncoder().encode(lines));
} catch (err) {
this._logService.error('[WorktreeSessionIndex] Failed to write JSONL: ', err);
}
});
}
/** Appends entries to the JSONL file and adds them to the in-memory index. */
async appendBatchToDisk(entries: WorktreeSessionEntry[]): Promise<void> {
if (entries.length === 0) {
return;
}
return this._writeSequencer.queue(async () => {
try {
const jsonlUri = Uri.file(this._jsonlPath);
await createDirectoryIfNotExists(this._fileSystemService, dirname(jsonlUri));
let existing = '';
try {
existing = new TextDecoder().decode(await this._fileSystemService.readFile(jsonlUri));
} catch {
// File doesn't exist yet.
}
const suffix = entries.map(e => JSON.stringify(e)).join('\n') + '\n';
await this._fileSystemService.writeFile(
jsonlUri,
new TextEncoder().encode(existing + suffix),
);
for (const entry of entries) {
this.addEntry(entry);
}
} catch (err) {
this._logService.error('[WorktreeSessionIndex] Failed to bulk-append entries: ', err);
}
});
}
/** Removes an entry from the in-memory index and rewrites the JSONL file. */
async removeAndWriteToDisk(sessionId: string): Promise<void> {
if (!this._byId.has(sessionId)) {
return;
}
this.deleteEntry(sessionId);
await this.writeToDisk();
}
}
@@ -546,19 +546,20 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
private readonly _resolvedResources = new ResourceSet();
observeSession(resource: URI): IObservable<IAgentSession | undefined> {
// Trigger resolve if not yet resolved for this resource (or if
// the guard was cleared after a provider refresh). This is
// separated from the observable cache so that re-calls after a
// refresh re-trigger the resolve RPC even though the observable
// already exists.
if (!this._resolvedResources.has(resource)) {
this._resolvedResources.add(resource);
const sessionType = getChatSessionType(resource);
this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None)
.catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`));
}
let observable = this._sessionObservables.get(resource);
if (!observable) {
// Lazily trigger a resolve for this resource so consumers reading
// lazy properties (e.g. `changes`) get fresh data without needing
// to wait for a tree row to scroll into view. The chat sessions
// service deduplicates in-flight resolves by resource.
if (!this._resolvedResources.has(resource)) {
this._resolvedResources.add(resource);
const sessionType = getChatSessionType(resource);
this.chatSessionsService.resolveChatSessionItem(sessionType, resource, CancellationToken.None)
.catch(error => this.logger.logIfTrace(`observeSession: resolve failed for ${resource.toString()}: ${error instanceof Error ? error.message : String(error)}`));
}
this._changedSignal ??= observableSignalFromEvent('agentSessionsChanged', this.onDidChangeSessions);
const signal = this._changedSignal;
observable = derived(reader => {
@@ -610,6 +611,22 @@ export class AgentSessionsModel extends Disposable implements IAgentSessionsMode
private async doResolveProvider(provider: string, options: { refreshProvider: boolean }, token: CancellationToken): Promise<void> {
if (options.refreshProvider) {
await this.chatSessionsService.refreshChatSessionItems([provider], token);
// Clear the resolve-once guard for sessions belonging to this
// provider and re-trigger resolve for any that were previously
// observed. This is necessary because the refresh returns items
// with lazy properties (e.g. changes: undefined) that need a
// fresh resolve RPC. Re-calling observeSession() for resources
// already in _sessionObservables is cheap (the observable is
// cached) and only fires the RPC side-effect.
for (const resource of [...this._resolvedResources]) {
if (getChatSessionType(resource) === provider) {
this._resolvedResources.delete(resource);
if (this._sessionObservables.has(resource)) {
this.observeSession(resource);
}
}
}
}
const mapSessionContributionToType = new Map<string, ResolvedChatSessionsExtensionPoint>();