diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 20b63962c67..a440c00afa3 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -2412,6 +2412,10 @@ "title": "%github.copilot.command.refreshAgentSessions%", "icon": "$(refresh)" }, + { + "command": "github.copilot.cloud.resetWorkspaceConfirmations", + "title": "%github.copilot.command.resetCloudAgentWorkspaceConfirmations%" + }, { "command": "github.copilot.cloud.sessions.openInBrowser", "title": "%github.copilot.command.openCopilotAgentSessionsInBrowser%", @@ -5245,4 +5249,4 @@ "string_decoder": "npm:string_decoder@1.2.0", "node-gyp": "npm:node-gyp@10.3.1" } -} \ No newline at end of file +} diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index 53db27b6c50..00aa3b1517c 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -139,6 +139,7 @@ "github.copilot.command.explainTerminalLastCommand": "Explain Last Terminal Command", "github.copilot.command.collectWorkspaceIndexDiagnostics": "Collect Workspace Index Diagnostics", "github.copilot.command.triggerPermissiveSignIn": "Login to GitHub with Full Permissions", + "github.copilot.command.resetCloudAgentWorkspaceConfirmations": "Reset Cloud Agent Workspace Confirmations", "github.copilot.git.generateCommitMessage": "Generate Commit Message", "github.copilot.git.resolveMergeConflicts": "Resolve Conflicts with AI", "github.copilot.devcontainer.generateDevContainerConfig": "Generate Dev Container Configuration", diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index 651145a0c8b..eba728a97fc 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -152,6 +152,11 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib cloudSessionsProvider.refresh(); }) ); + this.copilotCloudRegistrations.add( + vscode.commands.registerCommand('github.copilot.cloud.resetWorkspaceConfirmations', () => { + cloudSessionsProvider.resetWorkspaceContext(); + }) + ); this.copilotCloudRegistrations.add( vscode.commands.registerCommand('github.copilot.cloud.sessions.openInBrowser', async (chatSessionItem: vscode.ChatSessionItem) => { cloudSessionsProvider.openSessionsInBrowser(chatSessionItem); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index c5ec7e21511..8851e319cb0 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -26,11 +26,19 @@ import { PermissionRequest, requestPermission } from '../../agents/copilotcli/no import { ChatSummarizerProvider } from '../../prompt/node/summarizer'; import { IToolsService } from '../../tools/common/toolsService'; import { ICopilotCLITerminalIntegration } from './copilotCLITerminalIntegration'; -import { ConfirmationResult, CopilotCloudSessionsProvider, UncommittedChangesStep } from './copilotCloudSessionsProvider'; +import { CopilotCloudSessionsProvider } from './copilotCloudSessionsProvider'; const MODELS_OPTION_ID = 'model'; const ISOLATION_OPTION_ID = 'isolation'; +const UncommittedChangesStep = 'uncommitted-changes'; +type ConfirmationResult = { step: string; accepted: boolean; metadata?: CLIConfirmationMetadata }; +interface CLIConfirmationMetadata { + prompt: string; + references?: readonly vscode.ChatPromptReference[]; + chatContext: vscode.ChatContext; +} + // Track model selections per session // TODO@rebornix: we should have proper storage for the session model preference (revisit with API) const _sessionModel: Map = new Map(); @@ -414,7 +422,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } if (!isUntitled && confirmationResults.length) { - return await this.handleConfirmationData(session.object, request.prompt, confirmationResults, context, stream, token); + return await this.handleConfirmationData(request, session.object, request.prompt, confirmationResults, context, stream, token); } if (request.prompt.startsWith('/delegate')) { @@ -499,18 +507,12 @@ export class CopilotCLIChatSessionParticipant extends Disposable { } const prompt = request.prompt.substring('/delegate'.length).trim(); - if (!await this.cloudSessionProvider.tryHandleUncommittedChanges({ - prompt: prompt, - chatContext: context - }, stream, token)) { - const prInfo = await this.cloudSessionProvider.createDelegatedChatSession({ - prompt, - chatContext: context - }, stream, token); - if (prInfo) { - await this.recordPushToSession(session, request.prompt, prInfo); - } + + const prInfo = await this.cloudSessionProvider.delegate(request, stream, context, token, { prompt, chatContext: context }); + if (prInfo) { + await this.recordPushToSession(session, request.prompt, prInfo); } + } private getAcceptedRejectedConfirmationData(request: vscode.ChatRequest): ConfirmationResult[] { @@ -521,7 +523,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return results; } - private async handleConfirmationData(session: ICopilotCLISession, prompt: string, results: ConfirmationResult[], context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { + private async handleConfirmationData(request: vscode.ChatRequest, session: ICopilotCLISession, prompt: string, results: ConfirmationResult[], context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { const uncommittedChangesData = results.find(data => data.step === UncommittedChangesStep); if (!uncommittedChangesData) { stream.warning(`Unknown confirmation step: ${results.map(r => r.step).join(', ')}\n\n`); @@ -533,12 +535,7 @@ export class CopilotCLIChatSessionParticipant extends Disposable { return {}; } - const prInfo = await this.cloudSessionProvider?.createDelegatedChatSession({ - prompt: uncommittedChangesData.metadata.prompt, - references: uncommittedChangesData.metadata.references, - autoPushAndCommit: uncommittedChangesData.metadata.autoPushAndCommit, - chatContext: context - }, stream, token); + const prInfo = await this.cloudSessionProvider?.delegate(request, stream, context, token, uncommittedChangesData.metadata); if (prInfo) { await this.recordPushToSession(session, prompt, prInfo); } @@ -580,13 +577,13 @@ export class CopilotCLIChatSessionParticipant extends Disposable { private async recordPushToSession( session: ICopilotCLISession, userPrompt: string, - prInfo: { uri: string; title: string; description: string; author: string; linkTag: string } + prInfo: { uri: vscode.Uri; title: string; description: string; author: string; linkTag: string } ): Promise { // Add user message event session.addUserMessage(userPrompt); // Add assistant message event with embedded PR metadata - const assistantMessage = `GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.\n`; + const assistantMessage = `GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.\n`; session.addUserAssistantMessage(assistantMessage); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts index 7f65581036a..337c856dbd5 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudGitOperationsManager.ts @@ -4,8 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +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; @@ -14,10 +18,93 @@ export interface GitRepoInfo { } export class CopilotCloudGitOperationsManager { - constructor(private readonly logService: ILogService) { } + constructor( + private readonly logService: ILogService, + private readonly gitService: IGitService, + private readonly gitExtensionService: IGitExtensionService, + private readonly configurationService: IConfigurationService + ) { } - async commitAndPushChanges(repoInfo: GitRepoInfo): Promise { - const { repository, remoteName, baseRef } = repoInfo; + private get autoCommitAndPushEnabled(): boolean { + return this.configurationService.getConfig(ConfigKey.AgentDelegateAutoCommitAndPush); + } + + async repoInfo(): Promise { + // TODO: support selecting remote + // await this.promptAndUpdatePreferredGitHubRemote(true); + const repoId = await getRepoId(this.gitService); + if (!repoId) { + throw new Error(vscode.l10n.t('Repository information is not available. Open a GitHub repository to continue with cloud agent.')); + } + 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' + }; + } + + async validateRemoteHasBaseRef(stream: vscode.ChatResponseStream): Promise { + const { repository, remoteName, baseRef } = await this.repoInfo(); + stream.progress(vscode.l10n.t('Verifying branch \'{0}\' exists on remote \'{1}\'', baseRef, remoteName)); + if (repository && remoteName && baseRef) { + try { + 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); + }); + + if (!hasRemoteBranch) { + if (this.autoCommitAndPushEnabled) { + this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote. Auto-pushing because autoCommitAndPush is enabled.`); + stream.progress(vscode.l10n.t('Pushing branch \'{0}\'', baseRef)); + await repository.push(remoteName, baseRef, true); + } else { + throw new Error('autoCommitAndPush is disabled'); + } + } + } catch (error) { + this.logService.error(`Failed to verify remote branch for cloud agent: ${error instanceof Error ? error.message : String(error)}`); + throw new Error(vscode.l10n.t('Branch \'{0}\' does not exist on remote \'{1}\'. Push the branch manually or enable \'github.copilot.chat.agent.delegate.autoCommitAndPush\'', baseRef, remoteName)); + } + } + } + + + 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'); @@ -25,7 +112,7 @@ export class CopilotCloudGitOperationsManager { await repository.createBranch(asyncBranch, true); await this.performCommit(asyncBranch, repository, commitMessage); await repository.push(remoteName, asyncBranch, true); - this.showBranchSwitchNotification(repository, baseRef, asyncBranch); + await this.switchBackToBaseRef(repository, baseRef, asyncBranch); return asyncBranch; } catch (error) { await this.rollbackToOriginalBranch(repository, baseRef); @@ -41,6 +128,7 @@ export class CopilotCloudGitOperationsManager { 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.')); @@ -115,17 +203,9 @@ export class CopilotCloudGitOperationsManager { }); } - private showBranchSwitchNotification(repository: Repository, baseRef: string, newRef: string): void { + private async switchBackToBaseRef(repository: Repository, baseRef: string, newRef: string): Promise { if (repository.state.HEAD?.name !== baseRef) { - const SWAP_BACK_TO_ORIGINAL_BRANCH = vscode.l10n.t('Swap back to \'{0}\'', baseRef); - vscode.window.showInformationMessage( - vscode.l10n.t('Pending changes pushed to remote branch \'{0}\'.', newRef), - SWAP_BACK_TO_ORIGINAL_BRANCH, - ).then(async (selection) => { - if (selection === SWAP_BACK_TO_ORIGINAL_BRANCH) { - await repository.checkout(baseRef); - } - }); + await repository.checkout(baseRef); } } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts index a6edcd6bb87..6d9eab4592d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts @@ -8,8 +8,8 @@ import * as pathLib from 'path'; import * as vscode from 'vscode'; import { Uri } from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; -import { IAuthenticationChatUpgradeService } from '../../../platform/authentication/common/authenticationUpgrade'; -import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService'; import { IGitService } from '../../../platform/git/common/gitService'; import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI'; @@ -20,52 +20,36 @@ import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle import { ResourceMap } from '../../../util/vs/base/common/map'; import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ChatSummarizerProvider } from '../../prompt/node/summarizer'; -import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, RemoteAgentResult, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils'; +import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils'; import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager'; import { ChatSessionContentBuilder } from './copilotCloudSessionContentBuilder'; import { IPullRequestFileChangesService } from './pullRequestFileChangesService'; -export type ConfirmationResult = { step: string; accepted: boolean; metadata?: ConfirmationMetadata }; -export const UncommittedChangesStep = 'uncommitted-changes'; - interface ConfirmationMetadata { prompt: string; references?: readonly vscode.ChatPromptReference[]; chatContext: vscode.ChatContext; - autoPushAndCommit?: boolean; } -export interface PullRequestInfo { - uri: string; - title: string; - description: string; - author: string; - linkTag: string; - number: number; -} - -export interface ICommentResult { - id: number; - url: string; - body: string; - user?: { - login: string; - url: string; - avatarUrl: string; - email: string; - id: string; - name: string; - specialDisplayName?: string; - accountType: string; - }; - createdAt: string; - htmlUrl: string; - graphNodeId: string; +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.'); + } } const AGENTS_OPTION_GROUP_ID = 'agents'; const DEFAULT_AGENT_ID = '___vscode_default___'; const BACKGROUND_REFRESH_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes +const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore'; /** * Custom renderer for markdown-it that converts markdown to plain text @@ -135,7 +119,6 @@ class PlainTextRenderer { export class CopilotCloudSessionsProvider extends Disposable implements vscode.ChatSessionContentProvider, vscode.ChatSessionItemProvider { public static readonly TYPE = 'copilot-cloud-agent'; - private readonly DELEGATE_MODAL_DETAILS = vscode.l10n.t('The agent will work 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 _onDidChangeChatSessionItems = this._register(new vscode.EventEmitter()); public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; private readonly _onDidCommitChatSessionItem = this._register(new vscode.EventEmitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); @@ -144,14 +127,31 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C private chatSessionItemsPromise: Promise | undefined; private readonly sessionAgentMap = new ResourceMap(); private readonly sessionReferencesMap = new ResourceMap(); - public chatParticipant = vscode.chat.createChatParticipant(CopilotCloudSessionsProvider.TYPE, async (request, context, stream, token) => - await this.chatParticipantImpl(request, context, stream, token) - ); + public chatParticipant = vscode.chat.createChatParticipant(CopilotCloudSessionsProvider.TYPE, async (request, context, stream, token) => { + await this.chatParticipantImpl(request, context, stream, token); + }); private cachedSessionsSize: number = 0; private readonly plainTextRenderer = new PlainTextRenderer(); - private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService); + private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService, this.configurationService); private readonly _summarizer: ChatSummarizerProvider; + // 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 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?'); + + // Workspace storage keys + private readonly WORKSPACE_CONTEXT_PREFIX = 'copilot.cloudAgent'; + constructor( @IOctoKitService private readonly _octoKitService: IOctoKitService, @IGitService private readonly _gitService: IGitService, @@ -160,7 +160,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C @IGitExtensionService private readonly _gitExtensionService: IGitExtensionService, @IPullRequestFileChangesService private readonly _prFileChangesService: IPullRequestFileChangesService, @IAuthenticationService private readonly _authenticationService: IAuthenticationService, - @IAuthenticationChatUpgradeService private readonly _authenticationUpgradeService: IAuthenticationChatUpgradeService, + @IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext, @IConfigurationService private readonly configurationService: IConfigurationService, @IInstantiationService instantiationService: IInstantiationService, ) { @@ -189,10 +189,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C this._onDidChangeChatSessionItems.fire(); } - private get autoCommitAndPushEnabled(): boolean { - return this.configurationService.getConfig(ConfigKey.AgentDelegateAutoCommitAndPush); - } - async provideChatSessionProviderOptions(token: vscode.CancellationToken): Promise { const repoId = await getRepoId(this._gitService); if (!repoId) { @@ -596,85 +592,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return state === 'MERGED' ? '$(git-merge)' : '$(git-pull-request)'; } - private async startSession(stream: vscode.ChatResponseStream, token: vscode.CancellationToken, source: string, prompt: string, history?: string, references?: readonly vscode.ChatPromptReference[], customAgentName?: string, autoPushAndCommit?: boolean) { - /* __GDPR__ - "copilot.codingAgent.editor.invoke" : { - "promptLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "historyLength" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "referencesCount" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }, - "source" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } - } - */ - this.telemetry.sendTelemetryEvent('copilot.codingAgent.editor.invoke', { microsoft: true, github: false }, { - promptLength: prompt.length.toString() ?? '0', - historyLength: history?.length.toString() ?? '0', - referencesCount: references?.length.toString() ?? '0', - source, - }); - const result = await this.invokeRemoteAgent( - prompt, - [ - await this.extractFileReferences(references), - history - ].join('\n\n').trim(), - token, - autoPushAndCommit ?? false, - stream, - customAgentName, - ); - if (!result) { - return; - } - if (result.state !== 'success') { - this.logService.error(`Failed to provide new chat session item: ${result.error}${result.innerError ? `\nInner Error: ${result.innerError}` : ''}`); - stream.warning(result.error); - return; - } - return result.number; - } - - - private async handleConfirmationData(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, context: vscode.ChatContext, token: vscode.CancellationToken) { - const results: ConfirmationResult[] = []; - results.push(...(request.acceptedConfirmationData?.filter(data => !data?.authPermissionPrompted).map(data => ({ step: data.step, accepted: true, metadata: data?.metadata })) ?? [])); - results.push(...((request.rejectedConfirmationData ?? []).filter(data => !results.some(r => r.step === data.step)).map(data => ({ step: data.step, accepted: false, metadata: data?.metadata })))); - for (const data of results) { - switch (data.step) { - case 'create': - { - if (!data.accepted || !data.metadata) { - stream.markdown(vscode.l10n.t('Cloud agent request cancelled.')); - return {}; - } - if (!await this.tryHandleUncommittedChanges({ ...data.metadata, chatContext: context }, stream, token)) { - // We are NOT handling an uncommitted changes case, so no confirmation was pushed. - // This means we (the caller) should continue processing the request. - await this.createDelegatedChatSession({ ...data.metadata, chatContext: context }, stream, token); - } - break; - } - case UncommittedChangesStep: - { - if (!data.accepted || !data.metadata) { - stream.markdown(vscode.l10n.t('Cloud agent request cancelled due to uncommitted changes.')); - return {}; - } - - if (data.metadata.chatContext?.chatSessionContext?.isUntitled) { - await this.doUntitledCreation({ ...data.metadata, chatContext: context }, stream, token); - } else { - await this.createDelegatedChatSession({ ...data.metadata, chatContext: context }, stream, token); - } - break; - } - default: - stream.warning(`Unknown confirmation step: ${data.step}\n\n`); - break; - } - } - return {}; - } - private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean { if (!history || history.length === 0) { return false; @@ -688,186 +605,232 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return !allResponsesEmpty; } - async createDelegatedChatSession(metadata: ConfirmationMetadata, stream: vscode.ChatResponseStream, token: vscode.CancellationToken): Promise { - const { prompt, references } = metadata; + async delegate( + request: vscode.ChatRequest, + stream: vscode.ChatResponseStream, + context: vscode.ChatContext, + token: vscode.CancellationToken, + metadata: ConfirmationMetadata, + head_ref?: string): Promise<{ uri: vscode.Uri; title: string; description: string; author: string; linkTag: string }> { + + await this.gitOperationsManager.validateRemoteHasBaseRef(stream); + let history: string | undefined; - if (this.hasHistoryToSummarize(metadata.chatContext.history)) { + + // TODO: Do this async/optimistically before delegation triggered + if (this.hasHistoryToSummarize(context.history)) { stream.progress(vscode.l10n.t('Analyzing chat history')); - history = await this._summarizer.provideChatSummary(metadata.chatContext, token); - } - const number = await this.startSession(stream, token, 'chat', prompt, history, references, undefined, metadata.autoPushAndCommit); - if (!number) { - return undefined; - } - const pullRequest = await this.findPR(number); - if (!pullRequest) { - stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', number)); - return undefined; + history = await this._summarizer.provideChatSummary(context, token); } - // Store references for this session - const sessionUri = vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + number }); - if (references && references.length > 0) { - this.sessionReferencesMap.set(sessionUri, references); - } - - const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.repository.owner.login, repo: pullRequest.repository.name, pullRequestNumber: pullRequest.number }); - const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, pullRequest.body, getAuthorDisplayName(pullRequest.author), `#${pullRequest.number}`); - stream.push(card); - stream.markdown(vscode.l10n.t('GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.')); - await vscode.commands.executeCommand('vscode.open', sessionUri); - // Return PR info for embedding in session history - return { - uri: uri.toString(), - title: pullRequest.title, - description: pullRequest.body, - author: getAuthorDisplayName(pullRequest.author), - linkTag: `#${pullRequest.number}`, - number, - }; - } - - /** - * Checks for uncommitted changes in the current repository and prompts the user for confirmation if any are found. - * @returns 'true' if handling was performed. This will push a chat confirmation and initiate a new chat request (handled in handleConfirmationData()) - * otherwise 'false', meaning the caller should continue handling the request - */ - async tryHandleUncommittedChanges(metadata: ConfirmationMetadata, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { - try { - const repoId = await getRepoId(this._gitService); - if (!repoId) { - throw new Error('Repository information is not available.'); + let customAgentName: string | undefined; + if (metadata.chatContext.chatSessionContext?.chatSessionItem?.resource) { + customAgentName = this.sessionAgentMap.get(metadata.chatContext.chatSessionContext.chatSessionItem.resource); + if (customAgentName) { + this.logService.debug(`Using custom agent '${customAgentName}' for session ${metadata.chatContext.chatSessionContext.chatSessionItem.resource}`); } - const currentRepository = this._gitService.activeRepository.get(); - if (!currentRepository) { - throw new Error('No active repository found.'); - } - const git = this._gitExtensionService.getExtensionApi(); - const repo = git?.getRepository(currentRepository?.rootUri); - if (!repo) { - throw new Error( - vscode.l10n.t( - 'Unable to access {0}. Please check your permissions and try again.', - `${repoId.org}/${repoId.repo}` - ) - ); - } - - // Check for uncommitted changes and prompt user if checking is enabled - const hasChanges = repo.state.workingTreeChanges.length > 0 || repo.state.indexChanges.length > 0; - if (hasChanges && !this.autoCommitAndPushEnabled) { - this.logService.warn('Uncommitted changes detected, prompting user for confirmation.'); - stream.confirmation( - vscode.l10n.t('Uncommitted changes detected'), - vscode.l10n.t('You have uncommitted changes in your workspace. Consider committing them if you would like to include them in the cloud agent\'s work.'), - { - step: UncommittedChangesStep, - metadata: metadata satisfies ConfirmationMetadata, // Forward metadata - }, - ['Proceed', 'Cancel'] - ); - return true; // A confirmation was pushed, meaning a new request will be sent to handleConfirmationData(). The caller should STOP processing. - } - - if (hasChanges) { - const repoName = `${repoId.org}/${repoId.repo}`; - const message = vscode.l10n.t('Copilot cloud agent will continue your work in \'{0}\'.', repoName); - const detail = vscode.l10n.t('Choose how to handle your uncommitted changes before delegating to the cloud agent.'); - const push_changes = vscode.l10n.t('Push changes'); - const learn_more = vscode.l10n.t('Learn more'); - const continue_without_pushing = vscode.l10n.t('Continue without pushing'); - const modalResult = await vscode.window.showInformationMessage( - message, - { - modal: true, - detail, - }, - push_changes, - continue_without_pushing, - learn_more, - ); - - if (!modalResult) { - stream.markdown(vscode.l10n.t('Cloud agent request cancelled due to uncommitted changes.')); - return true; - } - - if (modalResult === learn_more) { - await vscode.env.openExternal(vscode.Uri.parse('https://aka.ms/coding-agent-docs')); - return true; - } - - metadata.autoPushAndCommit = modalResult === push_changes; - return false; - } - } catch (error) { - this.logService.warn(`Skipping detection of uncommitted changes due to error: ${error}`); } - return false; // No chat confirmation was pushed, meaning the caller should CONTINUE processing. - } - - private async doUntitledCreation(metadata: ConfirmationMetadata, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { - if (!metadata.chatContext?.chatSessionContext?.isUntitled) { - return {}; - } - const selectedAgent = this.sessionAgentMap.get(metadata.chatContext.chatSessionContext.chatSessionItem.resource); - const number = await this.startSession( - stream, - token, - 'untitledChatSession', + const { number, sessionId } = await this.invokeRemoteAgent( metadata.prompt, - undefined, - metadata.references, - selectedAgent, - metadata.autoPushAndCommit, + [ + await this.extractFileReferences(metadata.references), + history + ].filter(Boolean).join('\n\n').trim(), + token, + stream, + customAgentName, + head_ref, ); - if (!number) { - return {}; - } + + this.logService.debug(`Delegated to cloud agent for PR #${number} with session ID ${sessionId}`); // Store references for this session const sessionUri = vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + number }); + + // Cache references for presentation later if (metadata.references && metadata.references.length > 0) { this.sessionReferencesMap.set(sessionUri, metadata.references); } - // Tell UI to the new chat session - this._onDidCommitChatSessionItem.fire({ - original: metadata.chatContext.chatSessionContext.chatSessionItem, - modified: { - resource: sessionUri, - label: `Pull Request ${number}` - } - }); + const pullRequest = await this.findPR(number); + if (!pullRequest) { + throw new Error(`Failed to find pull request #${number} after delegation.`); + } + const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.repository.owner.login, repo: pullRequest.repository.name, pullRequestNumber: pullRequest.number }); + + if (metadata.chatContext.chatSessionContext?.isUntitled) { + // Untitled flow + this._onDidCommitChatSessionItem.fire({ + original: metadata.chatContext.chatSessionContext.chatSessionItem, + modified: { + resource: sessionUri, + label: `Pull Request ${number}` + } + }); + } else { + // Delegated flow + const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, pullRequest.body, getAuthorDisplayName(pullRequest.author), `#${pullRequest.number}`); + stream.push(card); + stream.markdown(vscode.l10n.t('GitHub Copilot cloud agent has begun working on your request. Follow its progress in the associated chat and pull request.')); + await vscode.commands.executeCommand('vscode.open', sessionUri); + } + + // Return this for external callers, eg: CLI + return { + uri, // PR uri + title: pullRequest.title, + description: pullRequest.body || '', + author: getAuthorDisplayName(pullRequest.author), + linkTag: `#${pullRequest.number}` + }; } - private async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { - if (request.acceptedConfirmationData || request.rejectedConfirmationData) { - const findAuthConfirmRequest = request.acceptedConfirmationData?.find(ref => ref?.authPermissionPrompted); - const findAuthRejectRequest = request.rejectedConfirmationData?.find(ref => ref?.authPermissionPrompted); - if (findAuthRejectRequest) { - stream.markdown(vscode.l10n.t('Cloud agent authentication requirements not met. Please allow access to proceed.')); - return {}; - } - if (findAuthConfirmRequest) { - const result = await this._authenticationUpgradeService.handleConfirmationRequestWithContext(stream, request, context.history); - request = result.request; - context = result.context ?? context; - } else { - return await this.handleConfirmationData(request, stream, context, token); + 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) { + 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.getPermissiveGitHubSession({ createIfNone: true, silent: false }); + 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.')); + } } - const accessToken = this._authenticationService.permissiveGitHubSession; - if (!accessToken) { - // Otherwise, show the permissive session upgrade prompt because it's required - this._authenticationUpgradeService.showPermissiveSessionUpgradeInChat( - stream, - request, - vscode.l10n.t('GitHub Copilot Cloud Agent requires access to your repositories on GitHub for handling requests.'), - context + 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.')); + } + } + + // Now trigger delegation + try { + await this.delegate(request, stream, context, token, metadata, 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() + .filter(key => key.startsWith(this.WORKSPACE_CONTEXT_PREFIX)); + for (const key of keys) { + this.logService.debug(`[resetWorkspaceContext] ${key}`); + this._extensionContext.workspaceState.update(key, undefined); + } + } + + 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; + } + + /** + * 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; + + const needsPermissiveAuth = !this._authenticationService.permissiveGitHubSession; + const hasUncommittedChanges = await this.detectedUncommittedChanges(); + + 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) { + 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, + ); + } + + if (buttons.length === 1) { + 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 + } + // 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 {}; } @@ -884,107 +847,97 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C isUntitled: String(context.chatSessionContext?.isUntitled) }); - if (context.chatSessionContext?.isUntitled) { - /* Generate new cloud agent session from an 'untitled' session */ + // Follow up + if (context.chatSessionContext && !context.chatSessionContext.isUntitled) { + await this.handleFollowUp(request, context, stream, token); + return {}; + } - const handledUncommittedChanges = await this.tryHandleUncommittedChanges({ - prompt: request.prompt, - references: request.references, - chatContext: context - }, stream, token); - - // If uncommitted changes were detected and a confirmation was shown, - // don't proceed with creation yet - wait for user response - if (handledUncommittedChanges) { - return {}; - } - - const { prompt, references } = request; - await this.doUntitledCreation({ - prompt, - references, - chatContext: context, - }, stream, token); - - } else if (context.chatSessionContext) { - /* Follow up to an existing cloud agent session */ - try { - if (token.isCancellationRequested) { - return {}; - } - - // Validate user input - const userPrompt = request.prompt; - if (!userPrompt || userPrompt.trim().length === 0) { - stream.markdown(vscode.l10n.t('Please provide a message for the cloud agent.')); - return {}; - } - - stream.progress(vscode.l10n.t('Preparing')); - const session = SessionIdForPr.parse(context.chatSessionContext.chatSessionItem.resource); - let prNumber = session?.prNumber; - if (!prNumber) { - prNumber = SessionIdForPr.parsePullRequestNumber(context.chatSessionContext.chatSessionItem.resource); - if (!prNumber) { - return {}; - } - } - const pullRequest = await this.findPR(prNumber); - if (!pullRequest) { - stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', context.chatSessionContext.chatSessionItem.resource)); - return {}; - } - - stream.progress(vscode.l10n.t('Delegating request to cloud agent')); - - const result = await this.addFollowUpToExistingPR(pullRequest.number, userPrompt); - if (!result) { - stream.markdown(vscode.l10n.t('Failed to add follow-up comment to the pull request.')); - return {}; - } - - // Show initial success message - stream.markdown(result); - stream.markdown('\n\n'); - - stream.progress(vscode.l10n.t('Attaching to session')); - - // Wait for new session and stream its progress - const newSession = await this.waitForNewSession(pullRequest, stream, token, true); - if (!newSession) { - return {}; - } - - // Stream the new session logs - stream.markdown(vscode.l10n.t('Cloud agent has begun work on your request')); - stream.markdown('\n\n'); - - await this.streamSessionLogs(stream, pullRequest, newSession.id, token); - - return {}; - } catch (error) { - this.logService.error(`Error in request handler: ${error}`); - stream.markdown(vscode.l10n.t('An error occurred while processing your request.')); - return { errorDetails: { message: error.message } }; - } - } else { - /* @copilot invoked from a 'normal' chat or 'cloud button' */ + // New request + const showConfirmation = await this.buildConfirmation(context); + if (showConfirmation) { + const { title, message, buttons } = showConfirmation; stream.confirmation( - vscode.l10n.t('Delegate to cloud agent'), - this.DELEGATE_MODAL_DETAILS, + title, + message, { - step: 'create', metadata: { prompt: request.prompt, references: request.references, chatContext: context, } satisfies ConfirmationMetadata }, - ['Delegate', 'Cancel'] + buttons + ); + } else { + // No confirmation + 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) { + if (!context.chatSessionContext || context.chatSessionContext.isUntitled) { + return {}; + } + const { prompt } = request; + if (!prompt || prompt.trim().length === 0) { + stream.markdown(vscode.l10n.t('Please provide a message for the cloud agent.')); + return {}; + } + + stream.progress(vscode.l10n.t('Preparing')); + const session = SessionIdForPr.parse(context.chatSessionContext.chatSessionItem.resource); + let prNumber = session?.prNumber; + if (!prNumber) { + prNumber = SessionIdForPr.parsePullRequestNumber(context.chatSessionContext.chatSessionItem.resource); + if (!prNumber) { + return {}; + } + } + const pullRequest = await this.findPR(prNumber); + if (!pullRequest) { + stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', context.chatSessionContext.chatSessionItem.resource)); + return {}; + } + + stream.progress(vscode.l10n.t('Delegating')); + + const result = await this.addFollowUpToExistingPR(pullRequest.number, prompt); + if (!result) { + stream.markdown(vscode.l10n.t('Failed to add follow-up comment to the pull request.')); + return {}; + } + + // Show initial success message + stream.markdown(result); + stream.markdown('\n\n'); + + stream.progress(vscode.l10n.t('Attaching to session')); + + // Wait for new session and stream its progress + const newSession = await this.waitForNewSession(pullRequest, stream, token, true); + if (!newSession) { + return {}; + } + + // Stream the new session logs + stream.markdown(vscode.l10n.t('Cloud agent has begun work on your request')); + stream.markdown('\n\n'); + + await this.streamSessionLogs(stream, pullRequest, newSession.id, token); + return {}; + } + private async extractFileReferences(references: readonly vscode.ChatPromptReference[] | undefined): Promise { if (!references || references.length === 0) { return; @@ -1145,7 +1098,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return { hasStreamedContent: false, hasSetupStepProgress: false }; } - // Parse the new log content const contentBuilder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService, this._prFileChangesService); @@ -1317,7 +1269,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return; } - async addFollowUpToExistingPR(pullRequestNumber: number, userPrompt: string, summary?: string): Promise { + private async addFollowUpToExistingPR(pullRequestNumber: number, userPrompt: string, summary?: string): Promise { try { const pr = await this.findPR(pullRequestNumber); if (!pr) { @@ -1370,115 +1322,18 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C return undefined; } - private async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true, chatStream?: vscode.ChatResponseStream, customAgentName?: string): Promise { - // TODO: support selecting remote - // await this.promptAndUpdatePreferredGitHubRemote(true); - const repoId = await getRepoId(this._gitService); - if (!repoId) { - return { error: vscode.l10n.t('Repository information is not available.'), state: 'error' }; - } - const currentRepository = this._gitService.activeRepository.get(); - if (!currentRepository) { - return { error: vscode.l10n.t('No active repository found.'), state: 'error' }; - } - const git = this._gitExtensionService.getExtensionApi(); - const repo = git?.getRepository(currentRepository?.rootUri); - // Check if user has permission to access the repository - if (!repo) { - return { - error: vscode.l10n.t( - 'Unable to access {0}. Please check your permissions and try again.', - `\`${repoId.org}/${repoId.repo}\`` - ), - state: 'error', - }; - } - - // NOTE: This is as unobtrusive as possible with the current high-level APIs. - // Get the current branch as base_ref (the ref the PR will merge into) - const base_ref = repo.state.HEAD?.name; - if (!base_ref) { - return { error: vscode.l10n.t('Unable to determine the current branch.'), state: 'error' }; - } - let head_ref: string | undefined; - - const remoteName = - repo.state.HEAD?.upstream?.remote ?? - currentRepository?.upstreamRemote ?? - repo.state.remotes?.[0]?.name; - - const hasChanges = repo.state.workingTreeChanges.length > 0 || repo.state.indexChanges.length > 0; - if (hasChanges && autoPushAndCommit) { - if (!remoteName) { - return { - error: vscode.l10n.t('Unable to determine a Git remote for this repository. Configure an upstream remote and try again.'), - state: 'error' - }; - } - try { - chatStream?.progress(vscode.l10n.t('Committing and pushing local changes')); - head_ref = await this.gitOperationsManager.commitAndPushChanges({ repository: repo, remoteName, baseRef: base_ref }); - } catch (error) { - return { - error: vscode.l10n.t('Failed to commit and push changes: {0}. Please commit or stash your changes manually and try again.', error instanceof Error ? error.message : String(error)), - innerError: error instanceof Error ? error.message : String(error), - state: 'error' - }; - } - } - - if (repo && remoteName && base_ref) { - try { - const remoteBranches = - (await repo.getBranches({ remote: true })) - .filter(b => b.remote); // Has an associated remote - const expectedRemoteBranch = `${remoteName}/${base_ref}`; - const alternateNames = new Set([ - expectedRemoteBranch, - `refs/remotes/${expectedRemoteBranch}`, - base_ref - ]); - 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); - }); - - if (!hasRemoteBranch) { - if (this.autoCommitAndPushEnabled) { - this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote. Auto-pushing because autoCommitAndPush is enabled.`); - await repo.push(remoteName, base_ref, true); - } else { - this.logService.warn(`Base branch '${expectedRemoteBranch}' not found on remote.`); - return { - error: vscode.l10n.t('The branch \'{0}\' does not exist on remote \'{1}\'. Please push the branch and try again.', base_ref, remoteName), - state: 'error' - }; - } - } - } catch (error) { - this.logService.error(`Failed to verify remote branch for cloud agent: ${error instanceof Error ? error.message : String(error)}`); - return { - error: vscode.l10n.t('Unable to verify that branch \'{0}\' exists on remote \'{1}\'. Please ensure the remote branch is available and try again.', base_ref, remoteName), - innerError: error instanceof Error ? error.message : undefined, - state: 'error' - }; - } - } - + private async invokeRemoteAgent(prompt: string, problemContext: string, token: vscode.CancellationToken, stream: vscode.ChatResponseStream, customAgentName?: string, head_ref?: string): Promise<{ number: number; sessionId: string }> { const title = extractTitle(prompt, problemContext); const { problemStatement, isTruncated } = truncatePrompt(this.logService, prompt, problemContext); + const { baseRef } = await this.gitOperationsManager.repoInfo(); + const repoId = await getRepoId(this._gitService); + + if (!repoId) { + throw new Error(vscode.l10n.t('Unable to determine repository information. Please ensure you are working within a Git repository.')); + } if (isTruncated) { - chatStream?.progress(vscode.l10n.t('Truncating context')); + stream.progress(vscode.l10n.t('Truncating context')); const truncationResult = await vscode.window.showWarningMessage( vscode.l10n.t('Prompt size exceeded'), { modal: true, detail: vscode.l10n.t('Your prompt will be truncated to fit within cloud agent\'s context window. This may affect the quality of the response.') }, CONTINUE_TRUNCATION); const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION; @@ -1491,7 +1346,7 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C isCancelled: String(userCancelled), }); if (userCancelled) { - return { error: vscode.l10n.t('User cancelled due to truncation.'), state: 'error' }; + throw new Error(vscode.l10n.t('User cancelled due to truncation.')); } } @@ -1502,64 +1357,42 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C pull_request: { title, body_placeholder: formatBodyPlaceholder(title), - base_ref, + base_ref: baseRef, body_suffix, ...(head_ref && { head_ref }), } }; - try { - chatStream?.progress(vscode.l10n.t('Delegating to cloud agent')); - this.logService.trace(`Invoking cloud agent job with payload: ${JSON.stringify(payload)}`); - const response = await this._octoKitService.postCopilotAgentJob(repoId.org, repoId.repo, JOBS_API_VERSION, payload); - if (!this.validateRemoteAgentJobResponse(response)) { - const statusCode = response?.status; - switch (statusCode) { - case 422: - // NOTE: Although earlier checks should prevent this, ensure that if we end up - // with a 422 from the API, we give a useful error message - return { - error: vscode.l10n.t('The cloud agent was unable to create a pull request with the specified base branch \'{0}\'. Please push branch to the remote and try again.', base_ref), - innerError: `Status code 422 received from cloud agent.`, - state: 'error', - }; - default: - return { - error: vscode.l10n.t('Received invalid response {0}from cloud agent.', statusCode ? statusCode + ' ' : ''), - innerError: `Response ${JSON.stringify(response)}`, - state: 'error', - }; - } + stream?.progress(vscode.l10n.t('Delegating to cloud agent')); + this.logService.trace(`[postCopilotAgentJob] Invoking cloud agent job with payload: ${JSON.stringify(payload)}`); + const response = await this._octoKitService.postCopilotAgentJob(repoId.org, repoId.repo, JOBS_API_VERSION, payload); + this.logService.trace(`[postCopilotAgentJob] Received response from cloud agent job invocation: ${JSON.stringify(response)}`); + if (!this.validateRemoteAgentJobResponse(response)) { + const statusCode = response?.status; + switch (statusCode) { + case 422: + // NOTE: Although earlier checks should prevent this, ensure that if we end up + // with a 422 from the API, we give a useful error message + throw new Error(vscode.l10n.t('The cloud agent was unable to create a pull request with the specified base branch \'{0}\'. Please push branch to the remote and try again.', baseRef)); + default: + throw new Error(vscode.l10n.t('Received invalid response {0} from cloud agent.', statusCode ? statusCode : '')); } - // For v1 API, we need to fetch the job details to get the PR info - // Since the PR might not be created immediately, we need to poll for it - chatStream?.progress(vscode.l10n.t('Creating pull request')); - const jobInfo = await this.waitForJobWithPullRequest(repoId.org, repoId.repo, response.job_id, token); - if (!jobInfo || !jobInfo.pull_request) { - return { error: vscode.l10n.t('Failed to retrieve pull request information from job'), state: 'error' }; - } - - const { number } = jobInfo.pull_request; - - // Find the actual PR to get the HTML URL - const pullRequest = await this.findPR(number); - if (!pullRequest) { - return { error: vscode.l10n.t('Failed to find pull request'), state: 'error' }; - } - const htmlUrl = pullRequest.url; - - const webviewUri = await toOpenPullRequestWebviewUri({ owner: pullRequest.repository.owner.login, repo: pullRequest.repository.name, pullRequestNumber: number }); - const prLlmString = `The remote agent has begun work and has created a pull request. Details about the pull request are being shown to the user. If the user wants to track progress or iterate on the agent's work, they should use the pull request.`; - return { - state: 'success', - number, - link: htmlUrl, - webviewUri, - llmDetails: head_ref ? `Local pending changes have been pushed to branch '${head_ref}'. ${prLlmString}` : prLlmString, - sessionId: response.session_id - }; - } catch (error) { - return { error: vscode.l10n.t('Failed delegating to cloud agent. Please try again later.'), innerError: error.message, state: 'error' }; } + + stream.progress(vscode.l10n.t('Creating pull request')); + const jobInfo = await this.waitForJobWithPullRequest(repoId.org, repoId.repo, response.job_id, token); + + if (!jobInfo || !jobInfo.pull_request) { + throw new Error(vscode.l10n.t('Failed to retrieve pull request information from job')); + } + + const { number } = jobInfo.pull_request; + if (!number || isNaN(number)) { + throw new Error(vscode.l10n.t('Invalid pull request number received from cloud agent')); + } + return { + number, + sessionId: response.session_id + }; } } 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 eaaea18bbb8..1dade876e98 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 @@ -71,8 +71,13 @@ class FakeGitService extends mock() { // Cloud provider fake for delegate scenario class FakeCloudProvider extends mock() { - override tryHandleUncommittedChanges = vi.fn(async () => false); - override createDelegatedChatSession = vi.fn(async () => ({ uri: 'pr://1', title: 'PR Title', description: 'Desc', author: 'Me', linkTag: 'tag' })) as unknown as CopilotCloudSessionsProvider['createDelegatedChatSession']; + override delegate = vi.fn(async () => ({ + uri: vscode.Uri.parse('pr://1'), + title: 'PR Title', + description: 'PR Description', + author: 'Test Author', + linkTag: '#1' + })) as unknown as CopilotCloudSessionsProvider['delegate']; } @@ -245,8 +250,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { // Warning should appear (we emitted stream.warning). The mock stream only records markdown. // Delegate path adds assistant PR metadata; ensure output contains PR metadata tag instead of relying on warning capture. expect(sdkSession.emittedEvents[1].content).toMatch(/ { @@ -261,8 +265,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { expect(manager.sessions.size).toBe(1); const sdkSession = Array.from(manager.sessions.values())[0]; - expect(cloudProvider.tryHandleUncommittedChanges).toHaveBeenCalled(); - expect(cloudProvider.createDelegatedChatSession).toHaveBeenCalled(); + expect(cloudProvider.delegate).toHaveBeenCalled(); // PR metadata recorded expect(sdkSession.emittedEvents.length).toBe(2); expect(sdkSession.emittedEvents[0].event).toBe('user.message'); @@ -299,13 +302,13 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { const sessionId = 'existing-confirm'; const sdkSession = new MockCliSdkSession(sessionId, new Date()); manager.sessions.set(sessionId, sdkSession); - const request = new TestChatRequest('Apply'); - (request as any).acceptedConfirmationData = [{ step: 'uncommitted-changes', metadata: { prompt: 'delegate work' } }]; + const request = new TestChatRequest('my prompt'); const context = createChatContext(sessionId, false); + (request as any).acceptedConfirmationData = [{ step: 'uncommitted-changes', metadata: { chatContext: context } }]; const stream = new MockChatResponseStream(); const token = disposables.add(new CancellationTokenSource()).token; // Cloud provider will create delegated chat session returning prInfo - (cloudProvider.createDelegatedChatSession as unknown as ReturnType).mockResolvedValue({ uri: 'pr://2', title: 'T', description: 'D', author: 'A', linkTag: 'L' }); + (cloudProvider.delegate as unknown as ReturnType).mockResolvedValue({ uri: 'pr://2', title: 'T', description: 'D', author: 'A', linkTag: 'L' }); await participant.createHandler()(request, context, stream, token); @@ -317,7 +320,13 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { expect(sdkSession.emittedEvents[1].event).toBe('assistant.message'); expect(sdkSession.emittedEvents[1].content).toContain('pr://2'); // Cloud provider used with provided metadata - expect(cloudProvider.createDelegatedChatSession).toHaveBeenCalledWith({ prompt: 'delegate work', chatContext: context }, expect.anything(), token); + expect(cloudProvider.delegate).toHaveBeenCalledWith( + request, + stream, + context, + token, + { chatContext: context } + ); }); it('handleConfirmationData cancels when uncommitted-changes rejected', async () => { @@ -334,7 +343,7 @@ describe('CopilotCLIChatSessionParticipant.handleRequest', () => { // Should not record push or call delegate session expect(sdkSession.emittedEvents.length).toBe(0); - expect(cloudProvider.createDelegatedChatSession).not.toHaveBeenCalled(); + expect(cloudProvider.delegate).not.toHaveBeenCalled(); // Cancellation message markdown captured expect(stream.output.some(o => /Cloud agent delegation request cancelled/i.test(o))).toBe(true); }); diff --git a/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts b/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts index 99c2a0f3013..bb8dca467e1 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts @@ -13,9 +13,6 @@ 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'; -type RemoteAgentSuccessResult = { link: string; state: 'success'; number: number; webviewUri: vscode.Uri; llmDetails: string; sessionId: string }; -type RemoteAgentErrorResult = { error: string; innerError?: string; state: 'error' }; -export type RemoteAgentResult = RemoteAgentSuccessResult | RemoteAgentErrorResult; /** * Truncation utility to ensure the problem statement sent to Copilot API is under the maximum length. diff --git a/extensions/copilot/src/platform/github/common/githubAPI.ts b/extensions/copilot/src/platform/github/common/githubAPI.ts index 0cd46e93d4b..1ed8c75a2ac 100644 --- a/extensions/copilot/src/platform/github/common/githubAPI.ts +++ b/extensions/copilot/src/platform/github/common/githubAPI.ts @@ -232,9 +232,13 @@ export async function makeSearchGraphQLRequest( first }; + // TODO: Handle rate limiting + // result.errors[0] + // {type: 'RATE_LIMIT', code: 'graphql_rate_limit', message: 'API rate limit already exceeded for user ID xxxxxxx.'} + const result = await makeGitHubGraphQLRequest(fetcherService, logService, telemetry, host, query, token, variables); - return result ? result.data.search.nodes : []; + return result.data?.search?.nodes ?? []; } export async function getPullRequestFromGlobalId(