From b0ee7a156ad4a2ded0a358b092d39a426dc95fbc Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Wed, 8 Apr 2026 16:21:22 +1000 Subject: [PATCH] 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> --- .../common/folderRepositoryManager.ts | 7 + .../chatSessions/copilotcli/common/utils.ts | 21 + .../vscode-node/copilotCLIFolderMru.ts | 26 +- .../chatSessionRepositoryTracker.ts | 2 +- .../chatSessions/vscode-node/chatSessions.ts | 20 +- .../copilotCLIChatSessionInitializer.ts | 313 ++++ .../vscode-node/copilotCLIChatSessions.ts | 1317 +++-------------- .../copilotCLIChatSessionsContribution.ts | 9 +- .../pullRequestDetectionService.ts | 245 +++ .../vscode-node/sessionOptionGroupBuilder.ts | 538 +++++++ .../vscode-node/sessionRequestLifecycle.ts | 129 ++ .../test/chatSessionInitializer.spec.ts | 671 +++++++++ .../copilotCLIChatSessionParticipant.spec.ts | 4 +- .../test/copilotCLIChatSessions.spec.ts | 197 ++- .../test/pullRequestDetectionService.spec.ts | 331 +++++ .../test/sessionOptionGroupBuilder.spec.ts | 939 ++++++++++++ .../test/sessionRequestLifecycle.spec.ts | 260 ++++ 17 files changed, 3859 insertions(+), 1170 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/copilotcli/common/utils.ts rename extensions/copilot/src/extension/chatSessions/{ => copilotcli}/vscode-node/copilotCLIFolderMru.ts (73%) create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestDetectionService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/pullRequestDetectionService.spec.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts diff --git a/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts b/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts index 4cd25297303..8a638aef84c 100644 --- a/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts +++ b/extensions/copilot/src/extension/chatSessions/common/folderRepositoryManager.ts @@ -179,3 +179,10 @@ export interface IFolderRepositoryManager { */ getFolderMRU(): Promise; } + +export interface IChatFolderMruService { + readonly _serviceBrand: undefined; + getRecentlyUsedFolders(token: vscode.CancellationToken): Promise; + deleteRecentlyUsedFolder(folder: vscode.Uri): Promise; +} +export const IChatFolderMruService = createServiceIdentifier('IChatFolderMruService'); diff --git a/extensions/copilot/src/extension/chatSessions/copilotcli/common/utils.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/common/utils.ts new file mode 100644 index 00000000000..b70995b49aa --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/common/utils.ts @@ -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'; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIFolderMru.ts b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLIFolderMru.ts similarity index 73% rename from extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIFolderMru.ts rename to extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLIFolderMru.ts index 16f34363f3f..81d8c1b04f5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIFolderMru.ts +++ b/extensions/copilot/src/extension/chatSessions/copilotcli/vscode-node/copilotCLIFolderMru.ts @@ -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 = { -readonly [K in keyof T]: T[K]; }; -export interface ICopilotCLIFolderMruService { - readonly _serviceBrand: undefined; - getRecentlyUsedFolders(token: CancellationToken): Promise; - deleteRecentlyUsedFolder(folder: Uri): Promise; -} -export const ICopilotCLIFolderMruService = createServiceIdentifier('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, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts index 2b52d4e9393..3e1d42f99a4 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionRepositoryTracker.ts @@ -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()}.`); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 3d4b879edb0..8fb11877d06 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -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))); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts new file mode 100644 index 00000000000..e73f6720ad1 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionInitializer.ts @@ -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 }, + disposables: DisposableStore, + token: vscode.CancellationToken + ): Promise<{ session: IReference | 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 | 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; model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined }>; +} + +export const ICopilotCLIChatSessionInitializer = createServiceIdentifier('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 }, + disposables: DisposableStore, + token: vscode.CancellationToken + ): Promise<{ session: IReference | 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 | 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; 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 { + 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 { + 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 { + 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; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index ad29de925a2..55d66865022 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -2,25 +2,20 @@ * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import type { Attachment, SessionOptions, SweCustomAgent } from '@github/copilot/sdk'; +import type { Attachment, SessionOptions } from '@github/copilot/sdk'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; -import { ChatExtendedRequestHandler, ChatRequestTurn2, ChatSessionProviderOptionItem, Uri } from 'vscode'; +import { ChatExtendedRequestHandler, ChatRequestTurn2, Uri } from 'vscode'; import { IRunCommandExecutionService } from '../../../platform/commands/common/runCommandExecutionService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { INativeEnvService } from '../../../platform/env/common/envService'; -import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; -import { getGitHubRepoInfoFromContext, IGitService, RepoContext } from '../../../platform/git/common/gitService'; +import { IGitService } from '../../../platform/git/common/gitService'; import { toGitUri } from '../../../platform/git/common/utils'; -import { derivePullRequestState } from '../../../platform/github/common/githubAPI'; -import { IOctoKitService } from '../../../platform/github/common/githubService'; import { ILogService } from '../../../platform/log/common/logService'; -import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles/common/promptsService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; -import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { isUri } from '../../../util/common/types'; -import { DeferredPromise, IntervalTimer, SequencerByKey } from '../../../util/vs/base/common/async'; +import { DeferredPromise, IntervalTimer } from '../../../util/vs/base/common/async'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter, Event } from '../../../util/vs/base/common/event'; @@ -29,42 +24,41 @@ import { ResourceMap } from '../../../util/vs/base/common/map'; import { relative } from '../../../util/vs/base/common/path'; import { basename, dirname, extUri } from '../../../util/vs/base/common/resources'; import { StopWatch } from '../../../util/vs/base/common/stopwatch'; -import { URI } from '../../../util/vs/base/common/uri'; +import { hasKey } from '../../../util/vs/base/common/types'; import { EXTENSION_ID } from '../../common/constants'; -import { ChatVariablesCollection, extractDebugTargetSessionIds, isPromptFile } from '../../prompt/common/chatVariablesCollection'; import { GitBranchNameGenerator } from '../../prompt/node/gitBranch'; -import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace'; -import { IChatSessionMetadataStore, StoredModeInstructions } from '../common/chatSessionMetadataStore'; +import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; -import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService'; import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; -import { FolderRepositoryInfo, FolderRepositoryMRUEntry, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; -import { emptyWorkspaceInfo, getWorkingDirectory, isIsolationEnabled, IWorkspaceInfo } from '../common/workspaceInfo'; +import { IChatFolderMruService, IFolderRepositoryManager, IsolationMode } from '../common/folderRepositoryManager'; +import { getWorkingDirectory, IWorkspaceInfo } from '../common/workspaceInfo'; import { ICustomSessionTitleService } from '../copilotcli/common/customSessionTitleService'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; +import { SessionIdForCLI } from '../copilotcli/common/utils'; import { getCopilotCLISessionDir } from '../copilotcli/node/cliHelpers'; -import { COPILOT_CLI_REASONING_EFFORT_PROPERTY, ICopilotCLIAgents, ICopilotCLIModels, ICopilotCLISDK, isWelcomeView } from '../copilotcli/node/copilotCli'; +import { ICopilotCLISDK } from '../copilotcli/node/copilotCli'; import { CopilotCLIPromptResolver } from '../copilotcli/node/copilotcliPromptResolver'; import { builtinSlashSCommands, CopilotCLICommand, copilotCLICommands, ICopilotCLISession } from '../copilotcli/node/copilotcliSession'; import { ICopilotCLISessionItem, ICopilotCLISessionService } from '../copilotcli/node/copilotcliSessionService'; import { buildMcpServerMappings } from '../copilotcli/node/mcpHandler'; import { ICopilotCLISessionTracker } from '../copilotcli/vscode-node/copilotCLISessionTracker'; -import { ICopilotCLIFolderMruService } from './copilotCLIFolderMru'; +import { ICopilotCLIChatSessionInitializer } from './copilotCLIChatSessionInitializer'; import { convertReferenceToVariable } from './copilotCLIPromptReferences'; import { ICopilotCLITerminalIntegration, TerminalOpenLocation } from './copilotCLITerminalIntegration'; import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; - -const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; +import { IPullRequestDetectionService } from './pullRequestDetectionService'; +import { ISessionOptionGroupBuilder, OPEN_REPOSITORY_COMMAND_ID, toRepositoryOptionItem, toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; +import { ISessionRequestLifecycle } from './sessionRequestLifecycle'; /** * ODO: - * 1. We cannot use setNewSessionFolder hence we need a way to track what is the folder we need to use when creating new sessions. - * 2. When we invoke initializeFolderRepository we should pass the folder thats been selected by the user. * 3. Verify all command handlers do the exact same thing - * 4. Remove this._currentSessionId - * 5. Remove isWorktreeIsolationSelected and update to account for dropdown. * 6. Is chatSessionContext?.initialSessionOptions still valid with new API * 7. Validated selected MRU item + * 8. We shouldn't have to pass model information into CLISession class, and then update sdk with the model info. Instead when we call get/create session, we should be able to pass the model info there and update the SDK session accordingly. + * This makes it unnecessary to pass model information. + * 2. Behavioral Change: trusted flag no longer unlocks dropdowns on trust failure +In the old code, when sessionResult.trusted === false, there was a call to this.unlockRepoOptionForSession(context, token) to reset dropdown selections. The new code at copilotCLIChatSessions.ts:634 simply returns {} without any dropdown reset. However, lockRepoOptionForSession and unlockRepoOptionForSession were already dead code (commented out), so this is actually correct — removing a no-op. * * Cases to cover: * 1. Hook up the dropdowns for empty workspace folders as well @@ -85,41 +79,16 @@ const COPILOT_WORKTREE_PATTERN = 'copilot-worktree-'; */ export interface ICopilotCLIChatSessionItemProvider extends IDisposable { - refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'delete'; sessionId: string }): Promise; + refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise; } -const REPOSITORY_OPTION_ID = 'repository'; -const BRANCH_OPTION_ID = 'branch'; -const ISOLATION_OPTION_ID = 'isolation'; -const LAST_USED_ISOLATION_OPTION_KEY = 'github.copilot.cli.lastUsedIsolationOption'; -const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.cli.sessions.openRepository'; const OPEN_IN_COPILOT_CLI_COMMAND_ID = 'github.copilot.cli.openInCopilotCLI'; -const MAX_MRU_ENTRIES = 10; const CHECK_FOR_STEERING_DELAY = 100; // ms -// // When we start new sessions, we don't have the real session id, we have a temporary untitled id. -// // We also need this when we open a session and later run it. -// // When opening the session for readonly mode we store it here and when run the session we read from here instead of opening session in readonly mode again. -// const _sessionBranch: Map = new Map(); -// const _sessionIsolation: Map = new Map(); - const _invalidCopilotCLISessionIdsWithErrorMessage = new Map(); -namespace SessionIdForCLI { - export function getResource(sessionId: string): vscode.Uri { - return vscode.Uri.from({ - scheme: 'copilotcli', path: `/${sessionId}`, - }); - } - - export function parse(resource: vscode.Uri): string { - return resource.path.slice(1); - } - - export function isCLIResource(resource: vscode.Uri): boolean { - return resource.scheme === 'copilotcli'; - } -} +// Re-export for backward compatibility +export { resolveBranchLockState, resolveBranchSelection, resolveIsolationSelection } from './sessionOptionGroupBuilder'; /** * Escape XML special characters @@ -181,79 +150,28 @@ export async function resolveSessionDirsForTerminal( return [...matching, ...rest]; } -function isBranchOptionFeatureEnabled(configurationService: IConfigurationService): boolean { - return configurationService.getConfig(ConfigKey.Advanced.CLIBranchSupport); -} - -function isReasoningEffortFeatureEnabled(configurationService: IConfigurationService): boolean { - return configurationService.getConfig(ConfigKey.Advanced.CLIThinkingEffortEnabled); -} - -function isIsolationOptionFeatureEnabled(configurationService: IConfigurationService): boolean { - return configurationService.getConfig(ConfigKey.Advanced.CLIIsolationOption); -} - - -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; -} - - -function toWorkspaceFolderOptionItem(workspaceFolderUri: URI, name: string): ChatSessionProviderOptionItem { - return { - id: workspaceFolderUri.fsPath, - name: name, - icon: new vscode.ThemeIcon('folder'), - } satisfies vscode.ChatSessionProviderOptionItem; -} - export class CopilotCLIChatSessionContentProvider extends Disposable implements vscode.ChatSessionContentProvider, ICopilotCLIChatSessionItemProvider { private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event; private readonly controller: vscode.ChatSessionItemController; private readonly newSessions = new ResourceMap(); - /** - * ID of the last used folder in an untitled workspace (for defaulting selection). - */ - private _lastUsedFolderIdInUntitledWorkspace?: { kind: 'folder' | 'repo'; uri: vscode.Uri; lastAccessed: number }; constructor( @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, - @IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore, @IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService, - @IWorkspaceService private readonly workspaceService: IWorkspaceService, - @IGitService private readonly gitService: IGitService, @IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager, @IConfigurationService private readonly configurationService: IConfigurationService, @ICustomSessionTitleService private readonly customSessionTitleService: ICustomSessionTitleService, - @IVSCodeExtensionContext private readonly context: IVSCodeExtensionContext, - @ICopilotCLISessionTracker private readonly sessionTracker: ICopilotCLISessionTracker, - @ICopilotCLITerminalIntegration private readonly terminalIntegration: ICopilotCLITerminalIntegration, @IRunCommandExecutionService private readonly commandExecutionService: IRunCommandExecutionService, - @IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService, - @IOctoKitService private readonly octoKitService: IOctoKitService, @ILogService private readonly logService: ILogService, - @IAgentSessionsWorkspace private readonly _agentSessionsWorkspace: IAgentSessionsWorkspace, - @ICopilotCLIFolderMruService private readonly copilotCLIFolderMruService: ICopilotCLIFolderMruService, + @IPullRequestDetectionService private readonly _prDetectionService: IPullRequestDetectionService, + @ISessionOptionGroupBuilder private readonly _optionGroupBuilder: ISessionOptionGroupBuilder, + @IGitService private readonly _gitService: IGitService, + @IChatSessionWorkspaceFolderService private readonly _workspaceFolderService: IChatSessionWorkspaceFolderService, + @IChatSessionMetadataStore private readonly _metadataStore: IChatSessionMetadataStore, ) { super(); - this._register(this.terminalIntegration); - - // Resolve session dirs for terminal links. See resolveSessionDirsForTerminal. - this.terminalIntegration.setSessionDirResolver(terminal => - resolveSessionDirsForTerminal(this.sessionTracker, terminal) - ); - let isRefreshing = false; const controller = this.controller = this._register(vscode.chat.createChatSessionItemController( 'copilotcli', @@ -343,15 +261,30 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } controller.getChatSessionInputState = async (sessionResource, context, token) => { - const groups = sessionResource ? await this.buildExistingSessionInputStateGroups(sessionResource, token) : await this.provideChatSessionProviderOptionGroups(context.previousInputState); - return controller.createChatSessionInputState(groups); + const groups = sessionResource ? await this._optionGroupBuilder.buildExistingSessionInputStateGroups(sessionResource, token) : await this._optionGroupBuilder.provideChatSessionProviderOptionGroups(context.previousInputState); + const state = controller.createChatSessionInputState(groups); + if (!sessionResource) { + // Only wire dynamic updates for new sessions (existing sessions are fully locked). + // Note: don't use the getChatSessionInputState token here — it's a one-shot token + // that may be disposed by the time the user interacts with the dropdowns. + state.onDidChange(() => this._optionGroupBuilder.handleInputStateChange(state)); + } + return state; }; } - public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'delete'; sessionId: string }): Promise { + public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise { if (refreshOptions.reason === 'delete') { const uri = SessionIdForCLI.getResource(refreshOptions.sessionId); this.controller.items.delete(uri); + } else if (refreshOptions.reason === 'update' && hasKey(refreshOptions, { 'sessionIds': true })) { + await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => { + const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None); + if (item) { + const chatSessionItem = await this.toChatSessionItem(item); + this.controller.items.add(chatSessionItem); + } + })); } else { const item = await this.sessionService.getSessionItem(refreshOptions.sessionId, CancellationToken.None); if (item) { @@ -371,52 +304,62 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements return diskSessions; } - private shouldShowBadge(): boolean { - const repositories = this.gitService.repositories - .filter(repository => repository.kind !== 'worktree'); - - return vscode.workspace.workspaceFolders === undefined || // empty window - vscode.workspace.isAgentSessionsWorkspace || // agent sessions workspace - repositories.length > 1; // multiple repositories - } - public async toChatSessionItem(session: ICopilotCLISessionItem): Promise { const resource = SessionIdForCLI.getResource(session.id); + const item = this.controller.createChatSessionItem(resource, session.label); + const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; - const label = session.label; + item.timing = session.timing; + item.status = session.status ?? vscode.ChatSessionStatus.Completed; + const [badge, changes, metadata] = await Promise.all([ + this.buildBadge(worktreeProperties, workingDirectory), + this.buildChanges(session.id, worktreeProperties, workingDirectory), + this.buildMetadata(session.id, worktreeProperties, workingDirectory), + ]); + item.badge = badge; + item.changes = changes; + item.metadata = metadata; + return item; + } - // Badge - let badge: vscode.MarkdownString | undefined; - if (this.shouldShowBadge()) { - if (worktreeProperties?.repositoryPath) { - // Worktree - const repositoryPathUri = vscode.Uri.file(worktreeProperties.repositoryPath); - const isTrusted = await vscode.workspace.isResourceTrusted(repositoryPathUri); - const badgeIcon = isTrusted ? '$(repo)' : '$(workspace-untrusted)'; - - badge = new vscode.MarkdownString(`${badgeIcon} ${basename(repositoryPathUri)}`); - badge.supportThemeIcons = true; - } else if (workingDirectory) { - // Workspace - const isTrusted = await vscode.workspace.isResourceTrusted(workingDirectory); - const badgeIcon = isTrusted ? '$(folder)' : '$(workspace-untrusted)'; - - badge = new vscode.MarkdownString(`${badgeIcon} ${basename(workingDirectory)}`); - badge.supportThemeIcons = true; - } + private async buildBadge( + worktreeProperties: Awaited>, + workingDirectory: vscode.Uri | undefined, + ): Promise { + const repositories = this._gitService.repositories.filter(r => r.kind !== 'worktree'); + const shouldShow = vscode.workspace.workspaceFolders === undefined || + vscode.workspace.isAgentSessionsWorkspace || + repositories.length > 1; + if (!shouldShow) { + return undefined; } + const badgeUri = worktreeProperties?.repositoryPath + ? vscode.Uri.file(worktreeProperties.repositoryPath) + : workingDirectory; + if (!badgeUri) { + return undefined; + } + const isTrusted = await vscode.workspace.isResourceTrusted(badgeUri); + const isRepo = !!worktreeProperties?.repositoryPath; + const icon = isTrusted ? (isRepo ? '$(repo)' : '$(folder)') : '$(workspace-untrusted)'; + const badge = new vscode.MarkdownString(`${icon} ${basename(badgeUri)}`); + badge.supportThemeIcons = true; + return badge; + } - // Statistics (only returned for trusted workspace/worktree folders) + private async buildChanges( + sessionId: string, + worktreeProperties: Awaited>, + workingDirectory: vscode.Uri | undefined, + ): Promise { const changes: vscode.ChatSessionChangedFile2[] = []; if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) { - // Worktree - changes.push(...(await this.copilotCLIWorktreeManagerService.getWorktreeChanges(session.id) ?? [])); + changes.push(...(await this.copilotCLIWorktreeManagerService.getWorktreeChanges(sessionId) ?? [])); } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { - // Workspace - const workspaceChanges = await this.workspaceFolderService.getWorkspaceChanges(session.id) ?? []; + const workspaceChanges = await this._workspaceFolderService.getWorkspaceChanges(sessionId) ?? []; changes.push(...workspaceChanges.map(change => new vscode.ChatSessionChangedFile2( vscode.Uri.file(change.filePath), change.originalFilePath @@ -428,16 +371,16 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements change.statistics.additions, change.statistics.deletions))); } + return changes; + } - // Status - const status = session.status ?? vscode.ChatSessionStatus.Completed; - - // Metadata - let metadata: { readonly [key: string]: unknown }; - + private async buildMetadata( + sessionId: string, + worktreeProperties: Awaited>, + workingDirectory: vscode.Uri | undefined, + ): Promise<{ readonly [key: string]: unknown }> { if (worktreeProperties) { - // Worktree - metadata = { + return { autoCommit: worktreeProperties.autoCommit !== false, baseCommit: worktreeProperties?.baseCommit, baseBranchName: worktreeProperties.version === 2 @@ -466,90 +409,35 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements ? worktreeProperties.lastCheckpointRef : undefined } satisfies { readonly [key: string]: unknown }; - } else { - // Workspace - const sessionRequestDetails = await this.chatSessionMetadataStore.getRequestDetails(session.id); - const repositoryProperties = await this.chatSessionMetadataStore.getRepositoryProperties(session.id); - - let lastCheckpointRef: string | undefined; - for (let i = sessionRequestDetails.length - 1; i >= 0; i--) { - const checkpointRef = sessionRequestDetails[i]?.checkpointRef; - if (checkpointRef !== undefined) { - lastCheckpointRef = checkpointRef; - break; - } - } - - const firstCheckpointRef = lastCheckpointRef - ? `${lastCheckpointRef.slice(0, lastCheckpointRef.lastIndexOf('/'))}/0` - : undefined; - - metadata = { - isolationMode: IsolationMode.Workspace, - repositoryPath: repositoryProperties?.repositoryPath, - branchName: repositoryProperties?.branchName, - baseBranchName: repositoryProperties?.baseBranchName, - workingDirectoryPath: workingDirectory?.fsPath, - firstCheckpointRef, - lastCheckpointRef - } satisfies { readonly [key: string]: unknown }; } - const item = this.controller.createChatSessionItem(resource, label); - item.badge = badge; - item.timing = session.timing; - item.changes = changes; - item.status = status; - item.metadata = metadata; - return item; - } + const [sessionRequestDetails, repositoryProperties] = await Promise.all([ + this._metadataStore.getRequestDetails(sessionId), + this._metadataStore.getRepositoryProperties(sessionId) + ]); - /** - * Detects a pull request for a session when the user opens it. - * If a PR is found, persists the URL and notifies the UI. - */ - public async detectPullRequestOnSessionOpen(sessionId: string): Promise { - try { - const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (worktreeProperties?.version !== 2 - || worktreeProperties.pullRequestState === 'merged' - || !worktreeProperties.branchName - || !worktreeProperties.repositoryPath) { - this.logService.debug(`[CopilotCLIChatSessionItemProvider] 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; + let lastCheckpointRef: string | undefined; + for (let i = sessionRequestDetails.length - 1; i >= 0; i--) { + const checkpointRef = sessionRequestDetails[i]?.checkpointRef; + if (checkpointRef !== undefined) { + lastCheckpointRef = checkpointRef; + break; } - - this.logService.debug(`[CopilotCLIChatSessionItemProvider] Detecting PR on session open for ${sessionId}, branch=${worktreeProperties.branchName}, existingPrUrl=${worktreeProperties.pullRequestUrl ?? 'none'}`); - - const prResult = await detectPullRequestFromGitHubAPI( - worktreeProperties.branchName, - worktreeProperties.repositoryPath, - this.gitService, - this.octoKitService, - this.logService, - ); - - if (prResult) { - const currentProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (currentProperties?.version === 2 - && (currentProperties.pullRequestUrl !== prResult.url || currentProperties.pullRequestState !== prResult.state)) { - this.logService.debug(`[CopilotCLIChatSessionItemProvider] Updating PR metadata for ${sessionId}: url=${prResult.url}, state=${prResult.state} (was url=${currentProperties.pullRequestUrl ?? 'none'}, state=${currentProperties.pullRequestState ?? 'none'})`); - await this.copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { - ...currentProperties, - pullRequestUrl: prResult.url, - pullRequestState: prResult.state, - changes: undefined, - }); - await this.refreshSession({ reason: 'update', sessionId }); - } else { - this.logService.debug(`[CopilotCLIChatSessionItemProvider] PR metadata unchanged for ${sessionId}, skipping update`); - } - } else { - this.logService.debug(`[CopilotCLIChatSessionItemProvider] No PR found via GitHub API for ${sessionId}`); - } - } catch (error) { - this.logService.trace(`[CopilotCLIChatSessionItemProvider] Failed to detect pull request on session open for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`); } + + const firstCheckpointRef = lastCheckpointRef + ? `${lastCheckpointRef.slice(0, lastCheckpointRef.lastIndexOf('/'))}/0` + : undefined; + + return { + isolationMode: IsolationMode.Workspace, + repositoryPath: repositoryProperties?.repositoryPath, + branchName: repositoryProperties?.branchName, + baseBranchName: repositoryProperties?.baseBranchName, + workingDirectoryPath: workingDirectory?.fsPath, + firstCheckpointRef, + lastCheckpointRef + } satisfies { readonly [key: string]: unknown }; } async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken, _context?: { readonly inputState: vscode.ChatSessionInputState; readonly sessionOptions: ReadonlyArray<{ optionId: string; value: string | vscode.ChatSessionProviderOptionItem }> }): Promise { @@ -575,6 +463,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements options: {}, }; } else { + this.newSessions.delete(resource); return await this.provideChatSessionContentForExistingSession(resource, token); } } finally { @@ -586,7 +475,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements const copilotcliSessionId = SessionIdForCLI.parse(resource); // Fire-and-forget: detect PR when the user opens a session. - void this.detectPullRequestOnSessionOpen(copilotcliSessionId); + this._prDetectionService.detectPullRequest(copilotcliSessionId); const folderRepo = await this.folderRepositoryManager.getFolderRepository(copilotcliSessionId, undefined, token); const [history, title] = await Promise.all([ @@ -622,300 +511,35 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } } - async provideChatSessionProviderOptionGroups(previousInputState: vscode.ChatSessionInputState | undefined): Promise { - const optionGroups: vscode.ChatSessionProviderOptionGroup[] = []; - const previouslySelectedIsolationOption = previousInputState?.groups.find(g => g.id === ISOLATION_OPTION_ID)?.selected; - if (isIsolationOptionFeatureEnabled(this.configurationService)) { - const lastUsed = this.context.globalState.get(LAST_USED_ISOLATION_OPTION_KEY, IsolationMode.Workspace); - const defaultSelection = lastUsed === IsolationMode.Workspace ? - { 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: [ - { 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: previouslySelectedIsolationOption ?? defaultSelection - }); - } - - // 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?.groups.find(g => g.id === REPOSITORY_OPTION_ID)?.selected; - 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?.groups.find(g => g.id === REPOSITORY_OPTION_ID)?.selected ?? repositories[0]; - defaultRepoUri = previouslySelected?.id ? vscode.Uri.file(previouslySelected.id) : defaultRepoUri; - optionGroups.push({ - id: REPOSITORY_OPTION_ID, - name: l10n.t('Folder'), - description: l10n.t('Pick Folder'), - items: repositories, - selected: previouslySelected ?? repositories[0] - }); - } else if (repositories.length === 1) { - defaultRepoUri = vscode.Uri.file(repositories[0].id); - } - } - - if ((isBranchOptionFeatureEnabled(this.configurationService))) { - // If we have a selected branch and it belongs to this repo, then use that as the default branch selection, - // //Else fall back to the repo's head branch, and if that doesn't exist use no default selection. - const repo = defaultRepoUri ? await this.gitService.getRepository(defaultRepoUri) : undefined; - const branches = repo ? await this.getBranchOptionItemsForRepository(repo.rootUri, repo.headBranchName) : []; - const previouslySelectedBranchItem = previousInputState?.groups.find(g => g.id === BRANCH_OPTION_ID)?.selected; - const activeBranch = repo?.headBranchName ? branches.find(branch => branch.id === repo.headBranchName) : undefined; - const selectedBranch = previouslySelectedBranchItem?.id || activeBranch?.id; - const selectedItem = (selectedBranch ? branches.find(branch => branch.id === selectedBranch) : undefined) ?? previouslySelectedBranchItem; - if (branches.length > 0) { - optionGroups.push({ - id: BRANCH_OPTION_ID, - name: l10n.t('Branch'), - description: l10n.t('Pick Branch'), - items: branches, - selected: selectedItem, - when: `chatSessionOption.${ISOLATION_OPTION_ID} == '${IsolationMode.Worktree}'` - }); - } - } - - return optionGroups; - } - - private async buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise { - 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.copilotCLIWorktreeManagerService.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; - 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: `chatSessionOption.${ISOLATION_OPTION_ID} == '${IsolationMode.Worktree}'` - }); - - return optionGroups; - } - - - private readonly _getBranchOptionItemsForRepositorySequencer = new SequencerByKey(); - private async getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise { - 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; - }); - } - - private 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(); - 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)); - } - public async trackLastUsedFolderInWelcomeView(folderUri: vscode.Uri) { - // Update MRU tracking for untitled workspaces - if (isWelcomeView(this.workspaceService)) { - const repository = await this.gitService.getRepository(folderUri); - if (repository) { - this._lastUsedFolderIdInUntitledWorkspace = { kind: 'repo', uri: repository.rootUri, lastAccessed: Date.now() }; - } else { - this._lastUsedFolderIdInUntitledWorkspace = { kind: 'folder', uri: folderUri, lastAccessed: Date.now() }; - } - } + public async updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise { + return this._optionGroupBuilder.updateInputStateAfterFolderSelection(inputState, folderUri); } } export class CopilotCLIChatSessionParticipant extends Disposable { constructor( - private readonly contentProvider: CopilotCLIChatSessionContentProvider, + private readonly sessionItemProvider: ICopilotCLIChatSessionItemProvider, private readonly promptResolver: CopilotCLIPromptResolver, private readonly cloudSessionProvider: CopilotCloudSessionsProvider | undefined, private readonly branchNameGenerator: GitBranchNameGenerator, @IGitService private readonly gitService: IGitService, - @ICopilotCLIModels private readonly copilotCLIModels: ICopilotCLIModels, - @ICopilotCLIAgents private readonly copilotCLIAgents: ICopilotCLIAgents, @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, @IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService, - @IChatSessionWorktreeCheckpointService private readonly copilotCLIWorktreeCheckpointService: IChatSessionWorktreeCheckpointService, - @IChatSessionWorkspaceFolderService private readonly workspaceFolderService: IChatSessionWorkspaceFolderService, @ITelemetryService private readonly telemetryService: ITelemetryService, @ILogService private readonly logService: ILogService, - @IPromptsService private readonly promptsService: IPromptsService, @IChatDelegationSummaryService private readonly chatDelegationSummaryService: IChatDelegationSummaryService, - @IFolderRepositoryManager private readonly folderRepositoryManager: IFolderRepositoryManager, @IConfigurationService private readonly configurationService: IConfigurationService, @ICopilotCLISDK private readonly copilotCLISDK: ICopilotCLISDK, - @IChatSessionMetadataStore private readonly chatSessionMetadataStore: IChatSessionMetadataStore, - @IOctoKitService private readonly octoKitService: IOctoKitService, - @IWorkspaceService private readonly workspaceService: IWorkspaceService, + @ICopilotCLIChatSessionInitializer private readonly sessionInitializer: ICopilotCLIChatSessionInitializer, + @ISessionRequestLifecycle private readonly sessionRequestLifecycle: ISessionRequestLifecycle, + @IPullRequestDetectionService private readonly prDetectionService: IPullRequestDetectionService, ) { super(); + + this._register(this.prDetectionService.onDidDetectPullRequest(sessionId => { + this.sessionItemProvider.refreshSession({ reason: 'update', sessionId }).catch(error => this.logService.error(error, 'Failed to refresh session after PR detection')); + })); } createHandler(): ChatExtendedRequestHandler { @@ -924,19 +548,6 @@ export class CopilotCLIChatSessionParticipant extends Disposable { private readonly contextForRequest = new Map(); - /** - * 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>(); - /** * Outer request handler that supports *yielding* for session steering. * @@ -1001,34 +612,70 @@ export class CopilotCLIChatSessionParticipant extends Disposable { }); } + private async authenticate(): Promise> { + const authInfo = await this.copilotCLISDK.getAuthInfo().catch((ex) => this.logService.error(ex, 'Authorization failed')); + if (!authInfo) { + this.logService.error(`Authorization failed`); + throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); + } + if ((authInfo.type === 'token' && !authInfo.token) && !this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl)) { + this.logService.error(`Authorization failed`); + throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); + } + return authInfo; + } + + /** + * Resolve the input and attachments for the SDK session based on request type. + * + * The VS Code chat API creates the session before firing the request handler, + * so delegated requests pre-resolve and cache prompt/attachments in `contextForRequest`. + */ + private async resolveInput( + request: vscode.ChatRequest, + session: ICopilotCLISession, + isNewSession: boolean, + token: vscode.CancellationToken, + ): Promise<{ input: { prompt: string; command?: CopilotCLICommand }; attachments: Attachment[] }> { + const contextForRequest = this.contextForRequest.get(session.sessionId); + this.contextForRequest.delete(session.sessionId); + + if (contextForRequest) { + return { input: { prompt: contextForRequest.prompt }, attachments: contextForRequest.attachments }; + } + + if (request.command && !request.prompt && !isNewSession) { + const input = (copilotCLICommands as readonly string[]).includes(request.command) + ? { command: request.command as CopilotCLICommand, prompt: '' } + : { prompt: `/${request.command}` }; + return { input, attachments: [] }; + } + + const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.workspace, [], token); + const input = (request.command && (copilotCLICommands as readonly string[]).includes(request.command)) + ? { command: request.command as CopilotCLICommand, prompt } + : { prompt }; + return { input, attachments }; + } + private async handleRequestImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { const { chatSessionContext } = context; const disposables = new DisposableStore(); - let sessionId: string | undefined = undefined; let sdkSessionId: string | undefined = undefined; + let session: IReference | undefined = undefined; try { this.sendTelemetryForHandleRequest(request, context); - const [authInfo,] = await Promise.all([this.copilotCLISDK.getAuthInfo().catch((ex) => this.logService.error(ex, 'Authorization failed')), this.lockRepoOptionForSession(context, token)]); - if (!authInfo) { - this.logService.error(`Authorization failed`); - throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); - } - if ((authInfo.type === 'token' && !authInfo.token) && !this.configurationService.getConfig(ConfigKey.Shared.DebugOverrideProxyUrl)) { - this.logService.error(`Authorization failed`); - throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); - } + const authInfo = await this.authenticate(); if (!chatSessionContext) { - // Delegating from another chat session return await this.handleDelegationFromAnotherChat(request, undefined, request.references, context, stream, authInfo, token); } const { resource } = chatSessionContext.chatSessionItem; - const id = SessionIdForCLI.parse(resource); - sessionId = id; - const isNewSession = this.sessionService.isNewSessionId(id); - const invalidSessionMessage = _invalidCopilotCLISessionIdsWithErrorMessage.get(id); + const sessionId = SessionIdForCLI.parse(resource); + const isNewSession = this.sessionService.isNewSessionId(sessionId); + const invalidSessionMessage = _invalidCopilotCLISessionIdsWithErrorMessage.get(sessionId); if (invalidSessionMessage) { const { issueUrl } = getSessionLoadFailureIssueInfo(invalidSessionMessage); const warningMessage = new vscode.MarkdownString(); @@ -1048,425 +695,57 @@ export class CopilotCLIChatSessionParticipant extends Disposable { yieldRequested: false, }; const branchNamePromise = isNewSession && request.prompt ? this.branchNameGenerator.generateBranchName(fakeContext, token) : Promise.resolve(undefined); - const [model, agent] = await Promise.all([ - this.getModelId(request, token), - this.getAgent(id, request, token), - ]); - const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { model, agent, branchName: branchNamePromise }, disposables, token); - const session = sessionResult.session; - if (session) { - disposables.add(session); - } + const sessionResult = await this.getOrCreateSession(request, chatSessionContext, stream, { branchName: branchNamePromise }, disposables, token); + ({ session } = sessionResult); + const { model } = sessionResult; if (!session || token.isCancellationRequested) { - // If user didn't trust, then reset the session options to make it read-write. - if (!sessionResult.trusted) { - await this.unlockRepoOptionForSession(context, token); - } return {}; } - if (context.history.length === 0) { - // Create baseline checkpoint when handling the first request - await this.copilotCLIWorktreeCheckpointService.handleRequest(session.object.sessionId); - } - sdkSessionId = session.object.sessionId; - 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')); - // Lock the repo option with more accurate information. - // Previously we just updated it with details of the folder. - // If user has selected a repo, then update with repo information (right icons, etc). - if (isNewSession) { - void this.lockRepoOptionForSession(context, token); - // The session has been created and initialized with workspace information, - // No need to track the temproary workspace folders as its been persisted. - // this.folderRepositoryManager.deleteNewSessionFolder(id); - } - const requestsForSession = this.pendingRequestBySession.get(session.object.sessionId) ?? new Set(); - requestsForSession.add(request); - this.pendingRequestBySession.set(session.object.sessionId, requestsForSession); + await this.sessionRequestLifecycle.startRequest(sdkSessionId, request, context.history.length === 0); - // Check if we have context stored for this request (created in createCLISessionAndSubmitRequest, work around) - const contextForRequest = this.contextForRequest.get(session.object.sessionId); - this.contextForRequest.delete(session.object.sessionId); if (request.command === 'delegate') { await this.handleDelegationToCloud(session.object, request, context, stream, token); - } else if (contextForRequest) { - // This is a request that was created in createCLISessionAndSubmitRequest with attachments already resolved. - const { prompt, attachments } = contextForRequest; - this.contextForRequest.delete(session.object.sessionId); - await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token); - await this.commitWorktreeChangesIfNeeded(request, session.object, token); - } else if (request.command && !request.prompt && !isNewSession) { - const input = (copilotCLICommands as readonly string[]).includes(request.command) - ? { command: request.command as CopilotCLICommand, prompt: '' } - : { prompt: `/${request.command}` }; - await session.object.handleRequest(request, input, [], model, authInfo, token); - await this.commitWorktreeChangesIfNeeded(request, session.object, token); - } else if (request.prompt && Object.values(builtinSlashSCommands).some(command => request.prompt.startsWith(command))) { - // Sessions app built-in slash commands - const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token); - await session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token); - await this.commitWorktreeChangesIfNeeded(request, session.object, token); } else { - // Construct the full prompt with references to be sent to CLI. - const { prompt, attachments } = await this.promptResolver.resolvePrompt(request, undefined, [], session.object.workspace, [], token); - const input = (request.command && (copilotCLICommands as readonly string[]).includes(request.command)) - ? { command: request.command as CopilotCLICommand, prompt } - : { prompt: prompt }; + const { input, attachments } = await this.resolveInput(request, session.object, isNewSession, token); await session.object.handleRequest(request, input, attachments, model, authInfo, token); - await this.commitWorktreeChangesIfNeeded(request, session.object, token); } - // No need to delay handling the request, we can refresh in background. - this.contentProvider.refreshSession({ reason: 'update', sessionId: session.object.sessionId }).catch(error => this.logService.error(error, 'Failed to refresh session item after handling request')); return {}; } catch (ex) { if (isCancellationError(ex)) { return {}; } throw ex; - } - finally { - if (sdkSessionId) { - const requestsForSession = this.pendingRequestBySession.get(sdkSessionId); - if (requestsForSession) { - requestsForSession.delete(request); - if (requestsForSession.size === 0) { - this.pendingRequestBySession.delete(sdkSessionId); - } - } + } finally { + if (sdkSessionId && session) { + await this.sessionRequestLifecycle.endRequest( + sdkSessionId, request, + { status: session.object.status, workspace: session.object.workspace, createdPullRequestUrl: session.object.createdPullRequestUrl }, + token, + ); + this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: sdkSessionId }) + .catch(error => this.logService.error(error, 'Failed to refresh session item after handling request')); } disposables.dispose(); } } - private async lockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) { - // const { chatSessionContext } = context; - // if (!chatSessionContext?.chatSessionItem?.resource) { - // return; - // } - // const { resource } = chatSessionContext.chatSessionItem; - // const id = SessionIdForCLI.parse(resource); - // if (!this.sessionService.isNewSessionId(id)) { - // return; - // } - // const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token); - // if (folderInfo.folder) { - // const folderName = basename(folderInfo.folder); - // const option = folderInfo.repository ? toRepositoryOptionItem(folderInfo.repository) : toWorkspaceFolderOptionItem(folderInfo.folder, folderName); - // const changes: { optionId: string; value: string | vscode.ChatSessionProviderOptionItem }[] = [ - // { optionId: REPOSITORY_OPTION_ID, value: { ...option, locked: true } } - // ]; - // // Also lock the branch option - // const selectedBranch = folderInfo.worktreeProperties?.branchName ?? _sessionBranch.get(id); - // if (selectedBranch && isBranchOptionFeatureEnabled(this.configurationService)) { - // changes.push({ - // optionId: BRANCH_OPTION_ID, - // value: { - // id: selectedBranch, - // name: selectedBranch, - // icon: new vscode.ThemeIcon('git-branch'), - // locked: true - // } - // }); - // } - // // Also lock the isolation option if set - // const selectedIsolation = _sessionIsolation.get(id); - // if (selectedIsolation && isIsolationOptionFeatureEnabled(this.configurationService)) { - // changes.push({ - // optionId: ISOLATION_OPTION_ID, - // value: { - // id: selectedIsolation, - // name: selectedIsolation === IsolationMode.Worktree - // ? l10n.t('Worktree') - // : l10n.t('Workspace'), - // icon: new vscode.ThemeIcon(selectedIsolation === IsolationMode.Worktree ? 'worktree' : 'folder'), - // locked: true - // } - // }); - // } - // this.contentProvider.notifySessionOptionsChange(resource, changes); - // } - } - - private async unlockRepoOptionForSession(context: vscode.ChatContext, token: vscode.CancellationToken) { - // const { chatSessionContext } = context; - // if (!chatSessionContext?.chatSessionItem?.resource) { - // return; - // } - // const { resource } = chatSessionContext.chatSessionItem; - // const id = SessionIdForCLI.parse(resource); - // if (!this.sessionService.isNewSessionId(id)) { - // return; - // } - // const folderInfo = await this.folderRepositoryManager.getFolderRepository(id, undefined, token); - // if (folderInfo.folder) { - // const option = folderInfo.repository?.fsPath ?? folderInfo.folder.fsPath; - // const changes: { optionId: string; value: string }[] = [ - // { optionId: REPOSITORY_OPTION_ID, value: option } - // ]; - // // Also unlock the branch option if a branch was selected - // const selectedBranch = _sessionBranch.get(id); - // if (selectedBranch && isBranchOptionFeatureEnabled(this.configurationService)) { - // changes.push({ optionId: BRANCH_OPTION_ID, value: selectedBranch }); - // } - // // Also unlock the isolation option if set - // const selectedIsolation = _sessionIsolation.get(id); - // if (selectedIsolation && isIsolationOptionFeatureEnabled(this.configurationService)) { - // changes.push({ optionId: ISOLATION_OPTION_ID, value: selectedIsolation }); - // } - // this.contentProvider.notifySessionOptionsChange(resource, changes); - // } - } - - private async commitWorktreeChangesIfNeeded(request: vscode.ChatRequest, session: ICopilotCLISession, token: vscode.CancellationToken): Promise { - const pendingRequests = this.pendingRequestBySession.get(session.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; + private async getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { branchName: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; isNewSession: boolean; model: { model: string; reasoningEffort?: string } | undefined; trusted: boolean }> { + const result = await this.sessionInitializer.getOrCreateSession(request, chatSessionContext, stream, options, disposables, token); + const { session, isNewSession, model, trusted } = result; + if (!session || token.isCancellationRequested) { + return { session: undefined, isNewSession, model, trusted }; } - if (token.isCancellationRequested) { - pendingRequests?.delete(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.copilotCLIWorktreeManagerService.handleRequestCompleted(session.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(session.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.copilotCLIWorktreeCheckpointService.handleRequestCompleted(session.sessionId, request.id); - } - - void this.handlePullRequestCreated(session).catch(ex => this.logService.error(ex, 'Failed to handle pull request creation')); - } finally { - pendingRequests?.delete(request); - } - } - - private static readonly _PR_DETECTION_RETRY_COUNT = 5; - private static readonly _PR_DETECTION_INITIAL_DELAY_MS = 2_000; - - private async handlePullRequestCreated(session: ICopilotCLISession): Promise { - const sessionId = session.sessionId; - let prUrl = session.createdPullRequestUrl; - let prState = ''; - - this.logService.debug(`[CopilotCLIChatSessionParticipant] handlePullRequestCreated for ${sessionId}: createdPullRequestUrl=${prUrl ?? 'none'}`); - - const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties || worktreeProperties.version !== 2) { - return; - } - - if (!prUrl) { - // Only attempt retry detection if the session has v2 worktree properties - // with branch info — v1 worktrees can't store PR URLs, and sessions - // without worktree properties have nothing to look up. - if (worktreeProperties.branchName && worktreeProperties.repositoryPath) { - this.logService.debug(`[CopilotCLIChatSessionParticipant] 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(`[CopilotCLIChatSessionParticipant] Skipping retry detection for ${sessionId}: branch=${worktreeProperties.branchName ?? 'none'}, repoPath=${!!worktreeProperties.repositoryPath}`); - } - } - - if (!prUrl) { - this.logService.debug(`[CopilotCLIChatSessionParticipant] No PR detected for ${sessionId} after all attempts`); - return; - } - - try { - await this.copilotCLIWorktreeManagerService.setWorktreeProperties(sessionId, { - ...worktreeProperties, - pullRequestUrl: prUrl, - pullRequestState: prState, - changes: undefined, - }); - await this.contentProvider.refreshSession({ reason: 'update', sessionId: session.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> { - const maxRetries = CopilotCLIChatSessionParticipant._PR_DETECTION_RETRY_COUNT; - const initialDelay = CopilotCLIChatSessionParticipant._PR_DETECTION_INITIAL_DELAY_MS; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - const delay = initialDelay * Math.pow(2, attempt); - this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detection retry for ${sessionId}: attempt ${attempt + 1}/${maxRetries}, waiting ${delay}ms`); - await new Promise(resolve => setTimeout(resolve, delay)); - - const prResult = await this.detectPullRequestForSession(sessionId); - if (prResult) { - this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detected on attempt ${attempt + 1} for ${sessionId}: url=${prResult.url}, state=${prResult.state}`); - return prResult; - } - } - - this.logService.debug(`[CopilotCLIChatSessionParticipant] PR detection exhausted all ${maxRetries} retries for ${sessionId}`); - return undefined; - } - - - /** - * Queries the GitHub API to find a pull request whose head branch matches the - * session's 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). - */ - private async detectPullRequestForSession(sessionId: string): Promise<{ url: string; state: string } | undefined> { - try { - const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(sessionId); - if (!worktreeProperties?.branchName || !worktreeProperties.repositoryPath) { - this.logService.debug(`[CopilotCLIChatSessionParticipant] 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(`[CopilotCLIChatSessionParticipant] Failed to detect pull request via GitHub API: ${error instanceof Error ? error.message : String(error)}`); - return undefined; - } - } - - /** - * Gets the agent to be used. - * If the request has a prompt file (modeInstructions2) that specifies an agent, uses that agent. - * If the prompt file specifies tools, those tools override the agent's default tools. - * Otherwise returns undefined (no agent). - */ - private async getAgent(sessionId: string | undefined, request: vscode.ChatRequest | undefined, token: vscode.CancellationToken): Promise { - // If we have a prompt file that specifies an agent or tools, use that. - 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; - } - } - // If not found, don't use any agent, default to empty agent. - return undefined; - } - - private async getPromptInfoFromRequest(request: vscode.ChatRequest, token: vscode.CancellationToken): Promise { - 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 getOrCreateSession(request: vscode.ChatRequest, chatSessionContext: vscode.ChatSessionContext, stream: vscode.ChatResponseStream, options: { model: { model: string; reasoningEffort?: string } | undefined; agent: SweCustomAgent | undefined; branchName: Promise }, disposables: DisposableStore, token: vscode.CancellationToken): Promise<{ session: IReference | undefined; trusted: boolean }> { - const { resource } = chatSessionContext.chatSessionItem; - const sessionId = SessionIdForCLI.parse(resource); - const isNewSession = this.sessionService.isNewSessionId(sessionId); - - const { workspaceInfo, cancelled, trusted } = await this.getOrInitializeWorkingDirectory(chatSessionContext, undefined, options.branchName, stream, request.toolInvocationToken, token); - const workingDirectory = getWorkingDirectory(workspaceInfo); - const worktreeProperties = workspaceInfo.worktreeProperties; - if (cancelled || token.isCancellationRequested) { - return { session: undefined, trusted }; - } - - const model = options.model?.model; - const agent = options.agent; - const reasoningEffort = options.model?.reasoningEffort; - const debugTargetSessionIds = extractDebugTargetSessionIds(request.references); - const mcpServerMappings = buildMcpServerMappings(request.tools); - const session = isNewSession ? - await this.sessionService.createSession({ sessionId, model, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings, reasoningEffort }, token) : - await this.sessionService.getSession({ sessionId, model, workspace: workspaceInfo, agent, debugTargetSessionIds, mcpServerMappings, reasoningEffort }, token); - - if (!session) { - stream.warning(l10n.t('Chat session not found.')); - return { session: undefined, trusted }; - } - this.logService.info(`Using Copilot CLI session: ${session.object.sessionId} (isNewSession: ${isNewSession}, isolationEnabled: ${isIsolationEnabled(workspaceInfo)}, workingDirectory: ${workingDirectory}, worktreePath: ${worktreeProperties?.worktreePath})`); if (isNewSession) { - this.contentProvider.refreshSession({ reason: 'update', sessionId: session.object.sessionId }); - if (worktreeProperties) { - void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties); - } + this.sessionItemProvider.refreshSession({ reason: 'update', sessionId: session.object.sessionId }); } - const sessionWorkingDirectory = getWorkingDirectory(session.object.workspace); - if (sessionWorkingDirectory && !isIsolationEnabled(session.object.workspace)) { - void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, sessionWorkingDirectory.fsPath, session.object.workspace.repositoryProperties); - } - disposables.add(session.object.attachStream(stream)); - const permissionLevel = request.permissionLevel; - session.object.setPermissionLevel(permissionLevel); - return { session, trusted }; - } - - private async getModelId(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 getModelFromPromptFile(promptFile.header.model, this.copilotCLIModels) : 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 }; + return { session, isNewSession, model, trusted }; } private async handleDelegationToCloud(session: ICopilotCLISession, request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { @@ -1490,71 +769,6 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } - private async getOrInitializeWorkingDirectory( - chatSessionContext: vscode.ChatSessionContext | undefined, - isolation: IsolationMode | undefined, - branchName: Promise | 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 }; - } - - 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 handleDelegationFromAnotherChat( request: vscode.ChatRequest, userPrompt: string | undefined, @@ -1577,37 +791,22 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return summary ? `${userPrompt}\n${summary}` : userPrompt; })(); - const [{ workspaceInfo, cancelled }, model, agent] = await Promise.all([ - this.getOrInitializeWorkingDirectory(undefined, undefined, undefined, stream, request.toolInvocationToken, token), - this.getModelId(request, token), // prefer model in request, as we're delegating from another session here. - this.getAgent(undefined, undefined, token) - ]); + const { workspaceInfo, cancelled } = await this.sessionInitializer.initializeWorkingDirectory(undefined, undefined, undefined, stream, request.toolInvocationToken, token); if (cancelled || token.isCancellationRequested) { stream.markdown(l10n.t('Copilot CLI delegation cancelled.')); return {}; } - const workingDirectory = getWorkingDirectory(workspaceInfo); - const worktreeProperties = workspaceInfo.worktreeProperties; const { prompt, attachments, references } = await this.promptResolver.resolvePrompt(request, await requestPromptPromise, (otherReferences || []).concat([]), workspaceInfo, [], token); const mcpServerMappings = buildMcpServerMappings(request.tools); - const session = await this.sessionService.createSession({ workspace: workspaceInfo, agent, model: model?.model, mcpServerMappings }, token); - 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')); + const { session, model } = await this.sessionInitializer.createDelegatedSession(request, workspaceInfo, { mcpServerMappings }, token); if (summary) { const summaryRef = await this.chatDelegationSummaryService.trackSummaryUsage(session.object.sessionId, summary); if (summaryRef) { references.push(summaryRef); } } - // Do not await, we want this code path to be as fast as possible. - if (worktreeProperties) { - void this.copilotCLIWorktreeManagerService.setWorktreeProperties(session.object.sessionId, worktreeProperties); - } - if (workingDirectory && !isIsolationEnabled(workspaceInfo)) { - void this.workspaceFolderService.trackSessionWorkspaceFolder(session.object.sessionId, workingDirectory.fsPath, workspaceInfo.repositoryProperties); - } try { this.contextForRequest.set(session.object.sessionId, { prompt, attachments }); @@ -1622,7 +821,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } catch { this.contextForRequest.delete(session.object.sessionId); session.object.handleRequest(request, { prompt }, attachments, model, authInfo, token) - .then(() => this.commitWorktreeChangesIfNeeded(request, session.object, token)) + .then(() => this.sessionRequestLifecycle.endRequest(session.object.sessionId, request, { status: session.object.status, workspace: session.object.workspace, createdPullRequestUrl: session.object.createdPullRequestUrl }, token)) .catch(error => { this.logService.error(`Failed to handle CLI session request: ${error}`); }) @@ -1670,7 +869,7 @@ export function registerCLIChatCommands( copilotCliWorkspaceSession: IChatSessionWorkspaceFolderService, contentProvider: CopilotCLIChatSessionContentProvider, folderRepositoryManager: IFolderRepositoryManager, - copilotCLIFolderMruService: ICopilotCLIFolderMruService, + copilotCLIFolderMruService: IChatFolderMruService, envService: INativeEnvService, fileSystemService: IFileSystemService, sessionTracker: ICopilotCLISessionTracker, @@ -1678,6 +877,12 @@ export function registerCLIChatCommands( logService: ILogService ): IDisposable { const disposableStore = new DisposableStore(); + + // Terminal integration setup: resolve session dirs for terminal links. + disposableStore.add(terminalIntegration); + terminalIntegration.setSessionDirResolver(terminal => + resolveSessionDirsForTerminal(sessionTracker, terminal) + ); disposableStore.add(vscode.commands.registerCommand('github.copilot.cli.sessions.delete', async (sessionItem?: vscode.ChatSessionItem) => { if (sessionItem?.resource) { const id = SessionIdForCLI.parse(sessionItem.resource); @@ -1876,10 +1081,10 @@ export function registerCLIChatCommands( return undefined; } - disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (sessionItemResource?: vscode.Uri) => { - if (!sessionItemResource) { - return; - } + // Command handler receives `{ inputState, sessionResource }` context args (new API) + disposableStore.add(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, async (contextArg?: { inputState: vscode.ChatSessionInputState; sessionResource: vscode.Uri | undefined } | vscode.Uri) => { + // Support both new API shape and legacy Uri shape for backward compat + const inputState = contextArg && !isUri(contextArg) ? contextArg.inputState : undefined; let selectedFolderUri: Uri | undefined = undefined; const mruItems = await copilotCLIFolderMruService.getRecentlyUsedFolders(CancellationToken.None); @@ -1972,9 +1177,15 @@ export function registerCLIChatCommands( return; } - const sessionId = SessionIdForCLI.parse(sessionItemResource); - if (copilotCLISessionService.isNewSessionId(sessionId)) { - await contentProvider.trackLastUsedFolderInWelcomeView(selectedFolderUri); + // // We need to check trust now, as we need to determine whether this is a Git repo or not. + // // Using the relevant services to check if its a git repo result in checking trust as well, might as well check now instead of complicating code later to handle both trusted and untrusted cases. + // if (!(await vscode.workspace.isResourceTrusted(selectedFolderUri))) { + // return; + // } + + // Update inputState groups with newly selected folder and reload branches + if (inputState) { + await contentProvider.updateInputStateAfterFolderSelection(inputState, selectedFolderUri); } })); @@ -2413,37 +1624,6 @@ export function registerCLIChatCommands( return disposableStore; } -async function getModelFromPromptFile(models: readonly string[], copilotCLIModels: ICopilotCLIModels): Promise { - for (const model of models) { - let modelId = await 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 copilotCLIModels.resolveModel(model.substring(0, model.indexOf('(')).trim()); - if (modelId) { - return modelId; - } - } - return undefined; -} - - -function folderMRUToChatProviderOptions(mruItems: FolderRepositoryMRUEntry[]): ChatSessionProviderOptionItem[] { - return mruItems.map((item) => { - if (item.repository) { - return toRepositoryOptionItem(item.folder); - } else { - return toWorkspaceFolderOptionItem(item.folder, basename(item.folder)); - } - }); - -} - - /** * Check if a path exists and is a directory. */ @@ -2460,46 +1640,3 @@ function isUnknownEventTypeError(error: unknown): boolean { const message = error instanceof Error ? error.message : String(error); return /Unknown event type:/i.test(message); } - -/** - * 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; -} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index cad6cd9cb92..f8db18505e4 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -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 { + public async refreshSession(refreshOptions: { reason: 'update'; sessionId: string } | { reason: 'update'; sessionIds: string[] } | { reason: 'delete'; sessionId: string }): Promise { 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 diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestDetectionService.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestDetectionService.ts new file mode 100644 index 00000000000..b8bba1239c7 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/pullRequestDetectionService.ts @@ -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; + + /** + * 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'); + +/** + * 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()); + readonly onDidDetectPullRequest: Event = 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 { + 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 { + 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(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; + } + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts new file mode 100644 index 00000000000..7a32f53c790 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionOptionGroupBuilder.ts @@ -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( + 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; + buildBranchOptionGroup(branches: vscode.ChatSessionProviderOptionItem[], headBranchName: string | undefined, isolationEnabled: boolean, currentIsolation: IsolationMode | undefined, previousSelection: vscode.ChatSessionProviderOptionItem | undefined): vscode.ChatSessionProviderOptionGroup | undefined; + handleInputStateChange(state: vscode.ChatSessionInputState): Promise; + buildExistingSessionInputStateGroups(resource: vscode.Uri, token: vscode.CancellationToken): Promise; + getBranchOptionItemsForRepository(repoUri: Uri, headBranchName: string | undefined): Promise; + getRepositoryOptionItems(): vscode.ChatSessionProviderOptionItem[]; + updateInputStateAfterFolderSelection(inputState: vscode.ChatSessionInputState, folderUri: vscode.Uri): Promise; +} +export const ISessionOptionGroupBuilder = createServiceIdentifier('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(); + + 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 { + 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(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 { + 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 { + 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 { + 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(); + 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 { + 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; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts new file mode 100644 index 00000000000..e9e898cab30 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/sessionRequestLifecycle.ts @@ -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; + + /** + * 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; +} + +export interface SessionCompletionInfo { + readonly status: vscode.ChatSessionStatus | undefined; + readonly workspace: IWorkspaceInfo; + readonly createdPullRequestUrl: string | undefined; +} + +export const ISessionRequestLifecycle = createServiceIdentifier('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>(); + + 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 { + if (isFirstRequest) { + await this.checkpointService.handleRequest(sessionId); + } + + const requests = this.pendingRequestBySession.get(sessionId) ?? new Set(); + requests.add(request); + this.pendingRequestBySession.set(sessionId, requests); + } + + async endRequest(sessionId: string, request: vscode.ChatRequest, session: SessionCompletionInfo, token: vscode.CancellationToken): Promise { + 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); + } + } + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts new file mode 100644 index 00000000000..a3069904ffb --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/chatSessionInitializer.spec.ts @@ -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() { + declare readonly _serviceBrand: undefined; + override isNewSessionId = vi.fn(() => true); + override createSession = vi.fn(async (): Promise> => ({ + object: makeSessionObject(), + dispose: vi.fn(), + })); + override getSession = vi.fn(async (): Promise | undefined> => ({ + object: makeSessionObject(), + dispose: vi.fn(), + })); +} + +class TestFolderRepositoryManager extends mock() { + declare readonly _serviceBrand: undefined; + override initializeFolderRepository = vi.fn(async (): Promise => ({ + 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 => ({ + folder: URI.file('/workspace') as unknown as vscode.Uri, + repository: undefined, + repositoryProperties: undefined, + worktree: undefined, + worktreeProperties: undefined, + trusted: true, + })); +} + +class TestWorktreeService extends mock() { + declare readonly _serviceBrand: undefined; + override setWorktreeProperties = vi.fn(async () => { }); +} + +class TestWorkspaceFolderService extends mock() { + declare readonly _serviceBrand: undefined; + override trackSessionWorkspaceFolder = vi.fn(async () => { }); +} + +class TestModels extends mock() { + 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() { + declare readonly _serviceBrand: undefined; + override resolveAgent = vi.fn(async (): Promise => undefined); +} + +class TestPromptsService extends mock() { + declare readonly _serviceBrand: undefined; + override parseFile = vi.fn(async () => ({ uri: URI.file('/test.prompt'), header: undefined, body: undefined })); +} + +class TestMetadataStore extends mock() { + declare readonly _serviceBrand: undefined; + override updateRequestDetails = vi.fn(async () => { }); +} + +class TestConfigurationService extends mock() { + declare readonly _serviceBrand: undefined; + override getConfig = vi.fn(() => undefined as any); +} + +class TestLogService extends mock() { + 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 { + 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 { + 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); + 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); + 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); + 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); + 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); + + 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); + + 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); + + 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); + + 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(); + }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts index f1b3ebbec45..d0fd00fa9ec 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessionParticipant.spec.ts @@ -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())(), + new (mock())(), ); const invalidParticipant = new CopilotCLIChatSessionParticipant( invalidContentProvider, diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts index 1585d75c001..ebca9047fee 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/copilotCLIChatSessions.spec.ts @@ -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).workspace = { + ...((vscodeShim as Record).workspace as object), + workspaceFolders: [], + isAgentSessionsWorkspace: false, + isResourceTrusted: async () => true, + }; }); class TestSessionService extends mock() { @@ -63,7 +64,7 @@ class TestSessionService extends mock() { 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() { override findPullRequestByHeadBranch = vi.fn(async (): Promise => undefined); } -class TestSessionTracker extends mock() { - 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() { declare readonly _serviceBrand: undefined; override executeCommand = vi.fn(async () => undefined); @@ -166,15 +154,14 @@ class TestCustomSessionTitleService extends mock() { function createProvider() { const sessionService = new TestSessionService(); const worktreeService = new TestWorktreeService(); - const workspaceService = new NullWorkspaceService([URI.file('/workspace')]); - const metadataStore = new class extends mock() { }; + const metadataStore = new class extends mock() { + 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() { + 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() { override get isAgentSessionsWorkspace() { return false; } }, - new (mock())(), + 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(); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/pullRequestDetectionService.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/pullRequestDetectionService.spec.ts new file mode 100644 index 00000000000..64e710f2d35 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/pullRequestDetectionService.spec.ts @@ -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() { + declare readonly _serviceBrand: undefined; + override getWorktreeProperties = vi.fn(async (): Promise => undefined); + override setWorktreeProperties = vi.fn(async () => { }); +} + +class TestGitService extends mock() { + 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 => this.repositories[0]); + + setRepo(repo: RepoContext): void { + this.repositories = [repo]; + } +} + +class TestOctoKitService extends mock() { + declare readonly _serviceBrand: undefined; + override findPullRequestByHeadBranch = vi.fn(async (): Promise => undefined); +} + +class TestLogService extends mock() { + declare readonly _serviceBrand: undefined; + override trace = vi.fn(); + override debug = vi.fn(); + override error = vi.fn(); +} + +function createV2WorktreeProperties(overrides?: Partial): ChatSessionWorktreeProperties { + return { + version: 2, + baseCommit: 'abc123', + baseBranchName: 'main', + branchName: 'copilot/test-branch', + repositoryPath: '/repo', + worktreePath: '/worktree', + ...overrides, + } as ChatSessionWorktreeProperties; +} + +function createPrSearchItem(overrides?: Partial): 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([]); + }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts new file mode 100644 index 00000000000..ff867b7a32e --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionOptionGroupBuilder.spec.ts @@ -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).workspace = { + ...((vscodeShim as Record).workspace as object), + isResourceTrusted: async () => true, + }; +}); + +// ─── Test Helpers ──────────────────────────────────────────────── + +class TestGitService extends mock() { + 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 => this.repositories[0]); + override getRefs = vi.fn(async () => [] as { name: string | undefined; type: number }[]); +} + +class TestFolderMruService extends mock() { + declare readonly _serviceBrand: undefined; + override getRecentlyUsedFolders = vi.fn(async () => [] as FolderRepositoryMRUEntry[]); + override deleteRecentlyUsedFolder = vi.fn(async () => { }); +} + +class TestWorktreeService extends mock() { + declare readonly _serviceBrand: undefined; + override getWorktreeProperties = vi.fn(async (): Promise => undefined); +} + +class TestFolderRepositoryManager extends mock() { + 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(); + 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).workspace; + (vscodeShim as Record).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).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(); + }); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts new file mode 100644 index 00000000000..eca0089c739 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/sessionRequestLifecycle.spec.ts @@ -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() { + declare readonly _serviceBrand: undefined; + override handleRequestCompleted = vi.fn(async () => { }); +} + +class TestCheckpointService extends mock() { + declare readonly _serviceBrand: undefined; + override handleRequest = vi.fn(async () => { }); + override handleRequestCompleted = vi.fn(async () => { }); +} + +class TestWorkspaceFolderService extends mock() { + declare readonly _serviceBrand: undefined; + override handleRequestCompleted = vi.fn(async () => { }); +} + +class TestPrDetectionService extends mock() { + 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 { + 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 { + 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(); + }); + }); +});