Copilot CLI: Controller API Changes (#308435)

* Copilot CLI: Controller API Changes

* Fix tets

* Fix tets

* Update extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Don Jayamanne
2026-04-08 16:21:22 +10:00
committed by GitHub
parent 0afa58dace
commit b0ee7a156a
17 changed files with 3859 additions and 1170 deletions

View File

@@ -179,3 +179,10 @@ export interface IFolderRepositoryManager {
*/
getFolderMRU(): Promise<FolderRepositoryMRUEntry[]>;
}
export interface IChatFolderMruService {
readonly _serviceBrand: undefined;
getRecentlyUsedFolders(token: vscode.CancellationToken): Promise<FolderRepositoryMRUEntry[]>;
deleteRecentlyUsedFolder(folder: vscode.Uri): Promise<void>;
}
export const IChatFolderMruService = createServiceIdentifier<IChatFolderMruService>('IChatFolderMruService');

View File

@@ -0,0 +1,21 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { Uri } from 'vscode';
import { URI } from '../../../../util/vs/base/common/uri';
export namespace SessionIdForCLI {
export function getResource(sessionId: string): Uri {
return URI.from({ scheme: 'copilotcli', path: `/${sessionId}` }) as unknown as Uri;
}
export function parse(resource: Uri): string {
return resource.path.slice(1);
}
export function isCLIResource(resource: Uri): boolean {
return resource.scheme === 'copilotcli';
}
}

View File

@@ -4,28 +4,20 @@
*--------------------------------------------------------------------------------------------*/
import { CancellationToken, Uri } from 'vscode';
import { IGitService } from '../../../platform/git/common/gitService';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../util/common/services';
import { raceTimeout } from '../../../util/vs/base/common/async';
import { ResourceMap, ResourceSet } from '../../../util/vs/base/common/map';
import { ChatSessionStatus } from '../../../vscodeTypes';
import { FolderRepositoryMRUEntry } from '../common/folderRepositoryManager';
import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
import { IGitService } from '../../../../platform/git/common/gitService';
import { IWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { raceTimeout } from '../../../../util/vs/base/common/async';
import { ResourceMap, ResourceSet } from '../../../../util/vs/base/common/map';
import { ChatSessionStatus } from '../../../../vscodeTypes';
import { FolderRepositoryMRUEntry, IChatFolderMruService } from '../../common/folderRepositoryManager';
import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
type Mutable<T> = {
-readonly [K in keyof T]: T[K];
};
export interface ICopilotCLIFolderMruService {
readonly _serviceBrand: undefined;
getRecentlyUsedFolders(token: CancellationToken): Promise<FolderRepositoryMRUEntry[]>;
deleteRecentlyUsedFolder(folder: Uri): Promise<void>;
}
export const ICopilotCLIFolderMruService = createServiceIdentifier<ICopilotCLIFolderMruService>('ICopilotCLIFolderMruService');
export class CopilotCLIFolderMruService implements ICopilotCLIFolderMruService {
export class CopilotCLIFolderMruService implements IChatFolderMruService {
declare _serviceBrand: undefined;
private readonly removedFolders = new ResourceSet();
private cachedEntries: FolderRepositoryMRUEntry[] | undefined = undefined;
@@ -64,7 +56,7 @@ export class CopilotCLIFolderMruService implements ICopilotCLIFolderMruService {
continue;
}
const isActive = session.status === ChatSessionStatus.InProgress;
const lastAccessed = session.timing?.lastRequestEnded ?? session.timing?.endTime ?? session.timing?.startTime ?? session.timing?.startTime ?? (isActive ? Date.now() : 0);
const lastAccessed = session.timing?.lastRequestEnded ?? session.timing?.endTime ?? session.timing?.lastRequestStarted ?? session.timing?.startTime ?? (isActive ? Date.now() : 0);
mruEntries.set(session.workingDirectory, {
folder: session.workingDirectory,
repository: undefined,

View File

@@ -107,7 +107,7 @@ export class ChatSessionRepositoryTracker extends Disposable {
// 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', sessionId: '' });
await this.sessionItemProvider.refreshSession({ reason: 'update', sessionIds: workspaceSessionIds });
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangesWorkspaceFile] Updated session properties for workspace ${uri.toString()}.`);
} else {
this.logService.trace(`[ChatSessionRepositoryTracker][onDidChangesWorkspaceFile] No session associated with workspace ${uri.toString()}.`);

View File

@@ -39,7 +39,7 @@ import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { IFolderRepositoryManager } from '../common/folderRepositoryManager';
import { IChatFolderMruService, IFolderRepositoryManager } from '../common/folderRepositoryManager';
import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';
import { ChatDelegationSummaryService, IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
import { CopilotCLIAgents, CopilotCLIModels, CopilotCLISDK, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK } from '../copilotcli/node/copilotCli';
@@ -50,12 +50,14 @@ import { CopilotCLISkills, ICopilotCLISkills } from '../copilotcli/node/copilotC
import { CopilotCLIMCPHandler, ICopilotCLIMCPHandler } from '../copilotcli/node/mcpHandler';
import { IUserQuestionHandler } from '../copilotcli/node/userInputHelpers';
import { CopilotCLIContrib, getServices } from '../copilotcli/vscode-node/contribution';
import { CopilotCLIFolderMruService } from '../copilotcli/vscode-node/copilotCLIFolderMru';
import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';
import { CustomSessionTitleService } from '../copilotcli/vscode-node/customSessionTitleServiceImpl';
import { GHPR_EXTENSION_ID } from '../vscode/chatSessionsUriHandler';
import { AgentSessionsWorkspace } from './agentSessionsWorkspace';
import { UserQuestionHandler } from './askUserQuestionHandler';
import { ChatPromptFileService } from './chatPromptFileService';
import { CopilotCLIChatSessionInitializer, ICopilotCLIChatSessionInitializer } from './copilotCLIChatSessionInitializer';
import { ChatSessionMetadataStore } from './chatSessionMetadataStoreImpl';
import { ChatSessionRepositoryTracker } from './chatSessionRepositoryTracker';
import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderServiceImpl';
@@ -66,12 +68,14 @@ import { ClaudeCustomizationProvider } from './claudeCustomizationProvider';
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionParticipant, registerCLIChatCommands } from './copilotCLIChatSessions';
import { CopilotCLIChatSessionContentProvider as CopilotCLIChatSessionContentProviderV1, CopilotCLIChatSessionItemProvider as CopilotCLIChatSessionItemProviderV1, CopilotCLIChatSessionParticipant as CopilotCLIChatSessionParticipantV1, registerCLIChatCommands as registerCLIChatCommandsV1 } from './copilotCLIChatSessionsContribution';
import { CopilotCLICustomizationProvider } from './copilotCLICustomizationProvider';
import { CopilotCLIFolderMruService, ICopilotCLIFolderMruService } from './copilotCLIFolderMru';
import { CopilotCLITerminalIntegration, ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
import { ClaudeFolderRepositoryManager, CopilotCLIFolderRepositoryManager } from './folderRepositoryManagerImpl';
import { PRContentProvider } from './prContentProvider';
import { IPullRequestDetectionService, PullRequestDetectionService } from './pullRequestDetectionService';
import { IPullRequestFileChangesService, PullRequestFileChangesService } from './pullRequestFileChangesService';
import { ISessionRequestLifecycle, SessionRequestLifecycle } from './sessionRequestLifecycle';
import { ISessionOptionGroupBuilder, SessionOptionGroupBuilder } from './sessionOptionGroupBuilder';
// https://github.com/microsoft/vscode-pull-request-github/blob/8a5c9a145cd80ee364a3bed9cf616b2bd8ac74c2/src/github/copilotApi.ts#L56-L71
@@ -180,7 +184,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
[ICustomSessionTitleService, new SyncDescriptor(CustomSessionTitleService)],
[ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)],
[IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)],
[ICopilotCLIFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)],
[IChatFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)],
[IPullRequestDetectionService, new SyncDescriptor(PullRequestDetectionService)],
[ISessionOptionGroupBuilder, new SyncDescriptor(SessionOptionGroupBuilder)],
[ISessionRequestLifecycle, new SyncDescriptor(SessionRequestLifecycle)],
[ICopilotCLIChatSessionInitializer, new SyncDescriptor(CopilotCLIChatSessionInitializer)],
...getServices()
));
@@ -206,7 +214,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService));
const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService));
const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels));
const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIFolderMruService));
const copilotCLIFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatPromptFileService)));
@@ -248,7 +256,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
[ICustomSessionTitleService, new SyncDescriptor(CustomSessionTitleService)],
[ICopilotCLISkills, new SyncDescriptor(CopilotCLISkills)],
[IChatSessionMetadataStore, new SyncDescriptor(ChatSessionMetadataStore)],
[ICopilotCLIFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)],
[IChatFolderMruService, new SyncDescriptor(CopilotCLIFolderMruService)],
...getServices()
));
@@ -275,7 +283,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib
const nativeEnvService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(INativeEnvService));
const fileSystemService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IFileSystemService));
const copilotModels = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIModels));
const copilotFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLIFolderMruService));
const copilotFolderMruService = copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatFolderMruService));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(ICopilotCLISessionTracker)));
this._register(copilotcliAgentInstaService.invokeFunction(accessor => accessor.get(IChatPromptFileService)));

View File

@@ -0,0 +1,313 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { SweCustomAgent } from '@github/copilot/sdk';
import * as l10n from '@vscode/l10n';
import * as vscode from 'vscode';
import { ILogService } from '../../../platform/log/common/logService';
import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../util/common/services';
import { DisposableStore, IReference } from '../../../util/vs/base/common/lifecycle';
import { URI } from '../../../util/vs/base/common/uri';
import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection';
import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { FolderRepositoryInfo, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
import { SessionIdForCLI } from '../copilotcli/common/utils';
import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels } from '../copilotcli/node/copilotCli';
import { ICopilotCLISession } from '../copilotcli/node/copilotcliSession';
import { ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService';
import { buildMcpServerMappings, McpServerMappings } from '../copilotcli/node/mcpHandler';
import { BRANCH_OPTION_ID, ISOLATION_OPTION_ID, REPOSITORY_OPTION_ID } from './sessionOptionGroupBuilder';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean {
return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled);
}
export interface ICopilotCLIChatSessionInitializer {
readonly _serviceBrand: undefined;
/**
* Get or create a session for a chat request with a chat session context.
* Handles working directory initialization, model/agent resolution,
* session creation, worktree properties, workspace folder tracking,
* stream attachment, permission level, and request metadata recording.
*/
getOrCreateSession(
request: vscode.ChatRequest,
chatSessionContext: vscode.ChatSessionContext,
stream: vscode.ChatResponseStream,
options: { branchName: Promise<string | undefined> },
disposables: DisposableStore,
token: vscode.CancellationToken
): Promise<{ session: IReference<ICopilotCLISession> | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }>;
/**
* Initialize a working directory, optionally based on a chat session context.
* Used for both normal requests and delegation flows.
*/
initializeWorkingDirectory(
chatSessionContext: vscode.ChatSessionContext | undefined,
isolation: IsolationMode | undefined,
branchName: Promise<string | undefined> | undefined,
stream: vscode.ChatResponseStream,
toolInvocationToken: vscode.ChatParticipantToolToken,
token: vscode.CancellationToken
): Promise<{ workspaceInfo: IWorkspaceInfo; cancelled: boolean; trusted: boolean }>;
/**
* Create a new session for delegation and handle post-creation bookkeeping
* including request metadata recording.
*/
createDelegatedSession(
request: vscode.ChatRequest,
workspace: IWorkspaceInfo,
options: { mcpServerMappings: McpServerMappings },
token: vscode.CancellationToken
): Promise<{ session: IReference<ICopilotCLISession>; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined }>;
}
export const ICopilotCLIChatSessionInitializer = createServiceIdentifier<ICopilotCLIChatSessionInitializer>('ICopilotCLIChatSessionInitializer');
export class CopilotCLIChatSessionInitializer implements ICopilotCLIChatSessionInitializer {
declare readonly _serviceBrand: undefined;
constructor(
@ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService,
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
@IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService,
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels,
@ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents,
@IPromptsService private readonly promptsService: IPromptsService,
@IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore,
@ILogService private readonly logService: ILogService,
@IConfigurationService private readonly configurationService: IConfigurationService,
) { }
async getOrCreateSession(
request: vscode.ChatRequest,
chatSessionContext: vscode.ChatSessionContext,
stream: vscode.ChatResponseStream,
options: { branchName: Promise<string | undefined> },
disposables: DisposableStore,
token: vscode.CancellationToken
): Promise<{ session: IReference<ICopilotCLISession> | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; trusted: boolean }> {
const { resource } = chatSessionContext.chatSessionItem;
const sessionId = SessionIdForCLI.parse(resource);
const isNewSession = this.sessionService.isNewSessionId(sessionId);
const [{ workspaceInfo, cancelled, trusted }, model, agent] = await Promise.all([
this.initializeWorkingDirectory(chatSessionContext, undefined, options.branchName, stream, request.toolInvocationToken, token),
this.resolveModel(request, token),
this.resolveAgent(request, token),
]);
const workingDirectory = getWorkingDirectory(workspaceInfo);
const worktreeProperties = workspaceInfo.worktreeProperties;
if (cancelled || token.isCancellationRequested) {
return { session: undefined, isNewSession, model, agent, trusted };
}
const debugTargetSessionIds = extractDebugTargetSessionIds(request.references);
const mcpServerMappings = buildMcpServerMappings(request.tools);
const session = isNewSession ?
await this.sessionService.createSession({ sessionId, model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token) :
await this.sessionService.getSession({ sessionId, model: model?.model, reasoningEffort: model?.reasoningEffort, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings }, token);
if (!session) {
stream.warning(l10n.t('Chat session not found.'));
return { session: undefined, isNewSession, model, agent, trusted };
}
this.logService.info(`Using Copilot CLI session: ${session.object.sessionId} (isNewSession: ${isNewSession}, isolationEnabled: ${isIsolationEnabled(workspaceInfo)}, workingDirectory: ${workingDirectory}, worktreePath: ${worktreeProperties?.worktreePath})`);
if (isNewSession) {
if (worktreeProperties) {
void this.worktreeService.setWorktreeProperties(session.object.sessionId, worktreeProperties);
}
this.finalizeSessionCreation(session.object.sessionId, session.object.workspace);
}
const modeInstructions = this.createModeInstructions(request);
this.chatSessionMetadataStore.updateRequestDetails(sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
disposables.add(session);
disposables.add(session.object.attachStream(stream));
session.object.setPermissionLevel(request.permissionLevel);
return { session, isNewSession, model, agent, trusted };
}
async initializeWorkingDirectory(
chatSessionContext: vscode.ChatSessionContext | undefined,
isolation: IsolationMode | undefined,
branchName: Promise<string | undefined> | undefined,
stream: vscode.ChatResponseStream,
toolInvocationToken: vscode.ChatParticipantToolToken,
token: vscode.CancellationToken
): Promise<{ workspaceInfo: IWorkspaceInfo; cancelled: boolean; trusted: boolean }> {
let folderInfo: FolderRepositoryInfo;
let folder: undefined | vscode.Uri = undefined;
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
if (workspaceFolders.length === 1) {
folder = workspaceFolders[0];
}
if (chatSessionContext) {
const sessionId = SessionIdForCLI.parse(chatSessionContext.chatSessionItem.resource);
const isNewSession = this.sessionService.isNewSessionId(sessionId);
if (isNewSession) {
let isolation = IsolationMode.Workspace;
let branch: string | undefined = undefined;
for (const opt of (chatSessionContext.initialSessionOptions || [])) {
const value = typeof opt.value === 'string' ? opt.value : opt.value.id;
if (opt.optionId === REPOSITORY_OPTION_ID && value) {
folder = vscode.Uri.file(value);
} else if (opt.optionId === BRANCH_OPTION_ID && value) {
branch = value;
} else if (opt.optionId === ISOLATION_OPTION_ID && value) {
isolation = value as IsolationMode;
}
}
// Use FolderRepositoryManager to initialize folder/repository with worktree creation
folderInfo = await this.folderRepositoryManager.initializeFolderRepository(sessionId, { stream, toolInvocationToken, branch, isolation, folder, newBranch: branchName }, token);
} else {
// Existing session - use getFolderRepository for resolution with trust check
folderInfo = await this.folderRepositoryManager.getFolderRepository(sessionId, { promptForTrust: true, stream }, token);
}
} else {
// No chat session context (e.g., delegation) - initialize with active repository
folderInfo = await this.folderRepositoryManager.initializeFolderRepository(undefined, { stream, toolInvocationToken, isolation, folder }, token);
}
if (folderInfo.trusted === false || folderInfo.cancelled) {
return { workspaceInfo: emptyWorkspaceInfo(), cancelled: true, trusted: folderInfo.trusted !== false };
}
const workspaceInfo = Object.assign({}, folderInfo);
return { workspaceInfo, cancelled: false, trusted: true };
}
async createDelegatedSession(
request: vscode.ChatRequest,
workspace: IWorkspaceInfo,
options: { mcpServerMappings: McpServerMappings },
token: vscode.CancellationToken
): Promise<{ session: IReference<ICopilotCLISession>; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined }> {
const [model, agent] = await Promise.all([
this.resolveModel(request, token),
this.resolveAgent(request, token),
]);
const session = await this.sessionService.createSession({ workspace, agent, model: model?.model, reasoningEffort: model?.reasoningEffort, mcpServerMappings: options.mcpServerMappings }, token);
const worktreeProperties = workspace.worktreeProperties;
if (worktreeProperties) {
void this.worktreeService.setWorktreeProperties(session.object.sessionId, worktreeProperties);
}
this.finalizeSessionCreation(session.object.sessionId, workspace);
const modeInstructions = this.createModeInstructions(request);
this.chatSessionMetadataStore.updateRequestDetails(session.object.sessionId, [{ vscodeRequestId: request.id, agentId: agent?.name ?? '', modeInstructions }]).catch(ex => this.logService.error(ex, 'Failed to update request details'));
return { session, model, agent };
}
/**
* Resolve the model ID to use for a request.
*/
async resolveModel(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<{ model: string; reasoningEffort?: string } | undefined> {
const promptFile = request ? await this.getPromptInfoFromRequest(request, token) : undefined;
const model = promptFile?.header?.model ? await this.getModelFromPromptFile(promptFile.header.model) : undefined;
if (token.isCancellationRequested) {
return undefined;
}
if (model) {
return { model };
}
// Get model from request.
const preferredModelInRequest = request?.model?.id ? await this.copilotCLIModels.resolveModel(request.model.id) : undefined;
if (preferredModelInRequest) {
const reasoningEffort = isReasoningEffortFeatureEnabled(this.configurationService) ? request?.modelConfiguration?.[COPILOT_CLI_REASONING_EFFORT_PROPERTY] : undefined;
return {
model: preferredModelInRequest,
reasoningEffort: typeof reasoningEffort === 'string' && reasoningEffort ? reasoningEffort : undefined
};
}
const defaultModel = await this.copilotCLIModels.getDefaultModel();
if (!defaultModel) {
return undefined;
}
return { model: defaultModel };
}
/**
* Resolve the agent to use for a request.
*/
async resolveAgent(request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise<SweCustomAgent | undefined> {
if (request?.modeInstructions2) {
const customAgent = request.modeInstructions2.uri ? await this.copilotCLIAgents.resolveAgent(request.modeInstructions2.uri.toString()) : await this.copilotCLIAgents.resolveAgent(request.modeInstructions2.name);
if (customAgent) {
const tools = (request.modeInstructions2.toolReferences || []).map(t => t.name);
if (tools.length > 0) {
customAgent.tools = tools;
}
return customAgent;
}
}
return undefined;
}
private finalizeSessionCreation(sessionId: string, workspace: IWorkspaceInfo): void {
const workingDirectory = getWorkingDirectory(workspace);
if (workingDirectory && !isIsolationEnabled(workspace)) {
void this.workspaceFolderService.trackSessionWorkspaceFolder(sessionId, workingDirectory.fsPath, workspace.repositoryProperties);
}
}
private createModeInstructions(request: vscode.ChatRequest): StoredModeInstructions | undefined {
return request.modeInstructions2 ? {
uri: request.modeInstructions2.uri?.toString(),
name: request.modeInstructions2.name,
content: request.modeInstructions2.content,
metadata: request.modeInstructions2.metadata,
isBuiltin: request.modeInstructions2.isBuiltin,
} : undefined;
}
private async getPromptInfoFromRequest(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise<ParsedPromptFile | undefined> {
const promptFile = new ChatVariablesCollection(request.references).find(isPromptFile);
if (!promptFile || !URI.isUri(promptFile.reference.value)) {
return undefined;
}
try {
return await this.promptsService.parseFile(promptFile.reference.value, token);
} catch (ex) {
this.logService.error(`Failed to parse the prompt file: ${promptFile.reference.value.toString()}`, ex);
return undefined;
}
}
private async getModelFromPromptFile(models: readonly string[]): Promise<string | undefined> {
for (const model of models) {
let modelId = await this.copilotCLIModels.resolveModel(model);
if (modelId) {
return modelId;
}
// Sometimes the models can contain ` (Copilot)` suffix, try stripping that and resolving again.
if (!model.includes('(')) {
continue;
}
modelId = await this.copilotCLIModels.resolveModel(model.substring(0, model.indexOf('(')).trim());
if (modelId) {
return modelId;
}
}
return undefined;
}
}

View File

@@ -38,7 +38,7 @@ import { IChatSessionMetadataStore, RepositoryProperties, StoredModeInstructions
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { FolderRepositoryInfo, FolderRepositoryMRUEntry, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
import { FolderRepositoryInfo, FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
import { isUntitledSessionId } from '../common/utils';
import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService';
@@ -51,7 +51,6 @@ import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli
import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler';
import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker';
import { ICopilotCLIChatSessionItemProvider } from './copilotCLIChatSessions';
import { ICopilotCLIFolderMruService } from './copilotCLIFolderMru';
import { convertReferenceToVariable } from './copilotCLIPromptReferences';
import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration';
import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider';
@@ -209,7 +208,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc
this._onDidChangeChatSessionItems.fire();
}
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'delete'; sessionId: string }): Promise<void> {
public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise<void> {
this._onDidChangeChatSessionItems.fire();
}
@@ -474,7 +473,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements
@ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService,
@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,
@ILogService private readonly logService: ILogService,
@ICopilotCLIFolderMruService private readonly folderMruService: ICopilotCLIFolderMruService,
@IChatFolderMruService private readonly folderMruService: IChatFolderMruService,
) {
super();
const originalRepos = this.getRepositoryOptionItems().length;
@@ -1922,7 +1921,7 @@ export function registerCLIChatCommands(
copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService,
contentProvider: CopilotCLIChatSessionContentProvider,
folderRepositoryManager: IFolderRepositoryManager,
cliFolderMruService: ICopilotCLIFolderMruService,
cliFolderMruService: IChatFolderMruService,
envService: INativeEnvService,
fileSystemService: IFileSystemService,
logService: ILogService

View File

@@ -0,0 +1,245 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getGitHubRepoInfoFromContext, IGitService } from '../../../platform/git/common/gitService';
import { derivePullRequestState } from '../../../platform/github/common/githubAPI';
import { IOctoKitService } from '../../../platform/github/common/githubService';
import { ILogService } from '../../../platform/log/common/logService';
import { createServiceIdentifier } from '../../../util/common/services';
import { Emitter, Event } from '../../../util/vs/base/common/event';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { URI } from '../../../util/vs/base/common/uri';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
const PR_DETECTION_RETRY_COUNT = 5;
const PR_DETECTION_INITIAL_DELAY_MS = 2_000;
export interface IPullRequestDetectionService {
readonly _serviceBrand: undefined;
/**
* Fired when a pull request is detected or updated for a session.
* Consumers should refresh the session UI in response.
*/
readonly onDidDetectPullRequest: Event<string>;
/**
* Detects a pull request for a session when the user opens it.
* If a PR is found, persists the URL and notifies the UI.
*/
detectPullRequest(sessionId: string): void;
/**
* Called after a request completes to persist PR metadata on the session.
* If a PR URL is provided, uses that; otherwise attempts detection
* via the GitHub API with exponential-backoff retry.
*/
handlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): void;
}
export const IPullRequestDetectionService = createServiceIdentifier<IPullRequestDetectionService>('IPullRequestDetectionService');
/**
* Queries the GitHub API to find a pull request whose head branch matches the
* given worktree branch. This covers cases where the MCP tool failed to report
* a PR URL, or the user created the PR externally (e.g., via github.com).
*/
async function detectPullRequestFromGitHubAPI(
branchName: string,
repositoryPath: string,
gitService: IGitService,
octoKitService: IOctoKitService,
logService: ILogService,
): Promise<{ url: string; state: string } | undefined> {
const repoContext = await gitService.getRepository(URI.file(repositoryPath));
if (!repoContext) {
logService.debug(`[detectPullRequestFromGitHubAPI] No git repository found for path: ${repositoryPath}`);
return undefined;
}
const repoInfo = getGitHubRepoInfoFromContext(repoContext);
if (!repoInfo) {
logService.debug(`[detectPullRequestFromGitHubAPI] Could not extract GitHub repo info from repository at: ${repositoryPath}`);
return undefined;
}
logService.debug(`[detectPullRequestFromGitHubAPI] Querying GitHub API for PR on ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);
const pr = await octoKitService.findPullRequestByHeadBranch(
repoInfo.id.org,
repoInfo.id.repo,
branchName,
{},
);
if (pr?.url) {
const prState = derivePullRequestState(pr);
logService.trace(`[detectPullRequestFromGitHubAPI] Detected pull request via GitHub API: ${pr.url} ${prState}`);
return { url: pr.url, state: prState };
}
logService.debug(`[detectPullRequestFromGitHubAPI] No PR found for ${repoInfo.id.org}/${repoInfo.id.repo}, branch=${branchName}`);
return undefined;
}
/**
* Encapsulates all pull-request detection and persistence logic for chat sessions.
*/
export class PullRequestDetectionService extends Disposable implements IPullRequestDetectionService {
declare readonly _serviceBrand: undefined;
private readonly _onDidDetectPullRequest = this._register(new Emitter<string>());
readonly onDidDetectPullRequest: Event<string> = this._onDidDetectPullRequest.event;
constructor(
@IChatSessionWorktreeService private readonly chatSessionWorktreeService: IChatSessionWorktreeService,
@IGitService private readonly gitService: IGitService,
@IOctoKitService private readonly octoKitService: IOctoKitService,
@ILogService private readonly logService: ILogService,
) {
super();
}
/**
* Detects a pull request for a session when the user opens it.
* If a PR is found, persists the URL and notifies the UI.
*/
detectPullRequest(sessionId: string): void {
this.doDetectPullRequestOnSessionOpen(sessionId).catch(ex =>
this.logService.error(ex instanceof Error ? ex : new Error(String(ex)), `Failed to detect pull request on session open for ${sessionId}`));
}
private async doDetectPullRequestOnSessionOpen(sessionId: string): Promise<void> {
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
if (worktreeProperties?.version !== 2
|| worktreeProperties.pullRequestState === 'merged'
|| !worktreeProperties.branchName
|| !worktreeProperties.repositoryPath) {
this.logService.debug(`[PullRequestDetectionService] Skipping PR detection on session open for ${sessionId}: version=${worktreeProperties?.version}, prState=${worktreeProperties?.version === 2 ? worktreeProperties.pullRequestState : 'n/a'}, branch=${!!worktreeProperties?.branchName}, repoPath=${!!worktreeProperties?.repositoryPath}`);
return;
}
this.logService.debug(`[PullRequestDetectionService] Detecting PR on session open for ${sessionId}, branch=${worktreeProperties.branchName}, existingPrUrl=${worktreeProperties.pullRequestUrl ?? 'none'}`);
const prResult = await this.detectPullRequestForSession(sessionId);
if (prResult) {
// Re-read to get the latest information.
const currentProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
if (currentProperties?.version === 2
&& (currentProperties.pullRequestUrl !== prResult.url || currentProperties.pullRequestState !== prResult.state)) {
await this.chatSessionWorktreeService.setWorktreeProperties(sessionId, {
...currentProperties, // use fresh copy
pullRequestUrl: prResult.url,
pullRequestState: prResult.state,
changes: undefined,
});
this._onDidDetectPullRequest.fire(sessionId);
} else {
this.logService.debug(`[PullRequestDetectionService] PR metadata unchanged for ${sessionId}, skipping update`);
}
} else {
this.logService.debug(`[PullRequestDetectionService] No PR found via GitHub API for ${sessionId}`);
}
}
/**
* Called after a request completes to persist PR metadata on the session.
* If the session reported a PR URL, uses that; otherwise attempts detection
* via the GitHub API with exponential-backoff retry.
* Fires {@link onDidDetectPullRequest} if a PR is detected and persisted.
*/
handlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): void {
this.doHandlePullRequestCreated(sessionId, createdPullRequestUrl).catch(ex =>
this.logService.error(ex instanceof Error ? ex : new Error(String(ex)), `Failed to handle pull request creation for session ${sessionId}`));
}
private async doHandlePullRequestCreated(sessionId: string, createdPullRequestUrl: string | undefined): Promise<void> {
let prUrl = createdPullRequestUrl;
let prState = '';
this.logService.debug(`[PullRequestDetectionService] handlePullRequestCreated for ${sessionId}: createdPullRequestUrl=${prUrl ?? 'none'}`);
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
if (!worktreeProperties || worktreeProperties.version !== 2) {
return;
}
if (!prUrl) {
if (worktreeProperties.branchName && worktreeProperties.repositoryPath) {
this.logService.debug(`[PullRequestDetectionService] No PR URL from session, attempting retry detection for ${sessionId}, branch=${worktreeProperties.branchName}`);
const prResult = await this.detectPullRequestWithRetry(sessionId);
prUrl = prResult?.url;
prState = prResult?.state ?? (prResult?.url ? 'open' : '');
} else {
this.logService.debug(`[PullRequestDetectionService] Skipping retry detection for ${sessionId}: branch=${worktreeProperties.branchName ?? 'none'}, repoPath=${!!worktreeProperties.repositoryPath}`);
}
}
if (!prUrl) {
this.logService.debug(`[PullRequestDetectionService] No PR detected for ${sessionId} after all attempts`);
return;
}
try {
await this.chatSessionWorktreeService.setWorktreeProperties(sessionId, {
...worktreeProperties,
pullRequestUrl: prUrl,
pullRequestState: prState,
changes: undefined,
});
this._onDidDetectPullRequest.fire(sessionId);
} catch (error) {
this.logService.error(error instanceof Error ? error : new Error(String(error)), `Failed to persist pull request metadata for session ${sessionId}`);
}
}
/**
* Attempts to detect a pull request for a freshly-completed session using
* exponential backoff. The GitHub API may not have indexed the PR immediately
* after `gh pr create` returns, so we retry with increasing delays:
* attempt 1: 2s, attempt 2: 4s, attempt 3: 8s, ...
*/
private async detectPullRequestWithRetry(sessionId: string): Promise<{ url: string; state: string } | undefined> {
for (let attempt = 0; attempt < PR_DETECTION_RETRY_COUNT; attempt++) {
const delay = PR_DETECTION_INITIAL_DELAY_MS * Math.pow(2, attempt);
this.logService.debug(`[PullRequestDetectionService] PR detection retry for ${sessionId}: attempt ${attempt + 1}/${PR_DETECTION_RETRY_COUNT}, waiting ${delay}ms`);
await new Promise<void>(resolve => setTimeout(resolve, delay));
const prResult = await this.detectPullRequestForSession(sessionId);
if (prResult) {
this.logService.debug(`[PullRequestDetectionService] PR detected on attempt ${attempt + 1} for ${sessionId}: url=${prResult.url}, state=${prResult.state}`);
return prResult;
}
}
this.logService.debug(`[PullRequestDetectionService] PR detection exhausted all ${PR_DETECTION_RETRY_COUNT} retries for ${sessionId}`);
return undefined;
}
/**
* Queries the GitHub API to find a pull request whose head branch matches the
* session's worktree branch.
*/
private async detectPullRequestForSession(sessionId: string): Promise<{ url: string; state: string } | undefined> {
try {
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(sessionId);
if (!worktreeProperties?.branchName || !worktreeProperties.repositoryPath) {
this.logService.debug(`[PullRequestDetectionService] detectPullRequestForSession: missing worktree info for ${sessionId}, branch=${worktreeProperties?.branchName ?? 'none'}, repoPath=${!!worktreeProperties?.repositoryPath}`);
return undefined;
}
return await detectPullRequestFromGitHubAPI(
worktreeProperties.branchName,
worktreeProperties.repositoryPath,
this.gitService,
this.octoKitService,
this.logService,
);
} catch (error) {
this.logService.debug(`[PullRequestDetectionService] Failed to detect pull request via GitHub API: ${error instanceof Error ? error.message : String(error)}`);
return undefined;
}
}
}

View File

@@ -0,0 +1,538 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as l10n from '@vscode/l10n';
import * as vscode from 'vscode';
import { ChatSessionProviderOptionItem, Uri } from 'vscode';
import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService';
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
import { IGitService, RepoContext } from '../../../platform/git/common/gitService';
import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService';
import { createServiceIdentifier } from '../../../util/common/services';
import { isUri } from '../../../util/common/types';
import { SequencerByKey } from '../../../util/vs/base/common/async';
import { CancellationToken } from '../../../util/vs/base/common/cancellation';
import { basename } from '../../../util/vs/base/common/resources';
import { URI } from '../../../util/vs/base/common/uri';
import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager';
import { SessionIdForCLI } from '../copilotcli/common/utils';
import { isWelcomeView } from '../copilotcli/node/copilotCli';
export const REPOSITORY_OPTION_ID = 'repository';
export const BRANCH_OPTION_ID = 'branch';
export const ISOLATION_OPTION_ID = 'isolation';
export const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository';
/**
* Resolve which branch should be selected.
*
* Priority: previous selection (if still in the branch list) → active (HEAD)
* branch → previous selection as-is (stale but preserved so it's not lost).
*/
export function resolveBranchSelection<T extends { id: string }>(
branches: readonly T[],
activeBranchId: string | undefined,
previousSelection: T | undefined,
): T | undefined {
if (previousSelection) {
const inList = branches.find(b => b.id === previousSelection.id);
if (inList) {
return inList;
}
}
const activeBranch = activeBranchId
? branches.find(b => b.id === activeBranchId)
: undefined;
return activeBranch ?? previousSelection;
}
/**
* Determine branch dropdown locked state and `when` clause.
*
* - Isolation enabled + Workspace selected → locked, with `when` clause
* - Isolation enabled + Worktree selected → editable, with `when` clause
* - Isolation disabled → locked, no `when` clause (nothing to reference)
*/
export function resolveBranchLockState(
isolationEnabled: boolean,
currentIsolation: IsolationMode | undefined,
): { locked: boolean; when: string | undefined } {
if (!isolationEnabled) {
// No isolation dropdown exists, so no `when` clause to reference
return { locked: true, when: undefined };
}
const isWorktree = currentIsolation === IsolationMode.Worktree;
return {
locked: !isWorktree,
when: `chatSessionOption.${ISOLATION_OPTION_ID} == '${IsolationMode.Worktree}'`,
};
}
/**
* Resolve which isolation item should be selected for a new session.
* Uses the previous selection if valid, otherwise falls back to the last-used value.
*/
export function resolveIsolationSelection(
lastUsed: IsolationMode,
previousSelectionId: string | undefined,
): IsolationMode {
if (previousSelectionId === IsolationMode.Workspace || previousSelectionId === IsolationMode.Worktree) {
return previousSelectionId;
}
return lastUsed;
}
const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption';
const MAX_MRU_ENTRIES = 10;
const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-';
export function getSelectedOption(groups: readonly vscode.ChatSessionProviderOptionGroup[], groupId: string): vscode.ChatSessionProviderOptionItem | undefined {
return groups.find(g => g.id === groupId)?.selected;
}
export function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean {
return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport);
}
export function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean {
return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption);
}
export function toRepositoryOptionItem(repository: RepoContext | Uri, isDefault: boolean = false): ChatSessionProviderOptionItem {
const repositoryUri = isUri(repository) ? repository : repository.rootUri;
const repositoryIcon = isUri(repository) ? 'repo' : repository.kind === 'repository' ? 'repo' : 'archive';
const repositoryName = repositoryUri.path.split('/').pop() ?? repositoryUri.toString();
return {
id: repositoryUri.fsPath,
name: repositoryName,
icon: new vscode.ThemeIcon(repositoryIcon),
default: isDefault
} satisfies vscode.ChatSessionProviderOptionItem;
}
export function toWorkspaceFolderOptionItem(workspaceFolderUri: URI, name: string): ChatSessionProviderOptionItem {
return {
id: workspaceFolderUri.fsPath,
name: name,
icon: new vscode.ThemeIcon('folder'),
} satisfies vscode.ChatSessionProviderOptionItem;
}
export function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] {
return mruItems.map((item) => {
if (item.repository) {
return toRepositoryOptionItem(item.folder);
} else {
return toWorkspaceFolderOptionItem(item.folder, basename(item.folder));
}
});
}
/**
* Builds and manages the dropdown option groups (repository, branch, isolation)
* for new and existing CLI chat sessions.
*/
export interface ISessionOptionGroupBuilder {
readonly _serviceBrand: undefined;
provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise<vscode.ChatSessionProviderOptionGroup[]>;
buildBranchOptionGroup(branches: vscode.ChatSessionProviderOptionItem[], headBranchName: string | undefined, isolationEnabled: boolean, currentIsolation: IsolationMode | undefined, previousSelection: vscode.ChatSessionProviderOptionItem | undefined): vscode.ChatSessionProviderOptionGroup | undefined;
handleInputStateChange(state: vscode.ChatSessionInputState): Promise<void>;
buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptionGroup[]>;
getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]>;
getRepositoryOptionItems(): vscode.ChatSessionProviderOptionItem[];
updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise<void>;
}
export const ISessionOptionGroupBuilder = createServiceIdentifier<ISessionOptionGroupBuilder>('ISessionOptionGroupBuilder');
export class SessionOptionGroupBuilder implements ISessionOptionGroupBuilder {
declare readonly _serviceBrand: undefined;
private _lastUsedFolderIdInUntitledWorkspace?: { kind: 'folder' | 'repo'; uri: vscode.Uri; lastAccessed: number };
private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey<string>();
constructor(
@IGitService private readonly gitService: IGitService,
@IConfigurationService private readonly configurationService: IConfigurationService,
@IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext,
@IWorkspaceService private readonly workspaceService: IWorkspaceService,
@IChatFolderMruService private readonly copilotCLIFolderMruService: IChatFolderMruService,
@IAgentSessionsWorkspace private readonly agentSessionsWorkspace: IAgentSessionsWorkspace,
@IChatSessionWorktreeService private readonly chatSessionWorktreeService: IChatSessionWorktreeService,
@IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager,
) { }
async provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise<vscode.ChatSessionProviderOptionGroup[]> {
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService);
const previouslySelectedIsolationOption = previousInputState ? getSelectedOption(previousInputState.groups, ISOLATION_OPTION_ID) : undefined;
let currentIsolation: IsolationMode | undefined;
if (isolationEnabled) {
const lastUsed = this.context.globalState.get<IsolationMode>(LAST_USED_ISOLATION_OPTION_KEY, IsolationMode.Workspace);
currentIsolation = resolveIsolationSelection(lastUsed, previouslySelectedIsolationOption?.id);
const items = [
{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },
{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },
];
optionGroups.push({
id: ISOLATION_OPTION_ID,
name: l10n.t('Isolation'),
description: l10n.t('Pick Isolation Mode'),
items,
selected: previouslySelectedIsolationOption ?? items.find(i => i.id === currentIsolation)!
});
}
// Handle repository options based on workspace type
let defaultRepoUri = !isWelcomeView(this.workspaceService) && !this.agentSessionsWorkspace.isAgentSessionsWorkspace && this.workspaceService.getWorkspaceFolders()?.length === 1 ? this.workspaceService.getWorkspaceFolders()![0] : undefined;
if (isWelcomeView(this.workspaceService)) {
const commands: vscode.Command[] = [];
const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined;
let items: vscode.ChatSessionProviderOptionItem[] = [];
// For untitled workspaces, show last used repositories and "Open Repository..." command
const repositories = await this.copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None);
items = folderMRUToChatProviderOptions(repositories);
items.splice(MAX_MRU_ENTRIES); // Limit to max entries
if (this._lastUsedFolderIdInUntitledWorkspace) {
const folder = this._lastUsedFolderIdInUntitledWorkspace.uri;
const isRepo = this._lastUsedFolderIdInUntitledWorkspace.kind === 'repo';
const lastAccessed = this._lastUsedFolderIdInUntitledWorkspace.lastAccessed;
const id = folder.fsPath;
if (!items.find(item => item.id === id)) {
const lastUsedEntry = folderMRUToChatProviderOptions([{
folder,
repository: isRepo ? folder : undefined,
lastAccessed
}])[0];
items.unshift(lastUsedEntry);
}
}
commands.push({
command: OPEN_REPOSITORY_COMMAND_ID,
title: l10n.t('Browse folders...')
});
optionGroups.push({
id: REPOSITORY_OPTION_ID,
name: l10n.t('Folder'),
description: l10n.t('Pick Folder'),
items,
selected: previouslySelected,
commands
});
} else {
const repositories = this.getRepositoryOptionItems();
if (repositories.length > 1) {
const previouslySelected = previousInputState ? getSelectedOption(previousInputState.groups, REPOSITORY_OPTION_ID) : undefined;
const selectedRepository = previouslySelected ? repositories.find(repository => repository.id === previouslySelected.id) ?? repositories[0] : repositories[0];
defaultRepoUri = selectedRepository.id ? vscode.Uri.file(selectedRepository.id) : defaultRepoUri;
optionGroups.push({
id: REPOSITORY_OPTION_ID,
name: l10n.t('Folder'),
description: l10n.t('Pick Folder'),
items: repositories,
selected: selectedRepository
});
} else if (repositories.length === 1) {
defaultRepoUri = vscode.Uri.file(repositories[0].id);
}
}
if (isBranchOptionFeatureEnabled(this.configurationService)) {
const repo = defaultRepoUri ? await this.gitService.getRepository(defaultRepoUri) : undefined;
const branches = repo ? await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName) : [];
const previouslySelectedBranchItem = previousInputState ? getSelectedOption(previousInputState.groups, BRANCH_OPTION_ID) : undefined;
const branchGroup = this.buildBranchOptionGroup(branches, repo?.headBranchName, isolationEnabled, currentIsolation, previouslySelectedBranchItem);
if (branchGroup) {
optionGroups.push(branchGroup);
}
}
return optionGroups;
}
/**
* Build a branch option group from pre-fetched branch items.
* Returns undefined if there are no branches.
*/
buildBranchOptionGroup(
branches: vscode.ChatSessionProviderOptionItem[],
headBranchName: string | undefined,
isolationEnabled: boolean,
currentIsolation: IsolationMode | undefined,
previousSelection: vscode.ChatSessionProviderOptionItem | undefined,
): vscode.ChatSessionProviderOptionGroup | undefined {
if (branches.length === 0) {
return undefined;
}
const selectedItem = resolveBranchSelection(branches, headBranchName, previousSelection);
const { locked, when } = resolveBranchLockState(isolationEnabled, currentIsolation);
return {
id: BRANCH_OPTION_ID,
name: l10n.t('Branch'),
description: l10n.t('Pick Branch'),
items: locked ? branches.map(b => ({ ...b, locked: true })) : branches,
selected: selectedItem && locked ? { ...selectedItem, locked: true } : selectedItem,
when
};
}
/**
* Rebuild the branch group based on current selections.
* Called when any dropdown changes — we don't need to know which one.
*/
async handleInputStateChange(state: vscode.ChatSessionInputState): Promise<void> {
const currentIsolation = getSelectedOption(state.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined;
const currentRepoId = getSelectedOption(state.groups, REPOSITORY_OPTION_ID)?.id;
const previousBranchSelection = getSelectedOption(state.groups, BRANCH_OPTION_ID);
const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService);
// Persist the user's isolation choice so it's remembered across sessions
if (currentIsolation) {
void this.context.globalState.update(LAST_USED_ISOLATION_OPTION_KEY, currentIsolation);
}
// Remove existing branch group, rebuild from scratch
const groups = [...state.groups.filter(g => g.id !== BRANCH_OPTION_ID)];
if (currentRepoId && isBranchOptionFeatureEnabled(this.configurationService)) {
const repoUri = vscode.Uri.file(currentRepoId);
const repo = await this.gitService.getRepository(repoUri);
if (repo) {
let branches: vscode.ChatSessionProviderOptionItem[] = [];
try {
branches = await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName);
} catch {
// On failure, branches remain empty — dropdown will be hidden
}
const branchGroup = this.buildBranchOptionGroup(branches, repo.headBranchName, isolationEnabled, currentIsolation, previousBranchSelection);
if (branchGroup) {
groups.push(branchGroup);
}
}
}
state.groups = groups;
}
async buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptionGroup[]> {
const copilotcliSessionId = SessionIdForCLI.parse(resource);
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
const folderInfo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token);
const repositories = isWelcomeView(this.workspaceService) ? folderMRUToChatProviderOptions(await this.copilotCLIFolderMruService.getRecentlyUsedFolders(token)) : this.getRepositoryOptionItems();
const folderOrRepoId = folderInfo.repository?.fsPath ?? folderInfo.folder?.fsPath;
const existingItem = folderOrRepoId ? repositories.find(repo => repo.id === folderOrRepoId) : undefined;
const worktreeProperties = await this.chatSessionWorktreeService.getWorktreeProperties(copilotcliSessionId);
let repoSelected: vscode.ChatSessionProviderOptionItem;
if (existingItem) {
repoSelected = { ...existingItem, locked: true };
} else if (folderInfo.repository) {
repoSelected = { ...toRepositoryOptionItem(folderInfo.repository), locked: true };
} else if (folderInfo.folder) {
const folderName = this.workspaceService.getWorkspaceFolderName(folderInfo.folder) || basename(folderInfo.folder);
repoSelected = { ...toWorkspaceFolderOptionItem(folderInfo.folder, folderName), locked: true };
} else {
let folderName = l10n.t('Unknown');
if (this.workspaceService.getWorkspaceFolders().length === 1) {
folderName = this.workspaceService.getWorkspaceFolderName(this.workspaceService.getWorkspaceFolders()[0]) || folderName;
}
repoSelected = { id: '', name: folderName, icon: new vscode.ThemeIcon('folder'), locked: true };
}
if (isIsolationOptionFeatureEnabled(this.configurationService)) {
const isWorktree = !!worktreeProperties;
const isolationSelected = {
id: isWorktree ? IsolationMode.Worktree : IsolationMode.Workspace,
name: isWorktree ? l10n.t('Worktree') : l10n.t('Workspace'),
icon: new vscode.ThemeIcon(isWorktree ? 'worktree' : 'folder'),
locked: true
};
optionGroups.push({
id: ISOLATION_OPTION_ID,
name: l10n.t('Isolation'),
description: l10n.t('Pick Isolation Mode'),
items: [
{ id: IsolationMode.Workspace, name: l10n.t('Workspace'), icon: new vscode.ThemeIcon('folder') },
{ id: IsolationMode.Worktree, name: l10n.t('Worktree'), icon: new vscode.ThemeIcon('worktree') },
],
selected: isolationSelected
});
}
optionGroups.push({
id: REPOSITORY_OPTION_ID,
name: l10n.t('Folder'),
description: l10n.t('Pick Folder'),
items: [repoSelected],
selected: repoSelected,
commands: []
});
const branchName = worktreeProperties?.branchName ?? folderInfo.repositoryProperties?.branchName;
const branchSelected = branchName ? { id: branchName, name: branchName, icon: new vscode.ThemeIcon('git-branch'), locked: true } : undefined;
optionGroups.push({
id: BRANCH_OPTION_ID,
name: l10n.t('Branch'),
description: l10n.t('Pick Branch'),
items: branchSelected ? [branchSelected] : [],
selected: branchSelected,
when: worktreeProperties ? `chatSessionOption.${ISOLATION_OPTION_ID} == '${IsolationMode.Worktree}'` : undefined
});
return optionGroups;
}
async getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise<vscode.ChatSessionProviderOptionItem[]> {
const key = `${repoUri.toString()}|${headBranchName ?? ''}`;
return this._getBranchOptionItemsForRepositorySequencer.queue(key, async () => {
const refs = await this.gitService.getRefs(repoUri, { sort: 'committerdate' });
// Filter to local branches only (RefType.Head === 0)
const localBranches = refs.filter(ref => ref.type === 0 /* RefType.Head */ && ref.name);
// Build items with HEAD branch first
const items: vscode.ChatSessionProviderOptionItem[] = [];
let headItem: vscode.ChatSessionProviderOptionItem | undefined;
let mainOrheadBranch: vscode.ChatSessionProviderOptionItem | undefined;
for (const ref of localBranches) {
if (!ref.name) {
continue;
}
if (ref.name.includes(COPILOT_WORKTREE_PATTERN)) {
continue;
}
const isHead = ref.name === headBranchName;
const item: vscode.ChatSessionProviderOptionItem = {
id: ref.name!,
name: ref.name!,
icon: new vscode.ThemeIcon('git-branch'),
// default: isHead
};
if (isHead) {
headItem = item;
} else if (ref.name === 'main' || ref.name === 'master') {
mainOrheadBranch = item;
} else {
items.push(item);
}
}
if (mainOrheadBranch) {
items.unshift(mainOrheadBranch);
}
if (headItem) {
items.unshift(headItem);
}
return items;
});
}
getRepositoryOptionItems() {
// Exclude worktrees from the repository list
const repositories = this.gitService.repositories
.filter(repository => repository.kind !== 'worktree')
.filter(repository => {
if (isWelcomeView(this.workspaceService)) {
// In the welcome view, include all repositories from the MRU list
return true;
}
// Only include repositories that belong to one of the workspace folders
return this.workspaceService.getWorkspaceFolder(repository.rootUri) !== undefined;
});
const repoItems = repositories
.map(repository => toRepositoryOptionItem(repository));
// In multi-root workspaces, also include workspace folders that don't have any git repos
const workspaceFolders = this.workspaceService.getWorkspaceFolders();
if (workspaceFolders.length) {
// Find workspace folders that contain git repos
const foldersWithRepos = new Set<string>();
for (const repo of repositories) {
const folder = this.workspaceService.getWorkspaceFolder(repo.rootUri);
if (folder) {
foldersWithRepos.add(folder.fsPath);
}
}
// Add workspace folders that don't have any git repos
for (const folder of workspaceFolders) {
if (!foldersWithRepos.has(folder.fsPath)) {
const folderName = this.workspaceService.getWorkspaceFolderName(folder);
repoItems.push(toWorkspaceFolderOptionItem(folder, folderName));
}
}
}
return repoItems.sort((a, b) => a.name.localeCompare(b.name));
}
/**
* After a folder is selected via "Browse folders..." command,
* update the repo group's selected item and rebuild the branch group.
*/
async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise<void> {
const repo = await this.gitService.getRepository(folderUri, true);
// Possible the user didn't trust this folder. In that case, we shouldn't be using this folder.
if (!repo && !(await vscode.workspace.isResourceTrusted(folderUri))) {
return;
}
// Update MRU tracking for untitled workspaces
if (isWelcomeView(this.workspaceService)) {
if (repo) {
this._lastUsedFolderIdInUntitledWorkspace = { kind: 'repo', uri: repo.rootUri, lastAccessed: Date.now() };
} else {
this._lastUsedFolderIdInUntitledWorkspace = { kind: 'folder', uri: folderUri, lastAccessed: Date.now() };
}
}
const repoItem = repo
? toRepositoryOptionItem(repo.rootUri)
: toWorkspaceFolderOptionItem(folderUri, folderUri.path.split('/').pop() ?? folderUri.fsPath);
// Update repo group's selected item
const groups = [...inputState.groups];
const repoGroupIdx = groups.findIndex(g => g.id === REPOSITORY_OPTION_ID);
if (repoGroupIdx !== -1) {
const repoGroup = groups[repoGroupIdx];
const items = repoGroup.items.find(i => i.id === repoItem.id)
? [...repoGroup.items]
: [repoItem, ...repoGroup.items];
groups[repoGroupIdx] = { ...repoGroup, items, selected: repoItem };
}
// Remove existing branch group, rebuild
const previousBranchSelection = getSelectedOption(inputState.groups, BRANCH_OPTION_ID);
const branchIdx = groups.findIndex(g => g.id === BRANCH_OPTION_ID);
if (branchIdx !== -1) {
groups.splice(branchIdx, 1);
}
if (repo && isBranchOptionFeatureEnabled(this.configurationService)) {
let branches: vscode.ChatSessionProviderOptionItem[] = [];
try {
branches = await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName);
} catch {
// branches remain empty
}
const isolationEnabled = isIsolationOptionFeatureEnabled(this.configurationService);
const currentIsolation = getSelectedOption(inputState.groups, ISOLATION_OPTION_ID)?.id as IsolationMode | undefined;
// Preserve previous branch selection if the same branch exists in the new repo
const branchGroup = this.buildBranchOptionGroup(branches, repo.headBranchName, isolationEnabled, currentIsolation, previousBranchSelection);
if (branchGroup) {
groups.push(branchGroup);
}
}
inputState.groups = groups;
}
}

View File

@@ -0,0 +1,129 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import * as vscode from 'vscode';
import { createServiceIdentifier } from '../../../util/common/services';
import { Disposable } from '../../../util/vs/base/common/lifecycle';
import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService';
import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService';
import { getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo';
import { IPullRequestDetectionService } from './pullRequestDetectionService';
export interface ISessionRequestLifecycle {
readonly _serviceBrand: undefined;
/**
* Begin tracking a request for a session. Creates a baseline checkpoint
* if this is the first request in the session.
*/
startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean): Promise<void>;
/**
* Finalize a request: commit worktree changes, create checkpoints, detect
* pull requests, and remove the request from tracking. Defers completion
* work until the last in-flight request for a session completes (to support
* steering).
*/
endRequest(sessionId: string, request: vscode.ChatRequest, session: SessionCompletionInfo, token: vscode.CancellationToken): Promise<void>;
}
export interface SessionCompletionInfo {
readonly status: vscode.ChatSessionStatus | undefined;
readonly workspace: IWorkspaceInfo;
readonly createdPullRequestUrl: string | undefined;
}
export const ISessionRequestLifecycle = createServiceIdentifier<ISessionRequestLifecycle>('ISessionRequestLifecycle');
export class SessionRequestLifecycle extends Disposable implements ISessionRequestLifecycle {
declare readonly _serviceBrand: undefined;
/**
* Tracks in-flight requests per session so we can coordinate worktree
* commit / PR handling and cleanup.
*
* We generally cannot have parallel requests for the same session, but when
* steering is involved there can be multiple requests in flight for a
* single session (the original request continues running while steering
* requests are processed). This map records all active requests for each
* session so that any worktree-related actions are deferred until the last
* in-flight request for that session has completed.
*/
private readonly pendingRequestBySession = new Map<string, Set<vscode.ChatRequest>>();
constructor(
@IChatSessionWorktreeService private readonly worktreeService: IChatSessionWorktreeService,
@IChatSessionWorktreeCheckpointService private readonly checkpointService: IChatSessionWorktreeCheckpointService,
@IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService,
@IPullRequestDetectionService private readonly prDetectionService: IPullRequestDetectionService,
) {
super();
}
async startRequest(sessionId: string, request: vscode.ChatRequest, isFirstRequest: boolean): Promise<void> {
if (isFirstRequest) {
await this.checkpointService.handleRequest(sessionId);
}
const requests = this.pendingRequestBySession.get(sessionId) ?? new Set<vscode.ChatRequest>();
requests.add(request);
this.pendingRequestBySession.set(sessionId, requests);
}
async endRequest(sessionId: string, request: vscode.ChatRequest, session: SessionCompletionInfo, token: vscode.CancellationToken): Promise<void> {
const pendingRequests = this.pendingRequestBySession.get(sessionId);
if (pendingRequests && pendingRequests.size > 1) {
// We still have pending requests for this session, which means the user has done some steering.
// Wait for all requests to complete, the last request to complete will handle the commit.
pendingRequests.delete(request);
return;
}
if (token.isCancellationRequested) {
this.untrackRequest(sessionId, request);
return;
}
try {
if (session.status === vscode.ChatSessionStatus.Completed) {
const workingDirectory = getWorkingDirectory(session.workspace);
if (isIsolationEnabled(session.workspace)) {
// When isolation is enabled and we are using a git worktree, so we commit
// all the changes in the worktree directory when the session is completed.
// Note that if the worktree supports checkpoints, then the commit will be
// done in the checkpoint so that users can easily see the changes made in
// the worktree and also revert back if needed.
await this.worktreeService.handleRequestCompleted(sessionId);
} else if (workingDirectory) {
// When isolation is not enabled, we are operating in the workspace directly,
// so we stage all the changes in the workspace directory when the session is
// completed
await this.workspaceFolderService.handleRequestCompleted(sessionId);
}
// Create checkpoint - we create a checkpoint for the worktree changes so that users
// can easily see the changes made in the worktree and also revert back if needed. This
// is used if worktree isolation is enabled, and auto-commit is disabled or workspace
// isolation is enabled.
await this.checkpointService.handleRequestCompleted(sessionId, request.id);
}
this.prDetectionService.handlePullRequestCreated(sessionId, session.createdPullRequestUrl);
} finally {
this.untrackRequest(sessionId, request);
}
}
private untrackRequest(sessionId: string, request: vscode.ChatRequest): void {
const requests = this.pendingRequestBySession.get(sessionId);
if (requests) {
requests.delete(request);
if (requests.size === 0) {
this.pendingRequestBySession.delete(sessionId);
}
}
}
}

View File

@@ -0,0 +1,671 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { SweCustomAgent } from '@github/copilot/sdk';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as vscode from 'vscode';
import { IConfigurationService } from '../../../../platform/configuration/common/configurationService';
import { ILogService } from '../../../../platform/log/common/logService';
import { IPromptsService } from '../../../../platform/promptFiles/common/promptsService';
import { IWorkspaceService, NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { mock } from '../../../../util/common/test/simpleMock';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { DisposableStore, IReference } from '../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../util/vs/base/common/uri';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
import { FolderRepositoryInfo, IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';
import { IWorkspaceInfo } from '../../common/workspaceInfo';
import { ICopilotCLIAgents, ICopilotCLIModels } from '../../copilotcli/node/copilotCli';
import { ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';
import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
import { CopilotCLIChatSessionInitializer } from '../copilotCLIChatSessionInitializer';
// ─── Test Helpers ────────────────────────────────────────────────
class TestSessionService extends mock<ICopilotCLISessionService>() {
declare readonly _serviceBrand: undefined;
override isNewSessionId = vi.fn(() => true);
override createSession = vi.fn(async (): Promise<IReference<ICopilotCLISession>> => ({
object: makeSessionObject(),
dispose: vi.fn(),
}));
override getSession = vi.fn(async (): Promise<IReference<ICopilotCLISession> | undefined> => ({
object: makeSessionObject(),
dispose: vi.fn(),
}));
}
class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {
declare readonly _serviceBrand: undefined;
override initializeFolderRepository = vi.fn(async (): Promise<FolderRepositoryInfo> => ({
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
trusted: true,
}));
override getFolderRepository = vi.fn(async (): Promise<FolderRepositoryInfo> => ({
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
trusted: true,
}));
}
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
declare readonly _serviceBrand: undefined;
override setWorktreeProperties = vi.fn(async () => { });
}
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
declare readonly _serviceBrand: undefined;
override trackSessionWorkspaceFolder = vi.fn(async () => { });
}
class TestModels extends mock<ICopilotCLIModels>() {
declare readonly _serviceBrand: undefined;
override resolveModel = vi.fn(async (id: string) => id === 'known-model' ? 'resolved-model' : undefined);
override getDefaultModel = vi.fn(async () => 'default-model');
}
class TestAgents extends mock<ICopilotCLIAgents>() {
declare readonly _serviceBrand: undefined;
override resolveAgent = vi.fn(async (): Promise<SweCustomAgent | undefined> => undefined);
}
class TestPromptsService extends mock<IPromptsService>() {
declare readonly _serviceBrand: undefined;
override parseFile = vi.fn(async () => ({ uri: URI.file('/test.prompt'), header: undefined, body: undefined }));
}
class TestMetadataStore extends mock<IChatSessionMetadataStore>() {
declare readonly _serviceBrand: undefined;
override updateRequestDetails = vi.fn(async () => { });
}
class TestConfigurationService extends mock<IConfigurationService>() {
declare readonly _serviceBrand: undefined;
override getConfig = vi.fn(() => undefined as any);
}
class TestLogService extends mock<ILogService>() {
declare readonly _serviceBrand: undefined;
override trace = vi.fn();
override debug = vi.fn();
override info = vi.fn();
override error = vi.fn();
}
function makeSessionObject(overrides?: Partial<ICopilotCLISession>): ICopilotCLISession {
return {
sessionId: 'test-session-id',
workspace: {
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
},
attachStream: vi.fn(() => ({ dispose: vi.fn() })),
setPermissionLevel: vi.fn(),
dispose: vi.fn(),
...overrides,
} as unknown as ICopilotCLISession;
}
function makeRequest(overrides?: Partial<vscode.ChatRequest>): vscode.ChatRequest {
return {
id: 'request-1',
prompt: 'hello',
model: { id: 'known-model' },
references: [],
tools: [],
toolInvocationToken: {} as vscode.ChatParticipantToolToken,
permissionLevel: 'full',
modeInstructions2: undefined,
...overrides,
} as unknown as vscode.ChatRequest;
}
function makeStream(): vscode.ChatResponseStream {
return {
warning: vi.fn(),
markdown: vi.fn(),
} as unknown as vscode.ChatResponseStream;
}
function makeChatSessionContext(sessionId: string = 'untitled:new-session', initialOptions?: { optionId: string; value: string }[]): vscode.ChatSessionContext {
return {
chatSessionItem: {
resource: URI.from({ scheme: 'copilotcli', path: `/${sessionId}` }) as unknown as vscode.Uri,
label: 'Test',
},
initialSessionOptions: initialOptions ?? [],
} as unknown as vscode.ChatSessionContext;
}
function createInitializer(overrides?: {
sessionService?: TestSessionService;
folderRepoManager?: TestFolderRepositoryManager;
worktreeService?: TestWorktreeService;
workspaceFolderService?: TestWorkspaceFolderService;
workspaceService?: IWorkspaceService;
models?: TestModels;
agents?: TestAgents;
promptsService?: TestPromptsService;
metadataStore?: TestMetadataStore;
logService?: TestLogService;
configurationService?: TestConfigurationService;
}) {
const sessionService = overrides?.sessionService ?? new TestSessionService();
const folderRepoManager = overrides?.folderRepoManager ?? new TestFolderRepositoryManager();
const worktreeService = overrides?.worktreeService ?? new TestWorktreeService();
const workspaceFolderService = overrides?.workspaceFolderService ?? new TestWorkspaceFolderService();
const workspaceService = overrides?.workspaceService ?? new NullWorkspaceService([URI.file('/workspace')]);
const models = overrides?.models ?? new TestModels();
const agents = overrides?.agents ?? new TestAgents();
const promptsService = overrides?.promptsService ?? new TestPromptsService();
const metadataStore = overrides?.metadataStore ?? new TestMetadataStore();
const logService = overrides?.logService ?? new TestLogService();
const configurationService = overrides?.configurationService ?? new TestConfigurationService();
const initializer = new CopilotCLIChatSessionInitializer(
sessionService,
folderRepoManager,
worktreeService,
workspaceFolderService,
workspaceService,
models,
agents,
promptsService,
metadataStore,
logService,
configurationService,
);
return { initializer, sessionService, folderRepoManager, worktreeService, workspaceFolderService, models, agents, promptsService, metadataStore, logService, configurationService };
}
// ─── Tests ───────────────────────────────────────────────────────
describe('ChatSessionInitializer', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
describe('resolveModelId', () => {
it('returns resolved model from request.model.id', async () => {
const { initializer } = createInitializer();
const request = makeRequest({ model: { id: 'known-model' } } as Partial<vscode.ChatRequest>);
const result = await initializer.resolveModel(request, CancellationToken.None);
expect(result).toEqual(expect.objectContaining({ model: 'resolved-model' }));
});
it('falls back to default model when request model is not resolvable', async () => {
const { initializer } = createInitializer();
const request = makeRequest({ model: { id: 'unknown-model' } } as Partial<vscode.ChatRequest>);
const result = await initializer.resolveModel(request, CancellationToken.None);
expect(result).toEqual(expect.objectContaining({ model: 'default-model' }));
});
it('returns default model when request is undefined', async () => {
const { initializer } = createInitializer();
const result = await initializer.resolveModel(undefined, CancellationToken.None);
expect(result).toEqual(expect.objectContaining({ model: 'default-model' }));
});
it('returns default model when request has no model', async () => {
const { initializer } = createInitializer();
const request = makeRequest({ model: undefined } as Partial<vscode.ChatRequest>);
const result = await initializer.resolveModel(request, CancellationToken.None);
expect(result).toEqual(expect.objectContaining({ model: 'default-model' }));
});
});
describe('resolveAgent', () => {
it('returns undefined when request has no modeInstructions2', async () => {
const { initializer } = createInitializer();
const request = makeRequest({ modeInstructions2: undefined } as Partial<vscode.ChatRequest>);
const result = await initializer.resolveAgent(request, CancellationToken.None);
expect(result).toBeUndefined();
});
it('returns undefined when request is undefined', async () => {
const { initializer } = createInitializer();
const result = await initializer.resolveAgent(undefined, CancellationToken.None);
expect(result).toBeUndefined();
});
it('resolves agent by URI when modeInstructions2 has uri', async () => {
const agents = new TestAgents();
const fakeAgent = { name: 'test-agent' } as SweCustomAgent;
agents.resolveAgent.mockResolvedValue(fakeAgent);
const { initializer } = createInitializer({ agents });
const request = makeRequest({
modeInstructions2: {
uri: URI.file('/agent.md') as unknown as vscode.Uri,
name: 'test-agent',
content: '',
toolReferences: [],
},
} as Partial<vscode.ChatRequest>);
const result = await initializer.resolveAgent(request, CancellationToken.None);
expect(result).toBe(fakeAgent);
expect(agents.resolveAgent).toHaveBeenCalledWith(URI.file('/agent.md').toString());
});
it('resolves agent by name when modeInstructions2 has no uri', async () => {
const agents = new TestAgents();
const fakeAgent = { name: 'test-agent' } as SweCustomAgent;
agents.resolveAgent.mockResolvedValue(fakeAgent);
const { initializer } = createInitializer({ agents });
const request = makeRequest({
modeInstructions2: {
uri: undefined,
name: 'test-agent',
content: '',
toolReferences: [],
},
} as Partial<vscode.ChatRequest>);
const result = await initializer.resolveAgent(request, CancellationToken.None);
expect(result).toBe(fakeAgent);
expect(agents.resolveAgent).toHaveBeenCalledWith('test-agent');
});
it('overrides agent tools when modeInstructions2 provides toolReferences', async () => {
const agents = new TestAgents();
const fakeAgent = { name: 'test-agent', tools: [] } as unknown as SweCustomAgent;
agents.resolveAgent.mockResolvedValue(fakeAgent);
const { initializer } = createInitializer({ agents });
const request = makeRequest({
modeInstructions2: {
uri: undefined,
name: 'test-agent',
content: '',
toolReferences: [{ name: 'tool-a' }, { name: 'tool-b' }],
},
} as Partial<vscode.ChatRequest>);
const result = await initializer.resolveAgent(request, CancellationToken.None);
expect(result!.tools).toEqual(['tool-a', 'tool-b']);
});
it('returns undefined when agent cannot be resolved', async () => {
const agents = new TestAgents();
agents.resolveAgent.mockResolvedValue(undefined);
const { initializer } = createInitializer({ agents });
const request = makeRequest({
modeInstructions2: {
uri: undefined,
name: 'unknown-agent',
content: '',
toolReferences: [],
},
} as Partial<vscode.ChatRequest>);
const result = await initializer.resolveAgent(request, CancellationToken.None);
expect(result).toBeUndefined();
});
});
describe('initializeWorkingDirectory', () => {
it('initializes folder for new session with chat session context', async () => {
const sessionService = new TestSessionService();
sessionService.isNewSessionId.mockReturnValue(true);
const { initializer, folderRepoManager } = createInitializer({ sessionService });
const context = makeChatSessionContext('untitled:new');
const result = await initializer.initializeWorkingDirectory(
context, undefined, undefined, makeStream(),
{} as vscode.ChatParticipantToolToken, CancellationToken.None
);
expect(result.cancelled).toBe(false);
expect(result.trusted).toBe(true);
expect(result.workspaceInfo.folder).toBeDefined();
expect(folderRepoManager.initializeFolderRepository).toHaveBeenCalled();
});
it('gets existing folder for non-new session', async () => {
const sessionService = new TestSessionService();
sessionService.isNewSessionId.mockReturnValue(false);
const { initializer, folderRepoManager } = createInitializer({ sessionService });
const context = makeChatSessionContext('existing-session');
const result = await initializer.initializeWorkingDirectory(
context, undefined, undefined, makeStream(),
{} as vscode.ChatParticipantToolToken, CancellationToken.None
);
expect(result.cancelled).toBe(false);
expect(folderRepoManager.getFolderRepository).toHaveBeenCalled();
});
it('initializes with active repository when no chat session context', async () => {
const { initializer, folderRepoManager } = createInitializer();
const result = await initializer.initializeWorkingDirectory(
undefined, undefined, undefined, makeStream(),
{} as vscode.ChatParticipantToolToken, CancellationToken.None
);
expect(result.cancelled).toBe(false);
expect(folderRepoManager.initializeFolderRepository).toHaveBeenCalledWith(
undefined, expect.anything(), expect.anything()
);
});
it('returns cancelled when trust is denied', async () => {
const folderRepoManager = new TestFolderRepositoryManager();
folderRepoManager.initializeFolderRepository.mockResolvedValue({
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
trusted: false,
});
const { initializer } = createInitializer({ folderRepoManager });
const result = await initializer.initializeWorkingDirectory(
undefined, undefined, undefined, makeStream(),
{} as vscode.ChatParticipantToolToken, CancellationToken.None
);
expect(result.cancelled).toBe(true);
expect(result.trusted).toBe(false);
});
it('returns cancelled when user cancels', async () => {
const folderRepoManager = new TestFolderRepositoryManager();
folderRepoManager.initializeFolderRepository.mockResolvedValue({
folder: undefined,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
trusted: true,
cancelled: true,
});
const { initializer } = createInitializer({ folderRepoManager });
const result = await initializer.initializeWorkingDirectory(
undefined, undefined, undefined, makeStream(),
{} as vscode.ChatParticipantToolToken, CancellationToken.None
);
expect(result.cancelled).toBe(true);
expect(result.trusted).toBe(true);
});
it('parses session options from chat session context', async () => {
const sessionService = new TestSessionService();
sessionService.isNewSessionId.mockReturnValue(true);
const { initializer, folderRepoManager } = createInitializer({ sessionService });
const options = [
{ optionId: 'repository', value: '/selected-repo' },
{ optionId: 'branch', value: 'feature-branch' },
{ optionId: 'isolation', value: IsolationMode.Worktree },
];
const context = makeChatSessionContext('untitled:new', options);
await initializer.initializeWorkingDirectory(
context, undefined, undefined, makeStream(),
{} as vscode.ChatParticipantToolToken, CancellationToken.None
);
expect(folderRepoManager.initializeFolderRepository).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
branch: 'feature-branch',
isolation: IsolationMode.Worktree,
}),
expect.anything()
);
});
});
describe('getOrCreateSession', () => {
it('creates new session and attaches stream', async () => {
const { initializer, sessionService } = createInitializer();
sessionService.isNewSessionId.mockReturnValue(true);
const disposables = new DisposableStore();
const stream = makeStream();
const result = await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext(), stream,
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(result.session).toBeDefined();
expect(result.isNewSession).toBe(true);
expect(result.model).toEqual(expect.objectContaining({ model: 'resolved-model' }));
expect(result.trusted).toBe(true);
expect(sessionService.createSession).toHaveBeenCalled();
expect(result.session!.object.attachStream).toHaveBeenCalledWith(stream);
expect(result.session!.object.setPermissionLevel).toHaveBeenCalled();
disposables.dispose();
});
it('gets existing session for non-new session ID', async () => {
const { initializer, sessionService } = createInitializer();
sessionService.isNewSessionId.mockReturnValue(false);
const disposables = new DisposableStore();
const result = await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext('existing-session'), makeStream(),
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(result.session).toBeDefined();
expect(result.isNewSession).toBe(false);
expect(sessionService.getSession).toHaveBeenCalled();
expect(sessionService.createSession).not.toHaveBeenCalled();
disposables.dispose();
});
it('returns undefined session when working directory init is cancelled', async () => {
const folderRepoManager = new TestFolderRepositoryManager();
folderRepoManager.initializeFolderRepository.mockResolvedValue({
folder: undefined, repository: undefined, repositoryProperties: undefined,
worktree: undefined, worktreeProperties: undefined,
trusted: false,
});
const { initializer } = createInitializer({ folderRepoManager });
const disposables = new DisposableStore();
const result = await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext(), makeStream(),
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(result.session).toBeUndefined();
expect(result.trusted).toBe(false);
disposables.dispose();
});
it('returns undefined session when session service returns undefined', async () => {
const sessionService = new TestSessionService();
sessionService.isNewSessionId.mockReturnValue(false);
sessionService.getSession.mockResolvedValue(undefined);
const { initializer } = createInitializer({ sessionService });
const disposables = new DisposableStore();
const stream = makeStream();
const result = await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext('missing'), stream,
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(result.session).toBeUndefined();
expect(stream.warning).toHaveBeenCalled();
disposables.dispose();
});
it('sets worktree properties for new session with worktree', async () => {
const sessionService = new TestSessionService();
sessionService.isNewSessionId.mockReturnValue(true);
const folderRepoManager = new TestFolderRepositoryManager();
folderRepoManager.initializeFolderRepository.mockResolvedValue({
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: URI.file('/repo') as unknown as vscode.Uri,
repositoryProperties: undefined,
worktree: URI.file('/worktree') as unknown as vscode.Uri,
worktreeProperties: {
version: 2,
baseCommit: 'abc',
baseBranchName: 'main',
branchName: 'copilot/test',
repositoryPath: '/repo',
worktreePath: '/worktree',
},
trusted: true,
});
const { initializer, worktreeService } = createInitializer({ sessionService, folderRepoManager });
const disposables = new DisposableStore();
await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext(), makeStream(),
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
'test-session-id',
expect.objectContaining({ branchName: 'copilot/test' })
);
disposables.dispose();
});
it('tracks workspace folder for new non-isolated session', async () => {
const sessionService = new TestSessionService();
sessionService.isNewSessionId.mockReturnValue(true);
const { initializer, workspaceFolderService } = createInitializer({ sessionService });
const disposables = new DisposableStore();
await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext(), makeStream(),
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();
disposables.dispose();
});
it('records request metadata', async () => {
const { initializer, metadataStore } = createInitializer();
const disposables = new DisposableStore();
await initializer.getOrCreateSession(
makeRequest(), makeChatSessionContext(), makeStream(),
{ branchName: Promise.resolve(undefined) },
disposables, CancellationToken.None
);
expect(metadataStore.updateRequestDetails).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([
expect.objectContaining({ vscodeRequestId: 'request-1' })
])
);
disposables.dispose();
});
});
describe('createDelegatedSession', () => {
it('creates session and finalizes', async () => {
const { initializer, sessionService, workspaceFolderService, metadataStore } = createInitializer();
const workspace: IWorkspaceInfo = {
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
};
const result = await initializer.createDelegatedSession(
makeRequest(), workspace, { mcpServerMappings: new Map() },
CancellationToken.None
);
expect(result.session).toBeDefined();
expect(result.model).toEqual(expect.objectContaining({ model: 'resolved-model' }));
expect(sessionService.createSession).toHaveBeenCalled();
expect(workspaceFolderService.trackSessionWorkspaceFolder).toHaveBeenCalled();
expect(metadataStore.updateRequestDetails).toHaveBeenCalled();
});
it('sets worktree properties when workspace has worktree', async () => {
const { initializer, worktreeService } = createInitializer();
const workspace: IWorkspaceInfo = {
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: URI.file('/repo') as unknown as vscode.Uri,
repositoryProperties: undefined,
worktree: URI.file('/worktree') as unknown as vscode.Uri,
worktreeProperties: {
version: 2,
baseCommit: 'abc',
baseBranchName: 'main',
branchName: 'copilot/test',
repositoryPath: '/repo',
worktreePath: '/worktree',
},
};
await initializer.createDelegatedSession(
makeRequest(), workspace, { mcpServerMappings: new Map() },
CancellationToken.None
);
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
'test-session-id',
expect.objectContaining({ branchName: 'copilot/test' })
);
});
it('does not track workspace folder for isolated session', async () => {
const { initializer, workspaceFolderService } = createInitializer();
const workspace: IWorkspaceInfo = {
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: URI.file('/repo') as unknown as vscode.Uri,
repositoryProperties: undefined,
worktree: URI.file('/worktree') as unknown as vscode.Uri,
worktreeProperties: {
version: 2,
baseCommit: 'abc',
baseBranchName: 'main',
branchName: 'copilot/test',
repositoryPath: '/repo',
worktreePath: '/worktree',
},
};
await initializer.createDelegatedSession(
makeRequest(), workspace, { mcpServerMappings: new Map() },
CancellationToken.None
);
// Isolated session (has worktreeProperties) should NOT track workspace folder
expect(workspaceFolderService.trackSessionWorkspaceFolder).not.toHaveBeenCalled();
});
});
});

View File

@@ -55,7 +55,7 @@ import { IQuestion, IQuestionAnswer, IUserQuestionHandler } from '../../copilotc
import { CustomSessionTitleService } from '../../copilotcli/vscode-node/customSessionTitleServiceImpl';
import { MockChatPromptFileService } from '../../copilotcli/vscode-node/test/testHelpers';
import { CopilotCLIChatSessionContentProvider, CopilotCLIChatSessionItemProvider, CopilotCLIChatSessionParticipant } from '../copilotCLIChatSessionsContribution';
import { ICopilotCLIFolderMruService } from '../copilotCLIFolderMru';
import { IChatFolderMruService } from '../../common/folderRepositoryManager';
import { CopilotCloudSessionsProvider } from '../copilotCloudSessionsProvider';
import { CopilotCLIFolderRepositoryManager } from '../folderRepositoryManagerImpl';
@@ -751,7 +751,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => {
customSessionTitleService,
new MockExtensionContext() as unknown as IVSCodeExtensionContext,
logService,
new (mock<ICopilotCLIFolderMruService>())(),
new (mock<IChatFolderMruService>())(),
);
const invalidParticipant = new CopilotCLIChatSessionParticipant(
invalidContentProvider,

View File

@@ -10,31 +10,26 @@ import * as vscodeShim from 'vscode';
import { IRunCommandExecutionService } from '../../../../platform/commands/common/runCommandExecutionService';
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
import { PullRequestSearchItem } from '../../../../platform/github/common/githubAPI';
import { IOctoKitService } from '../../../../platform/github/common/githubService';
import { ILogService } from '../../../../platform/log/common/logService';
import { MockExtensionContext } from '../../../../platform/test/node/extensionContext';
import { IWorkspaceService, NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { mock } from '../../../../util/common/test/simpleMock';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { Event } from '../../../../util/vs/base/common/event';
import { Disposable } from '../../../../util/vs/base/common/lifecycle';
import { URI } from '../../../../util/vs/base/common/uri';
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
import { IChatSessionMetadataStore } from '../../common/chatSessionMetadataStore';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
import { IFolderRepositoryManager } from '../../common/folderRepositoryManager';
import { IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';
import { emptyWorkspaceInfo } from '../../common/workspaceInfo';
import { ICustomSessionTitleService } from '../../copilotcli/common/customSessionTitleService';
import { ICopilotCLISession } from '../../copilotcli/node/copilotcliSession';
import { ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../../copilotcli/node/copilotcliSessionService';
import { ICopilotCLISessionTracker } from '../../copilotcli/vscode-node/copilotCLISessionTracker';
import { CopilotCLIChatSessionContentProvider } from '../copilotCLIChatSessions';
import { ICopilotCLIFolderMruService } from '../copilotCLIFolderMru';
import { ICopilotCLITerminalIntegration } from '../copilotCLITerminalIntegration';
import { CopilotCLIChatSessionContentProvider, resolveBranchLockState, resolveBranchSelection, resolveIsolationSelection, resolveSessionDirsForTerminal } from '../copilotCLIChatSessions';
import { PullRequestDetectionService } from '../pullRequestDetectionService';
import { ISessionOptionGroupBuilder } from '../sessionOptionGroupBuilder';
vi.mock('../copilotCLIShim.ps1', () => ({ default: '# mock powershell script' }));
beforeAll(() => {
@@ -53,6 +48,12 @@ beforeAll(() => {
dispose: () => { },
}),
};
(vscodeShim as Record<string, unknown>).workspace = {
...((vscodeShim as Record<string, unknown>).workspace as object),
workspaceFolders: [],
isAgentSessionsWorkspace: false,
isResourceTrusted: async () => true,
};
});
class TestSessionService extends mock<ICopilotCLISessionService>() {
@@ -63,7 +64,7 @@ class TestSessionService extends mock<ICopilotCLISessionService>() {
override onDidCreateSession = Event.None;
override getSessionWorkingDirectory = vi.fn(() => undefined);
override getSessionItem = vi.fn(async () => undefined);
override getAllSessions = vi.fn(async () => []);
override getAllSessions = vi.fn(async () => [] as ICopilotCLISessionItem[]);
override createNewSessionId = vi.fn(() => 'new-session');
override isNewSessionId = vi.fn(() => false);
override deleteSession = vi.fn(async () => { });
@@ -138,19 +139,6 @@ class TestOctoKitService extends mock<IOctoKitService>() {
override findPullRequestByHeadBranch = vi.fn(async (): Promise<PullRequestSearchItem | undefined> => undefined);
}
class TestSessionTracker extends mock<ICopilotCLISessionTracker>() {
declare readonly _serviceBrand: undefined;
override getSessionIds = vi.fn(() => []);
override getTerminal = vi.fn(async () => undefined);
}
class TestTerminalIntegration extends Disposable implements ICopilotCLITerminalIntegration {
declare readonly _serviceBrand: undefined;
openTerminal = vi.fn(async () => undefined);
setTerminalSessionDir = vi.fn();
setSessionDirResolver = vi.fn();
}
class TestRunCommandExecutionService extends mock<IRunCommandExecutionService>() {
declare readonly _serviceBrand: undefined;
override executeCommand = vi.fn(async () => undefined);
@@ -166,15 +154,14 @@ class TestCustomSessionTitleService extends mock<ICustomSessionTitleService>() {
function createProvider() {
const sessionService = new TestSessionService();
const worktreeService = new TestWorktreeService();
const workspaceService = new NullWorkspaceService([URI.file('/workspace')]);
const metadataStore = new class extends mock<IChatSessionMetadataStore>() { };
const metadataStore = new class extends mock<IChatSessionMetadataStore>() {
override getRequestDetails = vi.fn(async () => []);
override getRepositoryProperties = vi.fn(async () => undefined);
};
const gitService = new TestGitService();
const folderRepositoryManager = new TestFolderRepositoryManager();
const configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
const customSessionTitleService = new TestCustomSessionTitleService();
const context = new MockExtensionContext() as unknown as IVSCodeExtensionContext;
const sessionTracker = new TestSessionTracker();
const terminalIntegration = new TestTerminalIntegration();
const commandExecutionService = new TestRunCommandExecutionService();
const workspaceFolderService = new TestWorkspaceFolderService();
const octoKitService = new TestOctoKitService();
@@ -185,28 +172,40 @@ function createProvider() {
override error = vi.fn();
}();
const prDetectionService = new PullRequestDetectionService(
worktreeService,
gitService,
octoKitService,
logService,
);
const optionGroupBuilder = new class extends mock<ISessionOptionGroupBuilder>() {
declare readonly _serviceBrand: undefined;
override provideChatSessionProviderOptionGroups = vi.fn(async () => []);
override buildBranchOptionGroup = vi.fn(() => undefined);
override handleInputStateChange = vi.fn(async () => { });
override buildExistingSessionInputStateGroups = vi.fn(async () => []);
override getBranchOptionItemsForRepository = vi.fn(async () => []);
override getRepositoryOptionItems = vi.fn(() => []);
override updateInputStateAfterFolderSelection = vi.fn(async () => { });
}();
const provider = new CopilotCLIChatSessionContentProvider(
sessionService,
metadataStore,
worktreeService,
workspaceService as IWorkspaceService,
gitService,
folderRepositoryManager,
configurationService,
customSessionTitleService,
context,
sessionTracker,
terminalIntegration,
commandExecutionService,
workspaceFolderService,
octoKitService,
logService,
new class extends mock<IAgentSessionsWorkspace>() { override get isAgentSessionsWorkspace() { return false; } },
new (mock<ICopilotCLIFolderMruService>())(),
prDetectionService,
optionGroupBuilder,
gitService,
workspaceFolderService,
metadataStore,
);
return {
provider,
prDetectionService,
sessionService,
worktreeService,
gitService,
@@ -220,8 +219,8 @@ describe('CopilotCLIChatSessionContentProvider', () => {
});
it('triggers pull request detection when opening an existing session', async () => {
const { provider } = createProvider();
const detectSpy = vi.spyOn(provider, 'detectPullRequestOnSessionOpen').mockResolvedValue();
const { provider, prDetectionService } = createProvider();
const detectSpy = vi.spyOn(prDetectionService, 'detectPullRequest');
await provider.provideChatSessionContentForExistingSession(
URI.from({ scheme: 'copilotcli', path: '/session-1' }),
@@ -232,8 +231,7 @@ describe('CopilotCLIChatSessionContentProvider', () => {
});
it('persists detected pull request url and state on session open', async () => {
const { provider, worktreeService, gitService, octoKitService } = createProvider();
const refreshSpy = vi.spyOn(provider, 'refreshSession').mockResolvedValue();
const { prDetectionService, worktreeService, gitService, octoKitService } = createProvider();
const worktreeProperties: ChatSessionWorktreeProperties = {
version: 2,
baseCommit: 'abc123',
@@ -271,20 +269,19 @@ describe('CopilotCLIChatSessionContentProvider', () => {
body: '',
});
await provider.detectPullRequestOnSessionOpen('session-1');
prDetectionService.detectPullRequest('session-1');
expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
'session-1',
expect.objectContaining({
pullRequestUrl: 'https://github.com/testowner/testrepo/pull/42',
pullRequestState: 'open',
}),
);
expect(refreshSpy).toHaveBeenCalledWith({ reason: 'update', sessionId: 'session-1' });
));
});
it('skips session-open detection for merged pull requests', async () => {
const { provider, worktreeService, octoKitService } = createProvider();
const { prDetectionService, worktreeService, octoKitService } = createProvider();
const mergedProperties: ChatSessionWorktreeProperties = {
version: 2,
baseCommit: 'abc123',
@@ -297,9 +294,111 @@ describe('CopilotCLIChatSessionContentProvider', () => {
worktreeService.getWorktreeProperties.mockResolvedValue(mergedProperties);
await provider.detectPullRequestOnSessionOpen('session-1');
prDetectionService.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
});
});
// ─── Re-exported helper function smoke tests ────────────────────
// Full test coverage lives in sessionOptionGroupBuilder.spec.ts;
// these just verify the re-exports are wired up correctly.
describe('re-exported dropdown helpers', () => {
it('resolveBranchSelection is callable', () => {
const branches = [{ id: 'main', name: 'main' }];
expect(resolveBranchSelection(branches, 'main', undefined)?.id).toBe('main');
});
it('resolveBranchLockState is callable', () => {
const result = resolveBranchLockState(false, undefined);
expect(result.locked).toBe(true);
});
it('resolveIsolationSelection is callable', () => {
expect(resolveIsolationSelection(IsolationMode.Workspace, undefined)).toBe(IsolationMode.Workspace);
});
});
// ─── resolveSessionDirsForTerminal ──────────────────────────────
describe('resolveSessionDirsForTerminal', () => {
it('returns matching terminal sessions before non-matching ones', async () => {
const terminal = {} as vscode.Terminal;
const otherTerminal = {} as vscode.Terminal;
const tracker: ICopilotCLISessionTracker = {
_serviceBrand: undefined,
getSessionIds: () => ['session-a', 'session-b'],
getTerminal: vi.fn(async (id: string) => id === 'session-a' ? terminal : otherTerminal),
} as unknown as ICopilotCLISessionTracker;
const dirs = await resolveSessionDirsForTerminal(tracker, terminal);
expect(dirs).toHaveLength(2);
// First dir should be for the matching session
expect(dirs[0].fsPath).toContain('session-a');
});
it('returns empty array when no sessions exist', async () => {
const terminal = {} as vscode.Terminal;
const tracker: ICopilotCLISessionTracker = {
_serviceBrand: undefined,
getSessionIds: () => [],
getTerminal: vi.fn(async () => undefined),
} as unknown as ICopilotCLISessionTracker;
const dirs = await resolveSessionDirsForTerminal(tracker, terminal);
expect(dirs).toHaveLength(0);
});
});
// ─── Additional CopilotCLIChatSessionContentProvider tests ──────
describe('CopilotCLIChatSessionContentProvider (additional)', () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it('provides chat session items from session service', async () => {
const { provider, sessionService } = createProvider();
const sessionItem: ICopilotCLISessionItem = {
id: 'session-1',
label: 'Test Session',
status: undefined,
workingDirectory: undefined,
} as unknown as ICopilotCLISessionItem;
sessionService.getAllSessions.mockResolvedValue([sessionItem]);
const items = await provider.provideChatSessionItems(CancellationToken.None);
expect(items).toHaveLength(1);
expect(items[0].label).toBe('Test Session');
});
it('returns empty array when no sessions', async () => {
const { provider, sessionService } = createProvider();
sessionService.getAllSessions.mockResolvedValue([]);
const items = await provider.provideChatSessionItems(CancellationToken.None);
expect(items).toHaveLength(0);
});
it('delegates updateInputStateAfterFolderSelection to option group builder', async () => {
const { provider } = createProvider();
const state = { groups: [] } as any;
// Should not throw
await provider.updateInputStateAfterFolderSelection(state, URI.file('/folder') as any);
});
it('does not call refreshSession when PR detection finds no update', async () => {
const { provider, prDetectionService, worktreeService } = createProvider();
const refreshSpy = vi.spyOn(provider, 'refreshSession').mockResolvedValue();
// No worktree properties means no PR detection
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
prDetectionService.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(refreshSpy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,331 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
import { PullRequestSearchItem } from '../../../../platform/github/common/githubAPI';
import { IOctoKitService } from '../../../../platform/github/common/githubService';
import { ILogService } from '../../../../platform/log/common/logService';
import { mock } from '../../../../util/common/test/simpleMock';
import { Event } from '../../../../util/vs/base/common/event';
import { URI } from '../../../../util/vs/base/common/uri';
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
import { PullRequestDetectionService } from '../pullRequestDetectionService';
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
declare readonly _serviceBrand: undefined;
override getWorktreeProperties = vi.fn(async (): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
override setWorktreeProperties = vi.fn(async () => { });
}
class TestGitService extends mock<IGitService>() {
declare readonly _serviceBrand: undefined;
override onDidOpenRepository = Event.None;
override onDidCloseRepository = Event.None;
override onDidFinishInitialization = Event.None;
override activeRepository = { get: () => undefined } as IGitService['activeRepository'];
override repositories: RepoContext[] = [];
override getRepository = vi.fn(async (): Promise<RepoContext | undefined> => this.repositories[0]);
setRepo(repo: RepoContext): void {
this.repositories = [repo];
}
}
class TestOctoKitService extends mock<IOctoKitService>() {
declare readonly _serviceBrand: undefined;
override findPullRequestByHeadBranch = vi.fn(async (): Promise<PullRequestSearchItem | undefined> => undefined);
}
class TestLogService extends mock<ILogService>() {
declare readonly _serviceBrand: undefined;
override trace = vi.fn();
override debug = vi.fn();
override error = vi.fn();
}
function createV2WorktreeProperties(overrides?: Partial<ChatSessionWorktreeProperties>): ChatSessionWorktreeProperties {
return {
version: 2,
baseCommit: 'abc123',
baseBranchName: 'main',
branchName: 'copilot/test-branch',
repositoryPath: '/repo',
worktreePath: '/worktree',
...overrides,
} as ChatSessionWorktreeProperties;
}
function createPrSearchItem(overrides?: Partial<PullRequestSearchItem>): PullRequestSearchItem {
return {
id: 'pr-42',
number: 42,
title: 'Test PR',
url: 'https://github.com/owner/repo/pull/42',
state: 'OPEN',
isDraft: false,
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
author: { login: 'user' },
repository: { owner: { login: 'owner' }, name: 'repo' },
additions: 1,
deletions: 0,
files: { totalCount: 1 },
fullDatabaseId: 42,
headRefOid: 'deadbeef',
headRefName: 'copilot/test-branch',
baseRefName: 'main',
body: '',
...overrides,
};
}
function createGitRepo(path: string = '/repo'): RepoContext {
return {
rootUri: URI.file(path),
kind: 'repository',
remotes: ['origin'],
remoteFetchUrls: ['https://github.com/owner/repo.git'],
} as unknown as RepoContext;
}
describe('PullRequestDetectionService', () => {
let worktreeService: TestWorktreeService;
let gitService: TestGitService;
let octoKitService: TestOctoKitService;
let logService: TestLogService;
let service: PullRequestDetectionService;
beforeEach(() => {
vi.restoreAllMocks();
worktreeService = new TestWorktreeService();
gitService = new TestGitService();
octoKitService = new TestOctoKitService();
logService = new TestLogService();
service = new PullRequestDetectionService(worktreeService, gitService, octoKitService, logService);
});
describe('detectPullRequest', () => {
it('does not query GitHub API when no worktree properties exist', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();
});
it('does not query GitHub API when version is not 2', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue({
version: 1,
autoCommit: true,
baseCommit: 'abc',
branchName: 'branch',
repositoryPath: '/repo',
worktreePath: '/wt',
});
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();
});
it('skips detection when pullRequestState is merged', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({ pullRequestState: 'merged' })
);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();
});
it('skips detection when branchName is missing', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({ branchName: '' })
);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
});
it('skips detection when repositoryPath is missing', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({ repositoryPath: '' })
);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
});
it('updates properties when PR is found', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
gitService.setRepo(createGitRepo());
octoKitService.findPullRequestByHeadBranch.mockResolvedValue(createPrSearchItem());
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
'session-1',
expect.objectContaining({
pullRequestUrl: 'https://github.com/owner/repo/pull/42',
pullRequestState: 'open',
}),
));
});
it('fires onDidDetectPullRequest when PR is found on session open', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
gitService.setRepo(createGitRepo());
octoKitService.findPullRequestByHeadBranch.mockResolvedValue(createPrSearchItem());
const firedSessionIds: string[] = [];
service.onDidDetectPullRequest(id => firedSessionIds.push(id));
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(firedSessionIds).toEqual(['session-1']));
});
it('does not fire onDidDetectPullRequest when no PR found on session open', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
gitService.setRepo(createGitRepo());
octoKitService.findPullRequestByHeadBranch.mockResolvedValue(undefined);
const firedSessionIds: string[] = [];
service.onDidDetectPullRequest(id => firedSessionIds.push(id));
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(octoKitService.findPullRequestByHeadBranch).toHaveBeenCalled());
expect(firedSessionIds).toEqual([]);
});
it('does not update properties when PR url and state are unchanged', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({
pullRequestUrl: 'https://github.com/owner/repo/pull/42',
pullRequestState: 'open',
})
);
gitService.setRepo(createGitRepo());
octoKitService.findPullRequestByHeadBranch.mockResolvedValue(createPrSearchItem());
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(octoKitService.findPullRequestByHeadBranch).toHaveBeenCalled());
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
});
it('updates properties when PR state changed', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({
pullRequestUrl: 'https://github.com/owner/repo/pull/42',
pullRequestState: 'open',
})
);
gitService.setRepo(createGitRepo());
octoKitService.findPullRequestByHeadBranch.mockResolvedValue(
createPrSearchItem({ state: 'CLOSED' })
);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
'session-1',
expect.objectContaining({ pullRequestState: 'closed' }),
));
});
it('does not update properties when no PR is found via GitHub API', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
gitService.setRepo(createGitRepo());
octoKitService.findPullRequestByHeadBranch.mockResolvedValue(undefined);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(octoKitService.findPullRequestByHeadBranch).toHaveBeenCalled());
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
});
it('does not throw on error', async () => {
worktreeService.getWorktreeProperties.mockRejectedValue(new Error('Service down'));
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
});
it('does not query GitHub API when git repository is not found', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
gitService.getRepository.mockResolvedValue(undefined);
service.detectPullRequest('session-1');
await vi.waitFor(() => expect(gitService.getRepository).toHaveBeenCalled());
expect(octoKitService.findPullRequestByHeadBranch).not.toHaveBeenCalled();
});
});
describe('handlePullRequestCreated', () => {
it('does not persist when no worktree properties exist', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/42');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
});
it('does not persist when version is not 2', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue({
version: 1,
autoCommit: true,
baseCommit: 'abc',
branchName: 'branch',
repositoryPath: '/repo',
worktreePath: '/wt',
});
service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/42');
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
});
it('persists PR URL from session when provided', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/99');
await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalledWith(
'session-1',
expect.objectContaining({
pullRequestUrl: 'https://github.com/owner/repo/pull/99',
}),
));
});
it('fires onDidDetectPullRequest when PR is persisted', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
const firedSessionIds: string[] = [];
service.onDidDetectPullRequest(id => firedSessionIds.push(id));
service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/99');
await vi.waitFor(() => expect(firedSessionIds).toEqual(['session-1']));
});
it('does not fire onDidDetectPullRequest when no PR detected', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({ branchName: '', repositoryPath: '' })
);
const firedSessionIds: string[] = [];
service.onDidDetectPullRequest(id => firedSessionIds.push(id));
service.handlePullRequestCreated('session-1', undefined);
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(firedSessionIds).toEqual([]);
});
it('does not persist when no PR URL and no branch/repo for retry', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(
createV2WorktreeProperties({ branchName: '', repositoryPath: '' })
);
service.handlePullRequestCreated('session-1', undefined);
await vi.waitFor(() => expect(worktreeService.getWorktreeProperties).toHaveBeenCalled());
expect(worktreeService.setWorktreeProperties).not.toHaveBeenCalled();
});
it('does not fire event when setWorktreeProperties throws', async () => {
worktreeService.getWorktreeProperties.mockResolvedValue(createV2WorktreeProperties());
worktreeService.setWorktreeProperties.mockRejectedValue(new Error('Write failed'));
const firedSessionIds: string[] = [];
service.onDidDetectPullRequest(id => firedSessionIds.push(id));
service.handlePullRequestCreated('session-1', 'https://github.com/owner/repo/pull/42');
await vi.waitFor(() => expect(worktreeService.setWorktreeProperties).toHaveBeenCalled());
expect(firedSessionIds).toEqual([]);
});
});
});

View File

@@ -0,0 +1,939 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import type * as vscode from 'vscode';
// eslint-disable-next-line no-duplicate-imports
import * as vscodeShim from 'vscode';
import { ConfigKey } from '../../../../platform/configuration/common/configurationService';
import { DefaultsOnlyConfigurationService } from '../../../../platform/configuration/common/defaultsOnlyConfigurationService';
import { InMemoryConfigurationService } from '../../../../platform/configuration/test/common/inMemoryConfigurationService';
import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext';
import { IGitService, RepoContext } from '../../../../platform/git/common/gitService';
import { NullWorkspaceService } from '../../../../platform/workspace/common/workspaceService';
import { mock } from '../../../../util/common/test/simpleMock';
import { CancellationToken } from '../../../../util/vs/base/common/cancellation';
import { Event } from '../../../../util/vs/base/common/event';
import { URI } from '../../../../util/vs/base/common/uri';
import { IAgentSessionsWorkspace } from '../../common/agentSessionsWorkspace';
import { ChatSessionWorktreeProperties, IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
import { FolderRepositoryMRUEntry, IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../../common/folderRepositoryManager';
import {
BRANCH_OPTION_ID,
ISOLATION_OPTION_ID,
REPOSITORY_OPTION_ID,
SessionOptionGroupBuilder,
folderMRUToChatProviderOptions,
getSelectedOption,
isBranchOptionFeatureEnabled,
isIsolationOptionFeatureEnabled,
resolveBranchLockState,
resolveBranchSelection,
resolveIsolationSelection,
toRepositoryOptionItem,
toWorkspaceFolderOptionItem,
} from '../sessionOptionGroupBuilder';
beforeAll(() => {
(vscodeShim as Record<string, unknown>).workspace = {
...((vscodeShim as Record<string, unknown>).workspace as object),
isResourceTrusted: async () => true,
};
});
// ─── Test Helpers ────────────────────────────────────────────────
class TestGitService extends mock<IGitService>() {
declare readonly _serviceBrand: undefined;
override onDidOpenRepository = Event.None;
override onDidCloseRepository = Event.None;
override onDidFinishInitialization = Event.None;
override activeRepository = { get: () => undefined } as IGitService['activeRepository'];
override repositories: RepoContext[] = [];
override getRepository = vi.fn(async (_uri: URI): Promise<RepoContext | undefined> => this.repositories[0]);
override getRefs = vi.fn(async () => [] as { name: string | undefined; type: number }[]);
}
class TestFolderMruService extends mock<IChatFolderMruService>() {
declare readonly _serviceBrand: undefined;
override getRecentlyUsedFolders = vi.fn(async () => [] as FolderRepositoryMRUEntry[]);
override deleteRecentlyUsedFolder = vi.fn(async () => { });
}
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
declare readonly _serviceBrand: undefined;
override getWorktreeProperties = vi.fn(async (): Promise<ChatSessionWorktreeProperties | undefined> => undefined);
}
class TestFolderRepositoryManager extends mock<IFolderRepositoryManager>() {
declare readonly _serviceBrand: undefined;
override getFolderRepository = vi.fn(async () => ({
folder: undefined,
repository: undefined,
worktree: undefined,
worktreeProperties: undefined,
trusted: undefined,
}));
}
function createInMemoryContext(): IVSCodeExtensionContext {
const state = new Map<string, unknown>();
return {
globalState: {
get: (key: string, defaultValue?: unknown) => state.get(key) ?? defaultValue,
keys: () => [...state.keys()],
update: (key: string, value: unknown) => { state.set(key, value); return Promise.resolve(); },
},
} as unknown as IVSCodeExtensionContext;
}
function makeRepo(path: string, kind: 'repository' | 'worktree' = 'repository'): RepoContext {
return {
rootUri: URI.file(path),
kind,
headBranchName: 'main',
remotes: ['origin'],
remoteFetchUrls: ['https://github.com/owner/repo.git'],
} as unknown as RepoContext;
}
function makeRef(name: string, type: number = 0 /* Head */): { name: string; type: number } {
return { name, type };
}
// ─── Pure function tests ─────────────────────────────────────────
describe('getSelectedOption', () => {
it('returns selected from matching group', () => {
const selected = { id: 'main', name: 'main' };
const groups: vscode.ChatSessionProviderOptionGroup[] = [
{ id: 'branch', name: 'Branch', description: '', items: [selected], selected },
];
expect(getSelectedOption(groups, 'branch')).toBe(selected);
});
it('returns undefined when group not found', () => {
expect(getSelectedOption([], 'branch')).toBeUndefined();
});
it('returns undefined when group has no selection', () => {
const groups: vscode.ChatSessionProviderOptionGroup[] = [
{ id: 'branch', name: 'Branch', description: '', items: [] },
];
expect(getSelectedOption(groups, 'branch')).toBeUndefined();
});
});
describe('isBranchOptionFeatureEnabled / isIsolationOptionFeatureEnabled', () => {
it('reads CLIBranchSupport config key', () => {
const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
// Default value should be whatever the config default is
const result = isBranchOptionFeatureEnabled(configService);
expect(typeof result).toBe('boolean');
});
it('reads CLIIsolationOption config key', () => {
const configService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
const result = isIsolationOptionFeatureEnabled(configService);
expect(typeof result).toBe('boolean');
});
});
describe('toRepositoryOptionItem', () => {
it('creates option item from RepoContext', () => {
const repo = makeRepo('/workspace/my-project');
const item = toRepositoryOptionItem(repo);
expect(item.id).toBe(URI.file('/workspace/my-project').fsPath);
expect(item.name).toBe('my-project');
});
it('uses repo icon for repository kind', () => {
const repo = makeRepo('/repo', 'repository');
const item = toRepositoryOptionItem(repo);
expect(item.icon).toBeDefined();
});
it('creates option item from Uri', () => {
const uri = URI.file('/some/folder');
const item = toRepositoryOptionItem(uri as any);
expect(item.id).toBe(uri.fsPath);
expect(item.name).toBe('folder');
});
it('marks item as default when isDefault is true', () => {
const repo = makeRepo('/repo');
const item = toRepositoryOptionItem(repo, true);
expect(item.default).toBe(true);
});
});
describe('toWorkspaceFolderOptionItem', () => {
it('creates option item with folder icon', () => {
const uri = URI.file('/workspace/my-folder');
const item = toWorkspaceFolderOptionItem(uri, 'My Folder');
expect(item.id).toBe(uri.fsPath);
expect(item.name).toBe('My Folder');
expect(item.icon).toBeDefined();
});
});
describe('folderMRUToChatProviderOptions', () => {
it('converts MRU entries with repositories to repo option items', () => {
const uri = URI.file('/my-repo');
const entries: FolderRepositoryMRUEntry[] = [
{ folder: uri, repository: uri, lastAccessed: 100 },
];
const items = folderMRUToChatProviderOptions(entries);
expect(items).toHaveLength(1);
expect(items[0].id).toBe(uri.fsPath);
});
it('converts MRU entries without repositories to folder option items', () => {
const uri = URI.file('/my-folder');
const entries: FolderRepositoryMRUEntry[] = [
{ folder: uri, repository: undefined, lastAccessed: 100 },
];
const items = folderMRUToChatProviderOptions(entries);
expect(items).toHaveLength(1);
expect(items[0].id).toBe(uri.fsPath);
});
it('returns empty array for empty input', () => {
expect(folderMRUToChatProviderOptions([])).toEqual([]);
});
});
describe('resolveBranchSelection', () => {
const main = { id: 'main', name: 'main' };
const dev = { id: 'dev', name: 'dev' };
const featureX = { id: 'feature-x', name: 'feature-x' };
const branches = [main, dev, featureX];
it('returns previous selection if it still exists in the branch list', () => {
expect(resolveBranchSelection(branches, 'main', dev)?.id).toBe('dev');
});
it('falls back to active (HEAD) branch when previous selection is no longer in list', () => {
const stale = { id: 'deleted-branch', name: 'deleted-branch' };
expect(resolveBranchSelection(branches, 'main', stale)?.id).toBe('main');
});
it('preserves stale previous selection when no active branch matches either', () => {
const stale = { id: 'deleted-branch', name: 'deleted-branch' };
expect(resolveBranchSelection(branches, undefined, stale)?.id).toBe('deleted-branch');
});
it('returns active branch when there is no previous selection', () => {
expect(resolveBranchSelection(branches, 'dev', undefined)?.id).toBe('dev');
});
it('returns undefined when no branches, no active, no previous', () => {
expect(resolveBranchSelection([], undefined, undefined)).toBeUndefined();
});
it('returns undefined when branches exist but no active and no previous', () => {
expect(resolveBranchSelection(branches, undefined, undefined)).toBeUndefined();
});
});
describe('resolveBranchLockState', () => {
it('locked with when clause when isolation is enabled and Workspace is selected', () => {
const result = resolveBranchLockState(true, IsolationMode.Workspace);
expect(result.locked).toBe(true);
expect(result.when).toContain('worktree');
});
it('editable with when clause when isolation is enabled and Worktree is selected', () => {
const result = resolveBranchLockState(true, IsolationMode.Worktree);
expect(result.locked).toBe(false);
expect(result.when).toContain('worktree');
});
it('locked with no when clause when isolation feature is disabled', () => {
const result = resolveBranchLockState(false, undefined);
expect(result.locked).toBe(true);
expect(result.when).toBeUndefined();
});
it('no when clause when isolation is disabled even if isolation value is worktree', () => {
const result = resolveBranchLockState(false, IsolationMode.Worktree);
expect(result.locked).toBe(true);
expect(result.when).toBeUndefined();
});
it('when clause references the isolation option ID', () => {
const result = resolveBranchLockState(true, IsolationMode.Workspace);
expect(result.when).toBe(`chatSessionOption.${ISOLATION_OPTION_ID} == '${IsolationMode.Worktree}'`);
});
});
describe('resolveIsolationSelection', () => {
it('uses previous selection when it is a valid isolation mode', () => {
expect(resolveIsolationSelection(IsolationMode.Worktree, IsolationMode.Workspace)).toBe(IsolationMode.Workspace);
expect(resolveIsolationSelection(IsolationMode.Workspace, IsolationMode.Worktree)).toBe(IsolationMode.Worktree);
});
it('falls back to lastUsed when there is no previous selection', () => {
expect(resolveIsolationSelection(IsolationMode.Worktree, undefined)).toBe(IsolationMode.Worktree);
});
it('falls back to lastUsed when previous selection is not a valid isolation mode', () => {
expect(resolveIsolationSelection(IsolationMode.Workspace, 'invalid-value')).toBe(IsolationMode.Workspace);
});
});
// ─── SessionOptionGroupBuilder class tests ───────────────────────
describe('SessionOptionGroupBuilder', () => {
let gitService: TestGitService;
let configurationService: InMemoryConfigurationService;
let context: IVSCodeExtensionContext;
let workspaceService: NullWorkspaceService;
let folderMruService: TestFolderMruService;
let agentSessionsWorkspace: IAgentSessionsWorkspace;
let worktreeService: TestWorktreeService;
let folderRepositoryManager: TestFolderRepositoryManager;
let builder: SessionOptionGroupBuilder;
beforeEach(async () => {
vi.restoreAllMocks();
gitService = new TestGitService();
configurationService = new InMemoryConfigurationService(new DefaultsOnlyConfigurationService());
context = createInMemoryContext();
workspaceService = new NullWorkspaceService([URI.file('/workspace')]);
folderMruService = new TestFolderMruService();
agentSessionsWorkspace = { _serviceBrand: undefined, isAgentSessionsWorkspace: false };
worktreeService = new TestWorktreeService();
folderRepositoryManager = new TestFolderRepositoryManager();
builder = new SessionOptionGroupBuilder(
gitService,
configurationService,
context,
workspaceService,
folderMruService,
agentSessionsWorkspace,
worktreeService,
folderRepositoryManager,
);
});
describe('getRepositoryOptionItems', () => {
it('returns empty array when no repositories', () => {
gitService.repositories = [];
const items = builder.getRepositoryOptionItems();
// Should still return workspace folder as non-git folder
expect(items.length).toBeGreaterThanOrEqual(0);
});
it('excludes worktree repositories', () => {
gitService.repositories = [
makeRepo('/repo', 'repository'),
makeRepo('/worktree', 'worktree'),
];
const items = builder.getRepositoryOptionItems();
expect(items.find(i => i.id === URI.file('/worktree').fsPath)).toBeUndefined();
});
it('includes repositories that belong to workspace folders', () => {
const repoUri = URI.file('/workspace');
gitService.repositories = [makeRepo('/workspace')];
const items = builder.getRepositoryOptionItems();
expect(items.find(i => i.id === repoUri.fsPath)).toBeDefined();
});
it('includes workspace folders without git repos in multi-root', () => {
workspaceService = new NullWorkspaceService([URI.file('/workspace'), URI.file('/other-folder')]);
builder = new SessionOptionGroupBuilder(
gitService, configurationService, context, workspaceService,
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
);
// Only one repo under /workspace
gitService.repositories = [makeRepo('/workspace')];
const items = builder.getRepositoryOptionItems();
// Should include the repo and the non-git folder
expect(items.length).toBe(2);
expect(items.find(i => i.id === URI.file('/other-folder').fsPath)).toBeDefined();
});
it('sorts items alphabetically by name', () => {
// NullWorkspaceService.getWorkspaceFolderName returns 'default', so we use git repos
// which derive their name from the URI path
workspaceService = new NullWorkspaceService([URI.file('/z-repo'), URI.file('/a-repo')]);
builder = new SessionOptionGroupBuilder(
gitService, configurationService, context, workspaceService,
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
);
gitService.repositories = [makeRepo('/z-repo'), makeRepo('/a-repo')];
const items = builder.getRepositoryOptionItems();
expect(items.length).toBe(2);
expect(items[0].name).toBe('a-repo');
expect(items[1].name).toBe('z-repo');
});
});
describe('buildBranchOptionGroup', () => {
it('returns undefined when no branches', () => {
const result = builder.buildBranchOptionGroup([], 'main', false, undefined, undefined);
expect(result).toBeUndefined();
});
it('returns branch group with items', () => {
const branches = [
{ id: 'main', name: 'main', icon: {} as any },
{ id: 'dev', name: 'dev', icon: {} as any },
];
const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);
expect(result).toBeDefined();
expect(result!.id).toBe(BRANCH_OPTION_ID);
expect(result!.items).toHaveLength(2);
});
it('selects HEAD branch when no previous selection', () => {
const branches = [
{ id: 'main', name: 'main', icon: {} as any },
{ id: 'dev', name: 'dev', icon: {} as any },
];
const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);
expect(result!.selected?.id).toBe('main');
});
it('locks items when isolation is disabled', () => {
const branches = [{ id: 'main', name: 'main', icon: {} as any }];
const result = builder.buildBranchOptionGroup(branches, 'main', false, undefined, undefined);
expect(result!.items[0].locked).toBe(true);
});
it('locks items when isolation is enabled but Workspace is selected', () => {
const branches = [{ id: 'main', name: 'main', icon: {} as any }];
const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Workspace, undefined);
expect(result!.items[0].locked).toBe(true);
expect(result!.when).toBeDefined();
});
it('does not lock items when isolation is enabled and Worktree is selected', () => {
const branches = [{ id: 'main', name: 'main', icon: {} as any }];
const result = builder.buildBranchOptionGroup(branches, 'main', true, IsolationMode.Worktree, undefined);
expect(result!.items[0].locked).toBeUndefined();
expect(result!.when).toBeDefined();
});
});
describe('getBranchOptionItemsForRepository', () => {
it('returns branch items sorted with HEAD first', async () => {
const repoUri = URI.file('/repo');
gitService.getRefs.mockResolvedValue([
makeRef('feature'),
makeRef('main'),
makeRef('dev'),
]);
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
expect(items[0].id).toBe('main');
});
it('puts main/master branch second after HEAD', async () => {
const repoUri = URI.file('/repo');
gitService.getRefs.mockResolvedValue([
makeRef('feature'),
makeRef('main'),
makeRef('dev'),
]);
// HEAD is 'dev'
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'dev');
expect(items[0].id).toBe('dev'); // HEAD first
expect(items[1].id).toBe('main'); // main/master second
});
it('filters out copilot-worktree branches', async () => {
const repoUri = URI.file('/repo');
gitService.getRefs.mockResolvedValue([
makeRef('main'),
makeRef('copilot-worktree-abc123'),
]);
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
expect(items).toHaveLength(1);
expect(items[0].id).toBe('main');
});
it('filters out non-local branches (remote refs)', async () => {
const repoUri = URI.file('/repo');
gitService.getRefs.mockResolvedValue([
makeRef('main'),
{ name: 'origin/main', type: 1 }, // RefType.Remote
]);
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
expect(items).toHaveLength(1);
});
it('returns empty array when no refs', async () => {
const repoUri = URI.file('/repo');
gitService.getRefs.mockResolvedValue([]);
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
expect(items).toHaveLength(0);
});
it('skips refs with no name', async () => {
const repoUri = URI.file('/repo');
gitService.getRefs.mockResolvedValue([
{ name: undefined, type: 0 },
makeRef('main'),
]);
const items = await builder.getBranchOptionItemsForRepository(repoUri, 'main');
expect(items).toHaveLength(1);
});
});
describe('provideChatSessionProviderOptionGroups', () => {
it('returns repository group for multi-repo workspaces', async () => {
workspaceService = new NullWorkspaceService([URI.file('/repo1'), URI.file('/repo2')]);
builder = new SessionOptionGroupBuilder(
gitService, configurationService, context, workspaceService,
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
);
gitService.repositories = [makeRepo('/repo1'), makeRepo('/repo2')];
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup).toBeDefined();
expect(repoGroup!.items.length).toBe(2);
});
it('does not include repository group for single-repo workspace', async () => {
gitService.repositories = [makeRepo('/workspace')];
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup).toBeUndefined();
});
it('includes isolation group when feature is enabled', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
expect(isolationGroup).toBeDefined();
expect(isolationGroup!.items).toHaveLength(2);
});
it('does not include isolation group when feature is disabled', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, false);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
expect(isolationGroup).toBeUndefined();
});
it('includes branch group when feature is enabled and repo exists', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
const repo = makeRepo('/workspace');
gitService.repositories = [repo];
gitService.getRepository.mockResolvedValue(repo);
gitService.getRefs.mockResolvedValue([makeRef('main')]);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeDefined();
});
it('does not include branch group when feature is disabled', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeUndefined();
});
it('preserves previous isolation selection', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
const previousState: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: ISOLATION_OPTION_ID,
name: 'Isolation',
description: '',
items: [],
selected: { id: IsolationMode.Worktree, name: 'Worktree' },
}],
};
const groups = await builder.provideChatSessionProviderOptionGroups(previousState);
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree);
});
it('shows MRU items for welcome view (empty workspace)', async () => {
workspaceService = new NullWorkspaceService([]);
builder = new SessionOptionGroupBuilder(
gitService, configurationService, context, workspaceService,
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
);
const mruUri = URI.file('/recent-repo');
folderMruService.getRecentlyUsedFolders.mockResolvedValue([
{ folder: mruUri, repository: mruUri, lastAccessed: Date.now() },
]);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup).toBeDefined();
expect(repoGroup!.items).toHaveLength(1);
expect(repoGroup!.items[0].id).toBe(mruUri.fsPath);
// Should have a command for browsing folders
expect(repoGroup!.commands).toBeDefined();
expect(repoGroup!.commands!.length).toBeGreaterThan(0);
});
});
describe('handleInputStateChange', () => {
it('rebuilds branch group when repo changes', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
const repo = makeRepo('/new-repo');
gitService.getRepository.mockResolvedValue(repo);
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('develop')]);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [
{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [],
selected: { id: URI.file('/new-repo').fsPath, name: 'new-repo' },
},
{
id: BRANCH_OPTION_ID,
name: 'Branch',
description: '',
items: [{ id: 'old-branch', name: 'old-branch' }],
selected: { id: 'old-branch', name: 'old-branch' },
},
],
};
await builder.handleInputStateChange(state);
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeDefined();
expect(branchGroup!.items.length).toBe(2);
});
it('removes branch group when repo has no branches', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
gitService.getRepository.mockResolvedValue(makeRepo('/repo'));
gitService.getRefs.mockResolvedValue([]);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [
{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [],
selected: { id: URI.file('/repo').fsPath, name: 'repo' },
},
{
id: BRANCH_OPTION_ID,
name: 'Branch',
description: '',
items: [{ id: 'old', name: 'old' }],
},
],
};
await builder.handleInputStateChange(state);
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeUndefined();
});
it('does not add branch group when branch feature is disabled', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [],
selected: { id: URI.file('/repo').fsPath, name: 'repo' },
}],
};
await builder.handleInputStateChange(state);
expect(state.groups.find(g => g.id === BRANCH_OPTION_ID)).toBeUndefined();
});
});
describe('buildExistingSessionInputStateGroups', () => {
it('returns locked groups for existing session', async () => {
folderRepositoryManager.getFolderRepository.mockResolvedValue({
folder: URI.file('/workspace'),
repository: URI.file('/workspace'),
worktree: undefined,
worktreeProperties: undefined,
trusted: true,
} as any);
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup).toBeDefined();
expect(repoGroup!.selected?.locked).toBe(true);
});
it('includes worktree branch for worktree sessions', async () => {
const worktreeProps: ChatSessionWorktreeProperties = {
version: 2,
baseCommit: 'abc',
baseBranchName: 'main',
branchName: 'copilot/feature',
repositoryPath: '/repo',
worktreePath: '/wt',
};
folderRepositoryManager.getFolderRepository.mockResolvedValue({
folder: URI.file('/repo'),
repository: URI.file('/repo'),
worktree: undefined,
worktreeProperties: worktreeProps,
trusted: true,
} as any);
worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps);
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeDefined();
expect(branchGroup!.selected?.id).toBe('copilot/feature');
expect(branchGroup!.selected?.locked).toBe(true);
});
it('includes repository branch for non-worktree sessions', async () => {
folderRepositoryManager.getFolderRepository.mockResolvedValue({
folder: URI.file('/workspace'),
repository: URI.file('/workspace'),
repositoryProperties: {
repositoryPath: '/workspace',
branchName: 'main',
baseBranchName: 'origin/main',
},
trusted: true,
} as any);
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
const branchGroup = groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeDefined();
expect(branchGroup!.selected?.id).toBe('main');
expect(branchGroup!.selected?.locked).toBe(true);
expect(branchGroup!.when).toBeUndefined();
});
it('includes isolation group when feature is enabled and session is worktree', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
const worktreeProps: ChatSessionWorktreeProperties = {
version: 2,
baseCommit: 'abc',
baseBranchName: 'main',
branchName: 'copilot/feature',
repositoryPath: '/repo',
worktreePath: '/wt',
};
folderRepositoryManager.getFolderRepository.mockResolvedValue({
folder: URI.file('/repo'),
repository: URI.file('/repo'),
trusted: true,
} as any);
worktreeService.getWorktreeProperties.mockResolvedValue(worktreeProps);
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
expect(isolationGroup).toBeDefined();
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Worktree);
expect(isolationGroup!.selected?.locked).toBe(true);
});
it('shows Workspace isolation for non-worktree sessions', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIIsolationOption, true);
folderRepositoryManager.getFolderRepository.mockResolvedValue({
folder: URI.file('/workspace'),
repository: URI.file('/workspace'),
trusted: true,
} as any);
worktreeService.getWorktreeProperties.mockResolvedValue(undefined);
const resource = URI.from({ scheme: 'copilotcli', path: '/session-1' });
const groups = await builder.buildExistingSessionInputStateGroups(resource, CancellationToken.None);
const isolationGroup = groups.find(g => g.id === ISOLATION_OPTION_ID);
expect(isolationGroup!.selected?.id).toBe(IsolationMode.Workspace);
});
});
describe('updateInputStateAfterFolderSelection', () => {
it('updates repo group selected item', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
const repo = makeRepo('/new-folder');
gitService.getRepository.mockResolvedValue(repo);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [{ id: URI.file('/old-folder').fsPath, name: 'old-folder' }],
selected: { id: URI.file('/old-folder').fsPath, name: 'old-folder' },
}],
};
await builder.updateInputStateAfterFolderSelection(state, URI.file('/new-folder') as any);
const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup!.selected!.id).toBe(URI.file('/new-folder').fsPath);
});
it('rebuilds branch group when new folder is a git repo', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
const repo = makeRepo('/new-repo');
gitService.getRepository.mockResolvedValue(repo);
gitService.getRefs.mockResolvedValue([makeRef('main'), makeRef('feature')]);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [],
}],
};
await builder.updateInputStateAfterFolderSelection(state, URI.file('/new-repo') as any);
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeDefined();
expect(branchGroup!.items.length).toBe(2);
});
it('removes branch group when new folder is not a git repo', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, true);
gitService.getRepository.mockResolvedValue(undefined);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [
{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [],
},
{
id: BRANCH_OPTION_ID,
name: 'Branch',
description: '',
items: [{ id: 'main', name: 'main' }],
},
],
};
await builder.updateInputStateAfterFolderSelection(state, URI.file('/non-git-folder') as any);
const branchGroup = state.groups.find(g => g.id === BRANCH_OPTION_ID);
expect(branchGroup).toBeUndefined();
});
it('adds new folder to items if not already present', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
gitService.getRepository.mockResolvedValue(undefined);
const existingItem = { id: URI.file('/old').fsPath, name: 'old' };
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [existingItem],
}],
};
await builder.updateInputStateAfterFolderSelection(state, URI.file('/new-folder') as any);
const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup!.items.length).toBe(2);
});
it('returns early without updating state when non-git folder is untrusted', async () => {
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
gitService.getRepository.mockResolvedValue(undefined);
// Override isResourceTrusted to return false for this test
const origWorkspace = (vscodeShim as Record<string, unknown>).workspace;
(vscodeShim as Record<string, unknown>).workspace = {
...(origWorkspace as object),
isResourceTrusted: async () => false,
};
try {
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [{ id: URI.file('/old').fsPath, name: 'old' }],
selected: { id: URI.file('/old').fsPath, name: 'old' },
}],
};
await builder.updateInputStateAfterFolderSelection(state, URI.file('/untrusted') as any);
// State should be unchanged
const repoGroup = state.groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup!.selected!.id).toBe(URI.file('/old').fsPath);
expect(repoGroup!.items.length).toBe(1);
} finally {
(vscodeShim as Record<string, unknown>).workspace = origWorkspace;
}
});
it('tracks MRU in welcome view when updating folder selection', async () => {
// Use empty workspace to trigger welcome view
workspaceService = new NullWorkspaceService([]);
builder = new SessionOptionGroupBuilder(
gitService, configurationService, context, workspaceService,
folderMruService, agentSessionsWorkspace, worktreeService, folderRepositoryManager,
);
await configurationService.setConfig(ConfigKey.Advanced.CLIBranchSupport, false);
const repo = makeRepo('/my-repo');
gitService.getRepository.mockResolvedValue(repo);
const state: vscode.ChatSessionInputState = {
onDidChange: Event.None,
groups: [{
id: REPOSITORY_OPTION_ID,
name: 'Folder',
description: '',
items: [],
}],
};
await builder.updateInputStateAfterFolderSelection(state, URI.file('/my-repo') as any);
// Verify the last used folder appears in subsequent option group builds
folderMruService.getRecentlyUsedFolders.mockResolvedValue([]);
const groups = await builder.provideChatSessionProviderOptionGroups(undefined);
const repoGroup = groups.find(g => g.id === REPOSITORY_OPTION_ID);
expect(repoGroup!.items.find(i => i.id === URI.file('/my-repo').fsPath)).toBeDefined();
});
});
});

View File

@@ -0,0 +1,260 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type * as vscode from 'vscode';
import { mock } from '../../../../util/common/test/simpleMock';
import { Event } from '../../../../util/vs/base/common/event';
import { URI } from '../../../../util/vs/base/common/uri';
import { ChatSessionStatus } from '../../../../vscodeTypes';
import { IChatSessionWorkspaceFolderService } from '../../common/chatSessionWorkspaceFolderService';
import { IChatSessionWorktreeCheckpointService } from '../../common/chatSessionWorktreeCheckpointService';
import { IChatSessionWorktreeService } from '../../common/chatSessionWorktreeService';
import { IPullRequestDetectionService } from '../pullRequestDetectionService';
import { SessionRequestLifecycle, SessionCompletionInfo } from '../sessionRequestLifecycle';
// ─── Test Helpers ────────────────────────────────────────────────
class TestWorktreeService extends mock<IChatSessionWorktreeService>() {
declare readonly _serviceBrand: undefined;
override handleRequestCompleted = vi.fn(async () => { });
}
class TestCheckpointService extends mock<IChatSessionWorktreeCheckpointService>() {
declare readonly _serviceBrand: undefined;
override handleRequest = vi.fn(async () => { });
override handleRequestCompleted = vi.fn(async () => { });
}
class TestWorkspaceFolderService extends mock<IChatSessionWorkspaceFolderService>() {
declare readonly _serviceBrand: undefined;
override handleRequestCompleted = vi.fn(async () => { });
}
class TestPrDetectionService extends mock<IPullRequestDetectionService>() {
declare readonly _serviceBrand: undefined;
override onDidDetectPullRequest = Event.None;
override handlePullRequestCreated = vi.fn();
}
function makeRequest(id: string = 'req-1'): vscode.ChatRequest {
return { id } as unknown as vscode.ChatRequest;
}
function makeSession(overrides?: Partial<SessionCompletionInfo>): SessionCompletionInfo {
return {
status: ChatSessionStatus.Completed,
workspace: {
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
},
createdPullRequestUrl: undefined,
...overrides,
};
}
function makeIsolatedSession(overrides?: Partial<SessionCompletionInfo>): SessionCompletionInfo {
return makeSession({
workspace: {
folder: URI.file('/workspace') as unknown as vscode.Uri,
repository: URI.file('/repo') as unknown as vscode.Uri,
repositoryProperties: undefined,
worktree: URI.file('/worktree') as unknown as vscode.Uri,
worktreeProperties: {
version: 2,
baseCommit: 'abc',
baseBranchName: 'main',
branchName: 'copilot/test',
repositoryPath: '/repo',
worktreePath: '/worktree',
},
},
...overrides,
});
}
function makeToken(cancelled: boolean = false): vscode.CancellationToken {
return { isCancellationRequested: cancelled, onCancellationRequested: vi.fn() } as unknown as vscode.CancellationToken;
}
// ─── Tests ───────────────────────────────────────────────────────
describe('SessionRequestLifecycle', () => {
let worktreeService: TestWorktreeService;
let checkpointService: TestCheckpointService;
let workspaceFolderService: TestWorkspaceFolderService;
let prDetectionService: TestPrDetectionService;
let handler: SessionRequestLifecycle;
beforeEach(() => {
vi.restoreAllMocks();
worktreeService = new TestWorktreeService();
checkpointService = new TestCheckpointService();
workspaceFolderService = new TestWorkspaceFolderService();
prDetectionService = new TestPrDetectionService();
handler = new SessionRequestLifecycle(
worktreeService,
checkpointService,
workspaceFolderService,
prDetectionService,
);
});
describe('startRequest', () => {
it('creates baseline checkpoint on first request', async () => {
const request = makeRequest();
await handler.startRequest('session-1', request, true);
expect(checkpointService.handleRequest).toHaveBeenCalledWith('session-1');
});
it('skips baseline checkpoint on subsequent requests', async () => {
const request = makeRequest();
await handler.startRequest('session-1', request, false);
expect(checkpointService.handleRequest).not.toHaveBeenCalled();
});
});
describe('endRequest', () => {
it('commits worktree changes for isolated session', async () => {
const request = makeRequest();
const session = makeIsolatedSession();
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken());
expect(worktreeService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-1');
});
it('stages workspace changes for non-isolated session with working directory', async () => {
const request = makeRequest();
const session = makeSession(); // non-isolated, has folder
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken());
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-1');
});
it('skips commit/stage when session status is not Completed', async () => {
const request = makeRequest();
const session = makeSession({ status: ChatSessionStatus.InProgress });
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken());
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();
});
it('skips commit/stage when session status is undefined', async () => {
const request = makeRequest();
const session = makeSession({ status: undefined });
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken());
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
});
it('skips workspace commit when no working directory', async () => {
const request = makeRequest();
const session = makeSession({
workspace: {
folder: undefined,
repository: undefined,
repositoryProperties: undefined,
worktree: undefined,
worktreeProperties: undefined,
},
});
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken());
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
// Checkpoint should still be created
expect(checkpointService.handleRequestCompleted).toHaveBeenCalled();
});
it('defers handling when multiple requests are in flight (steering)', async () => {
const req1 = makeRequest('req-1');
const req2 = makeRequest('req-2');
const session = makeSession();
await handler.startRequest('session-1', req1, false);
await handler.startRequest('session-1', req2, false);
// First request completes — should defer (2 pending)
await handler.endRequest('session-1', req1, session, makeToken());
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();
// Second (last) request completes — should proceed
await handler.endRequest('session-1', req2, session, makeToken());
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledWith('session-1');
expect(checkpointService.handleRequestCompleted).toHaveBeenCalledWith('session-1', 'req-2');
});
it('skips everything when token is cancelled', async () => {
const request = makeRequest();
const session = makeSession();
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken(true));
expect(worktreeService.handleRequestCompleted).not.toHaveBeenCalled();
expect(workspaceFolderService.handleRequestCompleted).not.toHaveBeenCalled();
expect(checkpointService.handleRequestCompleted).not.toHaveBeenCalled();
});
it('calls PR detection service on completion', async () => {
const request = makeRequest();
const session = makeSession();
await handler.startRequest('session-1', request, false);
await handler.endRequest('session-1', request, session, makeToken());
// PR detection is fire-and-forget; wait for microtask
await new Promise(resolve => setTimeout(resolve, 10));
expect(prDetectionService.handlePullRequestCreated).toHaveBeenCalledWith('session-1', undefined);
});
it('cleans up tracked request even when commit throws', async () => {
workspaceFolderService.handleRequestCompleted.mockRejectedValue(new Error('commit failed'));
const request = makeRequest();
const session = makeSession();
await handler.startRequest('session-1', request, false);
await expect(handler.endRequest('session-1', request, session, makeToken())).rejects.toThrow('commit failed');
// After the error, a new request for the same session should proceed normally
workspaceFolderService.handleRequestCompleted.mockResolvedValue();
const req2 = makeRequest('req-2');
await handler.startRequest('session-1', req2, false);
await handler.endRequest('session-1', req2, session, makeToken());
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalledTimes(2);
});
it('handles request without prior tracking gracefully', async () => {
const request = makeRequest();
const session = makeSession();
// Not tracked, but should still work (pendingRequests is undefined → size check skipped)
await handler.endRequest('session-1', request, session, makeToken());
expect(workspaceFolderService.handleRequestCompleted).toHaveBeenCalled();
});
});
});