From d0945a99feeb9ac3cc6564f71fc69920461932e2 Mon Sep 17 00:00:00 2001 From: David O'Regan Date: Thu, 16 Apr 2026 20:03:00 -0700 Subject: [PATCH 1/3] Align Cloud Agent with Mission Control PR-decouple behavior The VS Code Cloud Agent experience was out of sync with Mission Control's CCA PR-decoupling flow. This aligns VS Code with MC behavior: - Drop the delegate / commit / push modal. Codex and Claude don't show one; Mission Control doesn't either. Permissive GitHub auth now uses the standard non-modal sign-in prompt via createIfNone. - Send the raw user prompt as problem_statement (no chat-history summary injection). This fixes the 'agent invents its own prompt' behavior Luke reported. - Send a minimal pull_request block (title/base_ref/[head_ref]) to match what sweagentd + Mission Control send. Drop body_placeholder and the 'Created from VS Code' body_suffix. - Drop the pre-delegation git preflight (uncommitted-changes detection, remote base-branch check, branch push). delegate() already falls back to the repo's default_branch when base_ref is not supplied. - Delete the now-unreachable CopilotCloudGitOperationsManager and the unused body_suffix / formatBodyPlaceholder helpers. Server-side verified: sweagentd v1 API (docs/adr/0001-create-job-api.md) makes pull_request optional and returns job_id + session_id before the PR exists; Mission Control's sweagentd client sends the minimal pull_request shape used here. Full no-PR (non-PR) workflow is on the sweagentd roadmap; this change is compatible with it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copilotCloudGitOperationsManager.ts | 231 ------------ .../copilotCloudSessionsProvider.ts | 347 ++---------------- .../vscode/copilotCodingAgentUtils.ts | 5 - 3 files changed, 28 insertions(+), 555 deletions(-) delete mode 100644 src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts diff --git a/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts b/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts deleted file mode 100644 index e621a9397d..0000000000 --- a/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts +++ /dev/null @@ -1,231 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as vscode from 'vscode'; -import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService'; -import { IGitService } from '../../../platform/git/common/gitService'; -import { Repository } from '../../../platform/git/vscode/git'; -import { ILogService } from '../../../platform/log/common/logService'; -import { getRepoId } from '../vscode/copilotCodingAgentUtils'; - -export interface GitRepoInfo { - repository: Repository; - remoteName: string; - baseRef: string; -} - -export class CopilotCloudGitOperationsManager { - constructor( - private readonly logService: ILogService, - private readonly gitService: IGitService, - private readonly gitExtensionService: IGitExtensionService - ) { } - - async repoInfo(): Promise { - // TODO: support selecting remote - // await this.promptAndUpdatePreferredGitHubRemote(true); - const repoIds = await getRepoId(this.gitService); - if (!repoIds || repoIds.length === 0) { - throw new Error(vscode.l10n.t('Repository information is not available. Open a GitHub repository to continue with cloud agent.')); - } - const repoId = repoIds[0]; - const currentRepository = this.gitService.activeRepository.get(); - if (!currentRepository) { - throw new Error(vscode.l10n.t('No active repository found. Open a GitHub repository to continue with cloud agent.')); - } - const git = this.gitExtensionService.getExtensionApi(); - const repo = git?.getRepository(currentRepository?.rootUri); - // Checks if user has permission to access the repository - if (!repo) { - throw new Error( - vscode.l10n.t( - 'Unable to access {0}. Please check your permissions and try again.', - `\`${repoId.org}/${repoId.repo}\`` - ) - ); - } - return { - repository: repo, - remoteName: repo.state.HEAD?.upstream?.remote ?? currentRepository.upstreamRemote ?? repo.state.remotes?.[0]?.name ?? 'origin', - baseRef: currentRepository.headBranchName ?? 'main' - }; - } - - /** - * Pushes the current ref to the remote - * @returns The name of the pushed branch - */ - async pushBaseRefToRemote(): Promise { - try { - const { repository, remoteName, baseRef } = await this.repoInfo(); - const expectedRemoteBranch = `${remoteName}/${baseRef}`; - this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote. Pushing...`); - await repository.push(remoteName, baseRef, true); - return baseRef; - } catch (error) { - this.logService.error(`Failed to push base ref to remote: ${error instanceof Error ? error.message : String(error)}`); - throw new Error(vscode.l10n.t('Failed to push base branch to remote. Please push the branch manually and try again.')); - } - } - - async checkIfRemoteHasRef(repository: Repository, remoteName: string, baseRef: string): Promise { - const remoteBranches = - (await repository.getBranches({ remote: true })) - .filter(b => b.remote); // Has an associated remote - const expectedRemoteBranch = `${remoteName}/${baseRef}`; - const alternateNames = new Set([ - expectedRemoteBranch, - `refs/remotes/${expectedRemoteBranch}`, - baseRef - ]); - const hasRemoteBranch = remoteBranches.some(branch => { - if (!branch.name) { - return false; - } - if (branch.remote && branch.remote !== remoteName) { - return false; - } - const candidateName = - (branch.remote && branch.name.startsWith(branch.remote + '/')) - ? branch.name - : `${branch.remote}/${branch.name}`; - return alternateNames.has(candidateName); - }); - return hasRemoteBranch; - } - - async commitAndPushChanges(): Promise { - const { repository, remoteName, baseRef } = await this.repoInfo(); - const asyncBranch = await this.generateRandomBranchName(repository, 'copilot'); - - const commitMessage = vscode.l10n.t('Checkpoint from VS Code for cloud agent session'); - try { - await repository.createBranch(asyncBranch, true); - await this.performCommit(asyncBranch, repository, commitMessage); - await repository.push(remoteName, asyncBranch, true); - await this.switchBackToBaseRef(repository, baseRef, asyncBranch); - return asyncBranch; - } catch (error) { - await this.rollbackToOriginalBranch(repository, baseRef); - this.logService.error(`Failed to automatically commit and push your changes: ${error instanceof Error ? error.message : String(error)}`); - throw new Error(vscode.l10n.t('Failed to automatically commit and push your changes. Please commit or stash your changes manually and try again.')); - } - } - - private async performCommit(asyncBranch: string, repository: Repository, commitMessage: string): Promise { - try { - await repository.commit(commitMessage, { all: true }); - if (repository.state.HEAD?.name !== asyncBranch || repository.state.workingTreeChanges.length > 0 || repository.state.indexChanges.length > 0) { - throw new Error(vscode.l10n.t('Uncommitted changes still detected.')); - } - } catch (error) { - // TODO: stream.progress('waiting for user to manually commit changes'); - const commitSuccessful = await this.handleInteractiveCommit(repository); - if (!commitSuccessful) { - throw new Error(vscode.l10n.t('Failed to commit changes. Please commit or stash your changes manually before using the cloud agent.')); - } - } - } - - private async handleInteractiveCommit(repository: Repository): Promise { - const COMMIT_YOUR_CHANGES = vscode.l10n.t('Commit your changes to continue cloud agent session. Close integrated terminal to cancel.'); - return vscode.window.withProgress({ - title: COMMIT_YOUR_CHANGES, - cancellable: true, - location: vscode.ProgressLocation.Notification - }, async (_, token) => { - return new Promise((resolve) => { - const startingCommit = repository.state.HEAD?.commit; - const terminal = vscode.window.createTerminal({ - name: 'GitHub Copilot Cloud Agent', - cwd: repository.rootUri.fsPath, - message: `\x1b[1m${COMMIT_YOUR_CHANGES}\x1b[0m` - }); - - terminal.show(); - - let disposed = false; - let timeoutId: TimeoutHandle | undefined = undefined; - let stateListener: vscode.Disposable | undefined = undefined; - let disposalListener: vscode.Disposable | undefined = undefined; - let cancellationListener: vscode.Disposable | undefined = undefined; - const cleanup = () => { - if (disposed) { - return; - } - disposed = true; - clearTimeout(timeoutId); - stateListener?.dispose(); - disposalListener?.dispose(); - cancellationListener?.dispose(); - terminal.dispose(); - }; - - if (token) { - cancellationListener = token.onCancellationRequested(() => { - cleanup(); - resolve(false); - }); - } - - stateListener = repository.state.onDidChange(() => { - if (repository.state.HEAD?.commit !== startingCommit) { - cleanup(); - resolve(true); - } - }); - - timeoutId = setTimeout(() => { - cleanup(); - resolve(false); - }, 5 * 60 * 1000); - - disposalListener = vscode.window.onDidCloseTerminal((closedTerminal) => { - if (closedTerminal === terminal) { - setTimeout(() => { - if (!disposed) { - cleanup(); - resolve(repository.state.HEAD?.commit !== startingCommit); - } - }, 1000); - } - }); - }); - }); - } - - private async switchBackToBaseRef(repository: Repository, baseRef: string, newRef: string): Promise { - if (repository.state.HEAD?.name !== baseRef) { - await repository.checkout(baseRef); - } - } - - private async rollbackToOriginalBranch(repository: Repository, baseRef: string): Promise { - if (repository.state.HEAD?.name !== baseRef) { - try { - await repository.checkout(baseRef); - } catch (error) { - this.logService.error(`Failed to checkout back to original branch '${baseRef}': ${error instanceof Error ? error.message : String(error)}`); - } - } - } - - private async generateRandomBranchName(repository: Repository, prefix: string): Promise { - for (let index = 0; index < 5; index++) { - const randomName = `${prefix}/vscode-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; - try { - const refs = await repository.getRefs({ pattern: `refs/heads/${randomName}` }); - if (!refs || refs.length === 0) { - return randomName; - } - } catch (error) { - this.logService.warn(`Failed to check refs for ${randomName}: ${error instanceof Error ? error.message : String(error)}`); - return randomName; - } - } - - return `${prefix}/vscode-${Date.now().toString(36)}`; - } -} diff --git a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index c5a4f5b7f8..dfa5220ad7 100644 --- a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -28,8 +28,7 @@ import { IInstantiationService } from '../../../util/vs/platform/instantiation/c import { SingleSlotTtlCache, TtlCache } from '../common/ttlCache'; import { isUntitledSessionId } from '../common/utils'; import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService'; -import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils'; -import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager'; +import { CONTINUE_TRUNCATION, extractTitle, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils'; import { ChatSessionContentBuilder, SessionResponseLogChunk } from './copilotCloudSessionContentBuilder'; import { IPullRequestFileChangesService } from './pullRequestFileChangesService'; import MarkdownIt = require('markdown-it'); @@ -47,21 +46,6 @@ type InitialSessionOption = { readonly value: string | vscode.ChatSessionProviderOptionItem; }; -function validateMetadata(metadata: unknown): asserts metadata is ConfirmationMetadata { - if (typeof metadata !== 'object') { - throw new Error('Invalid confirmation metadata: not an object.'); - } - if (metadata === null) { - throw new Error('Invalid confirmation metadata: null value.'); - } - if (typeof (metadata as ConfirmationMetadata).prompt !== 'string') { - throw new Error('Invalid confirmation metadata: missing or invalid prompt.'); - } - if (typeof (metadata as ConfirmationMetadata).chatContext !== 'object' || (metadata as ConfirmationMetadata).chatContext === null) { - throw new Error('Invalid confirmation metadata: missing or invalid chatContext.'); - } -} - function describeRuntimeValue(value: unknown): string { if (Array.isArray(value)) { return `array(length=${value.length})`; @@ -149,7 +133,6 @@ const DEFAULT_PARTNER_AGENT_ID = '___vscode_partner_agent_default___'; const DEFAULT_REPOSITORY_ID = '___vscode_repository_default___'; const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds -const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore'; const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository'; const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches'; const USER_SELECTED_REPOS_KEY = 'userSelectedRepositories'; @@ -267,7 +250,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C private activeSessionIds: Set = new Set(); private activeSessionPollingInterval: ReturnType | undefined; private readonly plainTextRenderer = new PlainTextRenderer(); - private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService); // TTL cache for CCA enabled status per repository (key: "owner/repo") // Only caches enabled=true results; disabled results always re-fetch to avoid stuck states @@ -277,22 +259,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C // Caches the most recently computed options regardless of repo/workspace context private _optionsCache = new SingleSlotTtlCache(OPTIONS_CACHE_TTL_MS); - // Title - private TITLE = vscode.l10n.t('Delegate to cloud agent'); - - // Buttons (used for matching, be careful changing!) - private readonly AUTHORIZE = vscode.l10n.t('Authorize'); - private readonly COMMIT = vscode.l10n.t('Commit Changes'); - private readonly PUSH_BRANCH = vscode.l10n.t('Push Branch'); - private readonly DELEGATE = vscode.l10n.t('Delegate'); - private readonly CANCEL = vscode.l10n.t('Cancel'); - - // Messages - private readonly BASE_MESSAGE = vscode.l10n.t('Cloud agent works asynchronously to create a pull request with your requested changes. This chat\'s history will be summarized and appended to the pull request as context.'); - private readonly AUTHORIZE_MESSAGE = vscode.l10n.t('Cloud agent requires elevated GitHub access to proceed.'); - private readonly COMMIT_MESSAGE = vscode.l10n.t('This workspace has uncommitted changes. Should these changes be pushed and included in cloud agent\'s work?'); - private readonly PUSH_BRANCH_MESSAGE = (baseRef: string, defaultBranch: string) => vscode.l10n.t('Push your currently checked out branch `{0}`, or start from the default branch `{1}`?', baseRef, defaultBranch); - // Workspace storage keys private readonly WORKSPACE_CONTEXT_PREFIX = 'copilot.cloudAgent'; @@ -1512,19 +1478,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return state === 'MERGED' ? '$(git-merge)' : '$(git-pull-request)'; } - private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean { - if (!history || history.length === 0) { - return false; - } - const allResponsesEmpty = history.every(turn => { - if (turn instanceof vscode.ChatResponseTurn) { - return turn.response.length === 0; - } - return true; - }); - return !allResponsesEmpty; - } - async delegate( request: vscode.ChatRequest, stream: vscode.ChatResponseStream, @@ -1535,14 +1488,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C head_ref?: string ): Promise { - let history: string | undefined; - - // TODO: Do this async/optimistically before delegation triggered - if (this.hasHistoryToSummarize(context.history)) { - stream.progress(vscode.l10n.t('Analyzing chat history')); - history = await this._chatDelegationSummaryService.summarize(context, token); - } - // Get the chat resource from context or metadata const chatResource = context.chatSessionContext?.chatSessionItem?.resource ?? metadata.chatContext.chatSessionContext?.chatSessionItem?.resource; @@ -1586,7 +1531,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C const { number, sessionId } = await this.invokeRemoteAgent( metadata.prompt, - [result, history].filter(Boolean).join('\n\n').trim(), + result, token, stream, base_ref, @@ -1596,9 +1541,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C partnerAgentName, selectedRepository ); - if (history) { - void this._chatDelegationSummaryService.trackSummaryUsage(sessionId, history); - } this.logService.debug(`Delegated to cloud agent for PR #${number} with session ID ${sessionId}`); // Store references for this session @@ -1646,106 +1588,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C }; } - private async handleConfirmationData(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, context: vscode.ChatContext, token: vscode.CancellationToken) { - if (!request.prompt || request.prompt.indexOf(':') === -1) { - this.logService.error('Invalid confirmation prompt format.'); - return {}; - } - - // Parse out the button selected by the user - const selection = (request.prompt?.split(':')[0] || '').trim().toUpperCase(); - const metadata: unknown = request.acceptedConfirmationData?.[0]?.metadata || request.rejectedConfirmationData?.[0]?.metadata; - try { - validateMetadata(metadata); - } catch (error) { - this.logService.error(`Invalid confirmation metadata: ${error}`); - return {}; - } - - // -- Process each button press in order of precedence - - if (!selection || selection === this.CANCEL.toUpperCase() || token.isCancellationRequested) { - /* __GDPR__ - "copilotcloud.chat.confirmationCancelled" : { - "owner": "joshspicer", - "comment": "Event sent when the cloud chat confirmation flow is cancelled.", - "tokenCancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the cancellation token was already cancelled." } - } - */ - this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.confirmationCancelled', { - tokenCancelled: String(token.isCancellationRequested) - }); - stream.markdown(vscode.l10n.t('Cloud agent cancelled')); - return {}; - } - - if (selection.includes(this.AUTHORIZE.toUpperCase())) { - stream.progress(vscode.l10n.t('Authorizing')); - try { - await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } }); - if (!this._authenticationService.permissiveGitHubSession) { - throw new Error('Failed to obtain permissive GitHub session'); - } - } catch (error) { - this.logService.error(`Authorization failed: ${error}`); - throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); - - } - } - - let head_ref: string | undefined; // If set, this is the branch we pushed pending changes to. - - if (selection.includes(this.COMMIT.toUpperCase())) { - try { - stream.progress(vscode.l10n.t('Committing and pushing local changes')); - head_ref = await this.gitOperationsManager.commitAndPushChanges(); - stream.markdown(vscode.l10n.t('Local changes pushed to remote branch `{0}`.', head_ref)); - } catch (error) { - this.logService.error(`Commit and push failed: ${error}`); - throw vscode.l10n.t('{0}. Commit or stash your changes and try again.', (error instanceof Error ? error.message : String(error)) ?? vscode.l10n.t('Failed to commit and push changes.')); - } - } else if (selection.includes(this.PUSH_BRANCH.toUpperCase())) { - try { - stream.progress(vscode.l10n.t('Pushing base branch to remote')); - const baseBranch = await this.gitOperationsManager.pushBaseRefToRemote(); - stream.markdown(vscode.l10n.t('Base branch `{0}` pushed to remote.', baseBranch)); - } catch (error) { - this.logService.error(`Push branch failed: ${error}`); - throw vscode.l10n.t('{0}. Push the current branch to remote and try again.', (error instanceof Error ? error.message : String(error)) ?? vscode.l10n.t('Failed to push current branch.')); - } - } - - // Get the selected repository from the chat context for multiroot workspace support - const chatResource = metadata.chatContext.chatSessionContext?.chatSessionItem?.resource; - const selectedRepository = chatResource ? this.sessionRepositoryMap.get(chatResource) : undefined; - - const base_ref: string = await (async () => { - const res = await this.checkBaseBranchPresentOnRemote(selectedRepository); - if (!res) { - // Unexpected - throw new Error(vscode.l10n.t('Repo base branch is not detected on remote. Push your branch and try again.')); - } - return (res?.missingOnRemote || !res?.baseRef) ? res.repoDefaultBranch : res?.baseRef; - })(); - stream.progress(vscode.l10n.t('Validating branch base branch exists on remote')); - - // Now trigger delegation - try { - await this.delegate(request, stream, context, token, metadata, base_ref, head_ref); - } catch (error) { - this.logService.error(`Failure in delegation: ${error}`); - throw new Error(vscode.l10n.t('{0}', (error instanceof Error ? error.message : String(error)))); - } - } - - private setWorkspaceContext(key: string, value: string) { - this._extensionContext.workspaceState.update(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`, value); - } - - private getWorkspaceContext(key: string): string | undefined { - return this._extensionContext.workspaceState.get(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`); - } - resetWorkspaceContext() { const keys = this._extensionContext.workspaceState.keys() @@ -1789,140 +1631,12 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return validRepos; } - private async detectedUncommittedChanges(): Promise { - const currentRepository = this._gitService.activeRepository?.get(); - if (!currentRepository) { - return false; - } - const git = this._gitExtensionService.getExtensionApi(); - const repo = git?.getRepository(currentRepository?.rootUri); - if (!repo) { - return false; - } - return repo.state.workingTreeChanges.length > 0 || repo.state.indexChanges.length > 0; - } - - /** - * Checks if the current base branch exists on the remote repository. - * Returns branch information including whether it's missing from remote, the base ref name, and the repository's default branch. - * @param selectedRepository - Optional repository in `org/repo` format. If provided, uses this specific repository - * instead of defaulting to the first one. This enables multiroot workspace support. - */ - private async checkBaseBranchPresentOnRemote(selectedRepository?: string): Promise<{ missingOnRemote: boolean; baseRef: string; repoDefaultBranch: string } | undefined> { - try { - const repoIds = await getRepoId(this._gitService); - if (!repoIds || repoIds.length === 0) { - return undefined; - } - - // In multiroot workspaces, use the selected repository if provided - let repoId = repoIds[0]; - if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) { - const [selectedOrg, selectedRepo] = selectedRepository.split('/'); - const matchingRepoId = repoIds.find(id => id.org === selectedOrg && id.repo === selectedRepo); - repoId = matchingRepoId ?? new GithubRepoId(selectedOrg, selectedRepo); - } - - const { baseRef, repository, remoteName } = await this.gitOperationsManager.repoInfo(); - const remoteRepoInfo = await this._githubRepositoryService.getRepositoryInfo(repoId.org, repoId.repo); - const remoteHasRef = await this.gitOperationsManager.checkIfRemoteHasRef(repository, remoteName, baseRef); - if (remoteHasRef) { - // Remote HAS the base branch, no action needed. - return { missingOnRemote: false, baseRef, repoDefaultBranch: remoteRepoInfo.default_branch }; - } - // Remote is MISSING the base branch - return { missingOnRemote: true, baseRef, repoDefaultBranch: remoteRepoInfo.default_branch }; - } catch (error) { - this.logService.debug(`Failed to check default branch: ${error}`); - return undefined; - } - } - - /** - * Returns either all the data for a confirmation dialog, or undefined if no confirmation is needed. - * */ - private async buildConfirmation(context: vscode.ChatContext): Promise<{ title: string; message: string; buttons: string[] } | undefined> { - const title: string = this.TITLE; - const buttons: string[] = [this.CANCEL]; - let message: string = this.BASE_MESSAGE; - - // Get the selected repository from the chat context for multiroot workspace support - const chatResource = context.chatSessionContext?.chatSessionItem?.resource; - const selectedRepository = chatResource ? this.sessionRepositoryMap.get(chatResource) : undefined; - - const needsPermissiveAuth = !this._authenticationService.permissiveGitHubSession; - const hasUncommittedChanges = await this.detectedUncommittedChanges(); - const baseBranchInfo = await this.checkBaseBranchPresentOnRemote(selectedRepository); - - if (needsPermissiveAuth && hasUncommittedChanges) { - message += '\n\n' + this.AUTHORIZE_MESSAGE; - message += '\n\n' + this.COMMIT_MESSAGE; - buttons.unshift( - vscode.l10n.t('{0} and {1}', this.AUTHORIZE, this.COMMIT), - this.AUTHORIZE, - ); - } else if (needsPermissiveAuth && baseBranchInfo?.missingOnRemote) { - const { baseRef, repoDefaultBranch } = baseBranchInfo; - message += '\n\n' + this.AUTHORIZE_MESSAGE; - message += '\n\n' + this.PUSH_BRANCH_MESSAGE(baseRef, repoDefaultBranch); - buttons.unshift( - vscode.l10n.t('{0} and {1}', this.AUTHORIZE, this.PUSH_BRANCH), - this.AUTHORIZE, - ); - } else if (needsPermissiveAuth) { - message += '\n\n' + this.AUTHORIZE_MESSAGE; - buttons.unshift( - this.AUTHORIZE, - ); - } else if (hasUncommittedChanges) { - message += '\n\n' + this.COMMIT_MESSAGE; - buttons.unshift( - vscode.l10n.t('{0} and {1}', this.COMMIT, this.DELEGATE), - this.DELEGATE, - ); - } else if (baseBranchInfo?.missingOnRemote) { - const { baseRef, repoDefaultBranch } = baseBranchInfo; - message += '\n\n' + this.PUSH_BRANCH_MESSAGE(baseRef, repoDefaultBranch); - buttons.unshift( - vscode.l10n.t('{0} and {1}', this.PUSH_BRANCH, this.DELEGATE), - this.DELEGATE, - ); - } - - // Check if the message has been modified from the default - const messageModified = message !== this.BASE_MESSAGE; - - // Only skip confirmation if neither buttons were modified nor message was modified - if (buttons.length === 1 && !messageModified) { - if (context.chatSessionContext?.isUntitled) { - return; // Don't show the confirmation - } - const seenDelegationPromptBefore = this.getWorkspaceContext(SEEN_DELEGATION_PROMPT_KEY); - if (seenDelegationPromptBefore) { - return; // Don't show the confirmation - } - } - - if (buttons.length === 1) { - // No other affirmative button added, so add generic one - buttons.unshift(this.DELEGATE); - } - - return { title, message, buttons }; - } - private async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { if (token.isCancellationRequested) { stream.warning(vscode.l10n.t('Cloud session cancelled.')); return {}; } - if (request.acceptedConfirmationData || request.rejectedConfirmationData) { - await this.handleConfirmationData(request, stream, context, token); - this.setWorkspaceContext(SEEN_DELEGATION_PROMPT_KEY, 'yes'); - return {}; - } - // Look up the partner agent and model for telemetry const chatResource = context.chatSessionContext?.chatSessionItem?.resource; @@ -1976,36 +1690,33 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return {}; } - // New request - const showConfirmation = await this.buildConfirmation(context); - if (showConfirmation) { - const { title, message, buttons } = showConfirmation; - stream.confirmation( - title, - message, - { - metadata: { - prompt: request.prompt, - references: request.references, - chatContext: context, - } satisfies ConfirmationMetadata - }, - buttons - ); - } else { - // No confirmation - await this.delegate( - request, - stream, - context, - token, - { - prompt: request.prompt, - references: request.references, - chatContext: context - } satisfies ConfirmationMetadata, - ); + // New request — align with Mission Control / CCA PR-decoupling flow: no modal, + // go straight to delegation. Ensure we have the permissive GitHub session first; + // if missing, trigger the standard non-modal auth prompt. + if (!this._authenticationService.permissiveGitHubSession) { + stream.progress(vscode.l10n.t('Authorizing')); + try { + await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } }); + } catch (error) { + this.logService.error(`Authorization failed: ${error}`); + throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); + } + if (!this._authenticationService.permissiveGitHubSession) { + throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); + } } + + await this.delegate( + request, + stream, + context, + token, + { + prompt: request.prompt, + references: request.references, + chatContext: context + } satisfies ConfirmationMetadata, + ); } private async handleFollowUp(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { @@ -2572,9 +2283,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C ...(resolvePartnerAgentName(partnerAgentName)), pull_request: { title, - body_placeholder: formatBodyPlaceholder(title), base_ref, - body_suffix, ...(head_ref && { head_ref }), } }; diff --git a/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts b/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts index 44500a8226..57bd96cb4c 100644 --- a/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts +++ b/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts @@ -10,7 +10,6 @@ import { UriHandlerPaths, UriHandlers } from './chatSessionsUriHandler'; export const MAX_PROBLEM_STATEMENT_LENGTH = 30_000 - 50; // 50 character buffer export const CONTINUE_TRUNCATION = vscode.l10n.t('Continue with truncation'); -export const body_suffix = vscode.l10n.t('Created from [VS Code](https://code.visualstudio.com/docs/copilot/copilot-coding-agent).'); // https://github.com/github/sweagentd/blob/main/docs/adr/0001-create-job-api.md export const JOBS_API_VERSION = 'v1'; @@ -66,10 +65,6 @@ export function extractTitle(prompt: string, context: string | undefined): strin } -export function formatBodyPlaceholder(title: string | undefined): string { - return vscode.l10n.t('Cloud agent has begun work on **{0}** and will update this pull request as work progresses.', title || vscode.l10n.t('your request')); -} - export async function getRepoId(gitService: IGitService): Promise { // Ensure git service is initialized await gitService.initialize(); From aecc4a79b3f210637615cbf9c6757f1705678f9a Mon Sep 17 00:00:00 2001 From: David O'Regan Date: Fri, 17 Apr 2026 06:57:32 -0700 Subject: [PATCH 2/3] Handle cancellation in auth flow; use logService.error with Error arg Address review feedback on PR #5078: - Handle isCancellationError (and token.isCancellationRequested) by throwing CancellationError instead of the generic auth-failed error, so user-dismissed sign-in prompts cancel cleanly. - Use logService.error(error, 'Authorization failed') per ILogService guidance rather than interpolating the error into the message string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vscode-node/copilotCloudSessionsProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index dfa5220ad7..5fbda36ddf 100644 --- a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -24,6 +24,7 @@ import { DeferredPromise, retry, RunOnceScheduler } from '../../../util/vs/base/ import { Event } from '../../../util/vs/base/common/event'; import { Disposable, DisposableStore, toDisposable } from '../../../util/vs/base/common/lifecycle'; import { ResourceMap } from '../../../util/vs/base/common/map'; +import { CancellationError, isCancellationError } from '../../../util/vs/base/common/errors'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { SingleSlotTtlCache, TtlCache } from '../common/ttlCache'; import { isUntitledSessionId } from '../common/utils'; @@ -1698,7 +1699,10 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C try { await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } }); } catch (error) { - this.logService.error(`Authorization failed: ${error}`); + if (isCancellationError(error) || token.isCancellationRequested) { + throw new CancellationError(); + } + this.logService.error(error, 'Authorization failed'); throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); } if (!this._authenticationService.permissiveGitHubSession) { From 86924b11829255dcec2796fe1f1ff4cdd3843f1f Mon Sep 17 00:00:00 2001 From: David O'Regan Date: Fri, 17 Apr 2026 07:28:50 -0700 Subject: [PATCH 3/3] Address regressions surfaced during review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the initial alignment commit. Independent review caught four real issues; this addresses all of them: 1. Current remote-tracking branch was being ignored. delegate() now checks RepoContext.upstreamBranchName — if the user's checked-out branch is pushed to remote, use it as base_ref. If it's unpushed, fall back to default_branch and emit a progress notice so the user knows which branch the agent will start from. 2. Auth cancellation was only handled in chatParticipantImpl, leaving CLI /delegate callers (copilotCLIChatSessions.ts, copilotCLIChatSessionsContribution.ts) with a generic "invalid response" error when the user dismissed the sign-in prompt. Moved the permissive-session acquisition + CancellationError normalization into delegate() itself so every caller benefits. 3. Uncommitted-changes warning now fires from the shared delegate() path, covering both staged (indexChanges) and unstaged (workingTree) changes. Aligns with Mission Control / Codex / Claude behavior — warn the user, but don't block. 4. Base-branch fallback is no longer silent: when we fall back to default_branch because the current branch isn't on remote, emit a stream.progress message telling the user what will happen. Also fixed the existing CLI-path uncommitted-changes checks in copilotCLIChatSessions.ts:1463 and copilotCLIChatSessionsContribution.ts:1742 which only looked at indexChanges; now they check workingTree too. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../vscode-node/copilotCLIChatSessions.ts | 5 +- .../copilotCLIChatSessionsContribution.ts | 5 +- .../copilotCloudSessionsProvider.ts | 72 +++++++++++++------ 3 files changed, 60 insertions(+), 22 deletions(-) diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 1b49a57d1d..9f2f084809 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -1460,7 +1460,10 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.sessionId); const repositoryPath = worktreeProperties?.repositoryPath ? Uri.file(worktreeProperties.repositoryPath) : getWorkingDirectory(session.workspace); const repository = repositoryPath ? await this.gitService.getRepository(repositoryPath) : undefined; - const hasChanges = (repository?.changes?.indexChanges && repository.changes.indexChanges.length > 0); + const hasChanges = !!repository?.changes && ( + repository.changes.indexChanges.length > 0 + || repository.changes.workingTree.length > 0 + ); if (hasChanges) { stream.warning(l10n.t('You have uncommitted changes in your workspace. The cloud agent will start from the last committed state. Consider committing your changes first if you want to include them.')); diff --git a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 04a025c079..d4ee9c577b 100644 --- a/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -1739,7 +1739,10 @@ export class CopilotCLIChatSessionParticipant extends Disposable { const worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.sessionId); const repositoryPath = worktreeProperties?.repositoryPath ? Uri.file(worktreeProperties.repositoryPath) : getWorkingDirectory(session.workspace); const repository = repositoryPath ? await this.gitService.getRepository(repositoryPath) : undefined; - const hasChanges = (repository?.changes?.indexChanges && repository.changes.indexChanges.length > 0); + const hasChanges = !!repository?.changes && ( + repository.changes.indexChanges.length > 0 + || repository.changes.workingTree.length > 0 + ); if (hasChanges) { stream.warning(l10n.t('You have uncommitted changes in your workspace. The cloud agent will start from the last committed state. Consider committing your changes first if you want to include them.')); diff --git a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index 5fbda36ddf..3dacacc741 100644 --- a/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -1489,6 +1489,37 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C head_ref?: string ): Promise { + // Ensure we have the permissive GitHub session before doing any work; this + // covers both the cloud-sessions provider entry point and the Copilot CLI + // `/delegate` path which calls into delegate() directly. + if (!this._authenticationService.permissiveGitHubSession) { + stream.progress(vscode.l10n.t('Authorizing')); + try { + await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } }); + } catch (error) { + if (isCancellationError(error) || token.isCancellationRequested) { + throw new CancellationError(); + } + this.logService.error(error, 'Authorization failed'); + throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); + } + if (!this._authenticationService.permissiveGitHubSession) { + throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); + } + } + + // Surface uncommitted local changes (staged + unstaged) so the user is aware + // the cloud agent runs against the remote commit, not their working tree. + // This aligns with Mission Control / Codex / Claude behavior. + const activeRepo = this._gitService.activeRepository.get(); + const hasUncommittedChanges = !!activeRepo?.changes && ( + activeRepo.changes.workingTree.length > 0 + || activeRepo.changes.indexChanges.length > 0 + ); + if (hasUncommittedChanges) { + stream.warning(vscode.l10n.t('You have uncommitted changes in your workspace. The cloud agent will start from the last pushed commit; commit and push first if you want those changes included.')); + } + // Get the chat resource from context or metadata const chatResource = context.chatSessionContext?.chatSessionItem?.resource ?? metadata.chatContext.chatSessionContext?.chatSessionItem?.resource; @@ -1526,8 +1557,24 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C repoOwner = repoId.org; repoName = repoId.repo; } - const { default_branch } = await this._githubRepositoryService.getRepositoryInfo(repoOwner, repoName); - base_ref = default_branch; + + // Prefer the user's currently checked-out branch when it tracks a remote + // (upstreamBranchName is set only when the branch exists on remote). + // Only applies when the active repository matches the selected repo + // (or no specific repo was selected). + const selectedMatchesActive = !selectedRepoOwner + || (repoId?.org === selectedRepoOwner && repoId?.repo === selectedRepoName); + const currentBranch = activeRepo?.headBranchName; + const hasUpstream = !!activeRepo?.upstreamBranchName; + if (selectedMatchesActive && currentBranch && hasUpstream) { + base_ref = currentBranch; + } else { + const { default_branch } = await this._githubRepositoryService.getRepositoryInfo(repoOwner, repoName); + base_ref = default_branch; + if (currentBranch && !hasUpstream) { + stream.progress(vscode.l10n.t('Current branch `{0}` is not on the remote; cloud agent will start from `{1}`.', currentBranch, default_branch)); + } + } } const { number, sessionId } = await this.invokeRemoteAgent( @@ -1692,24 +1739,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C } // New request — align with Mission Control / CCA PR-decoupling flow: no modal, - // go straight to delegation. Ensure we have the permissive GitHub session first; - // if missing, trigger the standard non-modal auth prompt. - if (!this._authenticationService.permissiveGitHubSession) { - stream.progress(vscode.l10n.t('Authorizing')); - try { - await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } }); - } catch (error) { - if (isCancellationError(error) || token.isCancellationRequested) { - throw new CancellationError(); - } - this.logService.error(error, 'Authorization failed'); - throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); - } - if (!this._authenticationService.permissiveGitHubSession) { - throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.')); - } - } - + // no preflight, go straight to delegation. delegate() performs its own + // permissive-auth check (shared with CLI /delegate callers), surfaces + // uncommitted-changes warnings, and handles base_ref selection. await this.delegate( request, stream,