From afa792a047d0a4705a433bdd016587b12f90deab Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Wed, 15 Oct 2025 19:53:41 -0700 Subject: [PATCH] Moving copilot coding agent chat participant (#1360) --- .../copilotChatSessionContentBuilder.ts | 8 +- .../copilotChatSessionsProvider.ts | 708 +++++++++++++++++- .../vscode/copilotCodingAgentUtils.ts | 71 ++ .../vscode.proposed.chatSessionsProvider.d.ts | 4 + .../src/platform/github/common/githubAPI.ts | 17 +- .../platform/github/common/githubService.ts | 84 +++ .../github/common/octoKitServiceImpl.ts | 32 +- 7 files changed, 899 insertions(+), 25 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts index 35cff42d7d8..1adb5e5394d 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionContentBuilder.ts @@ -94,12 +94,12 @@ export interface ParsedToolCallDetails { export class ChatSessionContentBuilder { constructor( private type: string, - private getLogsForSession: (id: string) => Promise, ) { } public async buildSessionHistory( sessions: SessionInfo[], pullRequest: PullRequestSearchItem, + getLogsForSession: (id: string) => Promise, ): Promise> { const sortedSessions = sessions .filter((session, index, array) => @@ -113,7 +113,7 @@ export class ChatSessionContentBuilder { // Process all sessions concurrently while maintaining order await Promise.all( sortedSessions.map(async (session, sessionIndex) => { - const logs = await this.getLogsForSession(session.id); + const logs = await getLogsForSession(session.id); // Create response turn const response = await this.createResponseTurn(pullRequest, logs, session); history.push(new ChatRequestTurn2( @@ -187,7 +187,7 @@ export class ChatSessionContentBuilder { } } - private parseSessionLogs(rawText: string): SessionResponseLogChunk[] { + public parseSessionLogs(rawText: string): SessionResponseLogChunk[] { const parts = rawText .split(/\r?\n/) .filter(part => part.startsWith('data: ')) @@ -262,7 +262,7 @@ export class ChatSessionContentBuilder { return currentResponseContent; } - private createToolInvocationPart(pullRequest: PullRequestSearchItem, toolCall: ToolCall, deltaContent: string = ''): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined { + public createToolInvocationPart(pullRequest: PullRequestSearchItem, toolCall: ToolCall, deltaContent: string = ''): ChatToolInvocationPart | ChatResponseThinkingProgressPart | undefined { if (!toolCall.function?.name || !toolCall.id) { return undefined; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts index f2d16c55e01..df7803348a2 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotChatSessionsProvider.ts @@ -4,25 +4,69 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; +import { ChatSessionItem } from 'vscode'; +import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService'; import { getGithubRepoIdFromFetchUrl, GithubRepoId, IGitService } from '../../../platform/git/common/gitService'; -import { PullRequestSearchItem } from '../../../platform/github/common/githubAPI'; -import { IOctoKitService } from '../../../platform/github/common/githubService'; +import { PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI'; +import { IOctoKitService, JobInfo, RemoteAgentJobPayload } from '../../../platform/github/common/githubService'; +import { ILogService } from '../../../platform/log/common/logService'; +import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; import { UriHandlerPaths, UriHandlers } from '../vscode/chatSessionsUriHandler'; +import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, JOBS_API_VERSION, RemoteAgentResult, truncatePrompt } from '../vscode/copilotCodingAgentUtils'; import { ChatSessionContentBuilder } from './copilotChatSessionContentBuilder'; +type ConfirmationResult = { step: string; accepted: boolean; metadata?: CreatePromptMetadata /* | SomeOtherMetadata */ }; + +interface CreatePromptMetadata { + prompt: string; + history?: string; + references?: vscode.ChatPromptReference[]; +} + +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; +} + export class CopilotChatSessionsProvider 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.'); + private readonly COPILOT = '@copilot'; + private readonly _onDidChangeChatSessionItems = this._register(new vscode.EventEmitter()); public onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; private readonly _onDidCommitChatSessionItem = this._register(new vscode.EventEmitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); public onDidCommitChatSessionItem = this._onDidCommitChatSessionItem.event; private chatSessions: Map = new Map(); private chatSessionItemsPromise: Promise | undefined; + private readonly _onDidCommitChatSession = this._register(new vscode.EventEmitter<{ original: ChatSessionItem; modified: ChatSessionItem }>()); + readonly onDidCommitChatSession = this._onDidCommitChatSession.event; + public chatParticipant = vscode.chat.createChatParticipant(CopilotChatSessionsProvider.TYPE, async (request, context, stream, token) => + await this.chatParticipantImpl(request, context, stream, token) + ); + constructor( @IOctoKitService private readonly _octoKitService: IOctoKitService, @IGitService private readonly _gitService: IGitService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @ILogService private readonly logService: ILogService, + @IGitExtensionService private readonly _gitExtensionService: IGitExtensionService ) { super(); } @@ -66,25 +110,13 @@ export class CopilotChatSessionsProvider extends Disposable implements vscode.Ch } async provideChatSessionContent(sessionId: string, token: vscode.CancellationToken): Promise { - let pr = this.chatSessions.get(Number(sessionId)); + const pr = await this.findPR(Number(sessionId)); if (!pr) { - try { - const repoId = await this.getRepoId(); - if (!repoId) { - throw new Error(`Failed to determine GitHub repo from workspace`); - } - const pullRequests = await this._octoKitService.getCopilotPullRequestsForUser(repoId.org, repoId.repo); - pr = pullRequests.find(pr => pr.number.toString() === sessionId); - if (!pr) { - throw new Error(`Pull request not found for ID: ${sessionId}`); - } - } catch (e) { - throw new Error(`Session not found for ID: ${sessionId}`, e); - } + throw new Error(`Session not found for ID: ${sessionId}`); } const sessions = await this._octoKitService.getCopilotSessionsForPR(pr.fullDatabaseId.toString()); - const sessionContentBuilder = new ChatSessionContentBuilder(CopilotChatSessionsProvider.TYPE, (sessionId: string) => this._octoKitService.getSessionLogs(sessionId)); - const history = await sessionContentBuilder.buildSessionHistory(sessions, pr); + const sessionContentBuilder = new ChatSessionContentBuilder(CopilotChatSessionsProvider.TYPE); + const history = await sessionContentBuilder.buildSessionHistory(sessions, pr, (sessionId: string) => this._octoKitService.getSessionLogs(sessionId)); return { history, activeResponseCallback: async () => { }, @@ -92,6 +124,25 @@ export class CopilotChatSessionsProvider extends Disposable implements vscode.Ch }; } + private async findPR(prNumber: number) { + let pr = this.chatSessions.get(prNumber); + if (pr) { + return pr; + } + const repoId = await this.getRepoId(); + if (!repoId) { + this.logService.warn('Failed to determine GitHub repo from workspace'); + return undefined; + } + const pullRequests = await this._octoKitService.getCopilotPullRequestsForUser(repoId.org, repoId.repo); + pr = pullRequests.find(pr => pr.number === prNumber); + if (!pr) { + this.logService.warn(`Pull request not found for number: ${prNumber}`); + return undefined; + } + return pr; + } + private getSessionState(state: string): vscode.ChatSessionStatus { switch (state) { case 'failed': @@ -128,4 +179,625 @@ export class CopilotChatSessionsProvider extends Disposable implements vscode.Ch return getGithubRepoIdFromFetchUrl(repo.remoteFetchUrls[0]); } } + + private async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) { + const startSession = async (source: string, prompt: string, history?: string, references?: readonly vscode.ChatPromptReference[]) => { + /* __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, + [ + // TODO: support file references + // this.extractFileReferences(references), + history + ].join('\n\n').trim(), + token, + false, + stream, + ); + 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; + }; + + const handleConfirmationData = async () => { + const results: ConfirmationResult[] = []; + results.push(...(request.acceptedConfirmationData?.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) { + stream.markdown(vscode.l10n.t('Coding agent request cancelled.')); + return {}; + } + const { prompt, history, references } = data.metadata as CreatePromptMetadata; + const number = await startSession('chat', prompt, history, references); + if (!number) { + return {}; + } + 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 {}; + } + + // TODO: handle card + // const uri = await this.toOpenPullRequestWebviewUri({ owner: pullRequest.remote.owner, repo: pullRequest.remote.repositoryName, pullRequestNumber: pullRequest.number }); + // const plaintextBody = marked.parse(pullRequest.body, { renderer: new PlainTextRenderer(true), smartypants: true }).trim(); + // const card = new vscode.ChatResponsePullRequestPart(uri, pullRequest.title, plaintextBody, pullRequest.author.specialDisplayName ?? pullRequest.author.login, `#${pullRequest.number}`); + // stream.push(card); + stream.markdown(vscode.l10n.t('GitHub Copilot coding agent has begun working on your request. Follow its progress in the associated chat and pull request.')); + vscode.window.showChatSession(CopilotChatSessionsProvider.TYPE, String(number), { viewColumn: vscode.ViewColumn.Active }); + break; + } + default: + stream.warning(`Unknown confirmation step: ${data.step}\n\n`); + break; + } + } + return {}; + }; + + if (request.acceptedConfirmationData || request.rejectedConfirmationData) { + return await handleConfirmationData(); + } + + if (context.chatSessionContext?.isUntitled) { + /* Generate new coding agent session from an 'untitled' session */ + const number = await startSession( + 'untitledChatSession', + context.chatSummary?.prompt ?? request.prompt, + context.chatSummary?.history, + request.references + ); + if (!number) { + return {}; + } + // Tell UI to the new chat session + this._onDidCommitChatSession.fire({ original: context.chatSessionContext.chatSessionItem, modified: { id: String(number), label: `Pull Request ${number}` } }); + } else if (context.chatSessionContext) { + /* Follow up to an existing coding 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 coding agent.')); + return {}; + } + + stream.progress(vscode.l10n.t('Preparing')); + + const pullRequest = await this.findPR(parseInt(context.chatSessionContext.chatSessionItem.id, 10)); + if (!pullRequest) { + stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', context.chatSessionContext.chatSessionItem.id)); + return {}; + } + + stream.progress(vscode.l10n.t('Delegating request to coding 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('Coding 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' */ + stream.confirmation( + vscode.l10n.t('Delegate to coding agent'), + this.DELEGATE_MODAL_DETAILS, + { + step: 'create', + metadata: { + prompt: context.chatSummary?.prompt ?? request.prompt, + history: context.chatSummary?.history, + references: request.references, + } + }, + ['Delegate', 'Cancel'] + ); + } + } + + private async streamSessionLogs(stream: vscode.ChatResponseStream, pullRequest: PullRequestSearchItem, sessionId: string, token: vscode.CancellationToken): Promise { + let lastLogLength = 0; + let lastProcessedLength = 0; + let hasActiveProgress = false; + const pollingInterval = 3000; // 3 seconds + + return new Promise((resolve, reject) => { + let isCompleted = false; + + const complete = async () => { + if (isCompleted) { + return; + } + isCompleted = true; + + // TODO: support file changes + // await pullRequest.getFileChangesInfo(); + // const multiDiffPart = await this.getFileChangesMultiDiffPart(pullRequest); + // if (multiDiffPart) { + // stream.push(multiDiffPart); + // } + + resolve(); + }; + + const pollForUpdates = async (): Promise => { + try { + if (token.isCancellationRequested) { + complete(); + return; + } + + // Get the specific session info + const sessionInfo = await this._octoKitService.getSessionInfo(sessionId); + if (!sessionInfo || token.isCancellationRequested) { + complete(); + return; + } + + // Get session logs + const logs = await this._octoKitService.getSessionLogs(sessionId); + + // Check if session is still in progress + if (sessionInfo.state !== 'in_progress') { + if (logs.length > lastProcessedLength) { + const newLogContent = logs.slice(lastProcessedLength); + const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent); + if (streamResult.hasStreamedContent) { + hasActiveProgress = false; + } + } + hasActiveProgress = false; + complete(); + return; + } + + if (logs.length > lastLogLength) { + this.logService.trace(`New logs detected, attempting to stream content`); + const newLogContent = logs.slice(lastProcessedLength); + const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent); + lastProcessedLength = logs.length; + + if (streamResult.hasStreamedContent) { + this.logService.trace(`Content was streamed, resetting hasActiveProgress to false`); + hasActiveProgress = false; + } else if (streamResult.hasSetupStepProgress) { + this.logService.trace(`Setup step progress detected, keeping progress active`); + // Keep hasActiveProgress as is, don't reset it + } else { + this.logService.trace(`No content was streamed, keeping hasActiveProgress as ${hasActiveProgress}`); + } + } + + lastLogLength = logs.length; + + if (!token.isCancellationRequested && sessionInfo.state === 'in_progress') { + if (!hasActiveProgress) { + this.logService.trace(`Showing progress indicator (hasActiveProgress was false)`); + stream.progress('Working...'); + hasActiveProgress = true; + } else { + this.logService.trace(`NOT showing progress indicator (hasActiveProgress was true)`); + } + setTimeout(pollForUpdates, pollingInterval); + } else { + complete(); + } + } catch (error) { + this.logService.error(`Error polling for session updates: ${error}`); + if (!token.isCancellationRequested) { + setTimeout(pollForUpdates, pollingInterval); + } else { + reject(error); + } + } + }; + + // Start polling + setTimeout(pollForUpdates, pollingInterval); + }); + } + + private async streamNewLogContent(pullRequest: PullRequestSearchItem, stream: vscode.ChatResponseStream, newLogContent: string): Promise<{ hasStreamedContent: boolean; hasSetupStepProgress: boolean }> { + try { + if (!newLogContent.trim()) { + return { hasStreamedContent: false, hasSetupStepProgress: false }; + } + + + // Parse the new log content + const contentBuilder = new ChatSessionContentBuilder(CopilotChatSessionsProvider.TYPE); + + const logChunks = contentBuilder.parseSessionLogs(newLogContent); + let hasStreamedContent = false; + let hasSetupStepProgress = false; + + for (const chunk of logChunks) { + for (const choice of chunk.choices) { + const delta = choice.delta; + + if (delta.role === 'assistant') { + // Handle special case for run_custom_setup_step/run_setup + if (choice.finish_reason === 'tool_calls' && delta.tool_calls?.length && (delta.tool_calls[0].function.name === 'run_custom_setup_step' || delta.tool_calls[0].function.name === 'run_setup')) { + const toolCall = delta.tool_calls[0]; + let args: any = {}; + try { + args = JSON.parse(toolCall.function.arguments); + } catch { + // fallback to empty args + } + + if (delta.content && delta.content.trim()) { + // Finished setup step - create/update tool part + const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content); + if (toolPart) { + stream.push(toolPart); + hasStreamedContent = true; + } + } else { + // Running setup step - just track progress + hasSetupStepProgress = true; + this.logService.trace(`Setup step in progress: ${args.name || 'Unknown step'}`); + } + } else { + if (delta.content) { + if (!delta.content.startsWith('')) { + stream.markdown(delta.content); + hasStreamedContent = true; + } + } + + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, delta.content || ''); + if (toolPart) { + stream.push(toolPart); + hasStreamedContent = true; + } + } + } + } + } + + // Handle finish reasons + if (choice.finish_reason && choice.finish_reason !== 'null') { + this.logService.trace(`Streaming finish_reason: ${choice.finish_reason}`); + } + } + } + + if (hasStreamedContent) { + this.logService.trace(`Streamed content (markdown or tool parts), progress should be cleared`); + } else if (hasSetupStepProgress) { + this.logService.trace(`Setup step progress detected, keeping progress indicator`); + } else { + this.logService.trace(`No actual content streamed, progress may still be showing`); + } + return { hasStreamedContent, hasSetupStepProgress }; + } catch (error) { + this.logService.error(`Error streaming new log content: ${error}`); + return { hasStreamedContent: false, hasSetupStepProgress: false }; + } + } + + private async waitForQueuedToInProgress( + sessionId: string, + token?: vscode.CancellationToken + ): Promise { + let sessionInfo: SessionInfo | undefined; + + const waitForQueuedMaxRetries = 3; + const waitForQueuedDelay = 5_000; // 5 seconds + + // Allow for a short delay before the session is marked as 'queued' + let waitForQueuedCount = 0; + do { + sessionInfo = await this._octoKitService.getSessionInfo(sessionId); + if (sessionInfo && sessionInfo.state === 'queued') { + this.logService.trace('Queued session found'); + break; + } + if (waitForQueuedCount < waitForQueuedMaxRetries) { + this.logService.trace('Session not yet queued, waiting...'); + await new Promise(resolve => setTimeout(resolve, waitForQueuedDelay)); + } + ++waitForQueuedCount; + } while (waitForQueuedCount <= waitForQueuedMaxRetries && (!token || !token.isCancellationRequested)); + + if (!sessionInfo || sessionInfo.state !== 'queued') { + // Failure + this.logService.trace('Failed to find queued session'); + return; + } + + const maxWaitTime = 2 * 60 * 1_000; // 2 minutes + const pollInterval = 3_000; // 3 seconds + const startTime = Date.now(); + + this.logService.trace(`Session ${sessionInfo.id} is queued, waiting for transition to in_progress...`); + while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) { + const sessionInfo = await this._octoKitService.getSessionInfo(sessionId); + if (sessionInfo?.state === 'in_progress') { + this.logService.trace(`Session ${sessionInfo.id} now in progress.`); + return sessionInfo; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + } + + private async waitForNewSession( + pullRequest: PullRequestSearchItem, + stream: vscode.ChatResponseStream, + token: vscode.CancellationToken, + waitForTransitionToInProgress: boolean = false + ): Promise { + // Get the current number of sessions + const initialSessions = await this._octoKitService.getCopilotSessionsForPR(pullRequest.fullDatabaseId.toString()); + const initialSessionCount = initialSessions.length; + + // Poll for a new session to start + const maxWaitTime = 5 * 60 * 1000; // 5 minutes + const pollInterval = 3000; // 3 seconds + const startTime = Date.now(); + + while (Date.now() - startTime < maxWaitTime && !token.isCancellationRequested) { + const currentSessions = await this._octoKitService.getCopilotSessionsForPR(pullRequest.fullDatabaseId.toString()); + + // Check if a new session has started + if (currentSessions.length > initialSessionCount) { + const newSession = currentSessions + .sort((a: { created_at: string | number | Date }, b: { created_at: string | number | Date }) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0]; + if (!waitForTransitionToInProgress) { + return newSession; + } + const inProgressSession = await this.waitForQueuedToInProgress(newSession.id, token); + if (!inProgressSession) { + stream.markdown(vscode.l10n.t('Timed out waiting for coding agent to begin work. Please try again shortly.')); + return; + } + return inProgressSession; + } + + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + stream.markdown(vscode.l10n.t('Timed out waiting for the coding agent to respond. The agent may still be processing your request.')); + return; + } + + async addFollowUpToExistingPR(pullRequestNumber: number, userPrompt: string, summary?: string): Promise { + try { + const pr = await this.findPR(pullRequestNumber); + if (!pr) { + this.logService.error(`Could not find pull request #${pullRequestNumber}`); + return; + } + // Add a comment tagging @copilot with the user's prompt + const commentBody = `${this.COPILOT} ${userPrompt} \n\n --- \n\n ${summary ?? ''}`; + const commentResult = { + body: commentBody, + user: { + login: pr.author?.login + }, + createdAt: new Date().toISOString() + }; + if (!commentResult) { + this.logService.error(`Failed to add comment to PR #${pullRequestNumber}`); + return; + } + // allow-any-unicode-next-line + return vscode.l10n.t('🚀 Follow-up comment added to [#{0}]({1})', pullRequestNumber, commentResult.body); + } catch (err) { + this.logService.error(`Failed to add follow-up comment to PR #${pullRequestNumber}: ${err}`); + return; + } + } + + private async waitForJobWithPullRequest( + owner: string, + repo: string, + jobId: string, + token?: vscode.CancellationToken + ): Promise { + const maxWaitTime = 30 * 1000; // 30 seconds + const pollInterval = 2000; // 2 seconds + const startTime = Date.now(); + + this.logService.trace(`Waiting for job ${jobId} to have pull request information...`); + + while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) { + const jobInfo = await this._octoKitService.getJobByJobId(owner, repo, jobId, 'vscode-copilot-chat'); + if (jobInfo && jobInfo.pull_request && jobInfo.pull_request.number) { + this.logService.trace(`Job ${jobId} now has pull request #${jobInfo.pull_request.number}`); + return jobInfo; + } + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + this.logService.warn(`Timed out waiting for job ${jobId} to have pull request information`); + return undefined; + } + + async invokeRemoteAgent(prompt: string, problemContext?: string, token?: vscode.CancellationToken, autoPushAndCommit = true, chatStream?: vscode.ChatResponseStream): Promise { + // TODO: support selecting remote + // await this.promptAndUpdatePreferredGitHubRemote(true); + const repoId = await this.getRepoId(); + 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; // This is the ref coding agent starts work from (omitted unless we push local changes) + + const hasChanges = + autoPushAndCommit && + ((currentRepository?.changes?.workingTree && currentRepository.changes.workingTree.length > 0) || (currentRepository?.changes?.indexChanges && currentRepository.changes.indexChanges.length > 0)); + if (hasChanges) { + // TODO: support pending changes + // if (!CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled()) { + // return { error: vscode.l10n.t('Uncommitted changes detected. Please commit or stash your changes before starting the remote agent. Enable \'{0}\' to push your changes automatically.', CODING_AGENT_AUTO_COMMIT_AND_PUSH), state: 'error' }; + // } + // try { + // chatStream?.progress(vscode.l10n.t('Waiting for local changes')); + // head_ref = await this.gitOperationsManager.commitAndPushChanges(repoInfo); + // } catch (error) { + // return { error: vscode.l10n.t('Failed to commit and push changes. Please try again later.'), innerError: error.message, state: 'error' }; + // } + } + + // try { + // if (!(await ghRepository.hasBranch(base_ref))) { + // if (!CopilotRemoteAgentConfig.getAutoCommitAndPushEnabled()) { + // // We won't auto-push a branch if the user has disabled the setting + // return { error: vscode.l10n.t('The branch \'{0}\' does not exist on the remote repository \'{1}/{2}\'. Please create the remote branch first.', base_ref, owner, repo), state: 'error' }; + // } + // // Push the branch + // Logger.appendLine(`Base ref needs to exist on remote. Auto pushing base_ref '${base_ref}' to remote repository '${owner}/${repo}'`, CopilotRemoteAgentManager.ID); + // await repository.push(remote.remoteName, base_ref, true); + // } + // } catch (error) { + // return { error: vscode.l10n.t('Failed to configure base branch \'{0}\' does not exist on the remote repository \'{1}/{2}\'. Please create the remote branch first.', base_ref, owner, repo), state: 'error' }; + // } + + const title = extractTitle(prompt, problemContext); + const { problemStatement, isTruncated } = truncatePrompt(this.logService, prompt, problemContext); + + if (isTruncated) { + chatStream?.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 coding agent\'s context window. This may affect the quality of the response.') }, CONTINUE_TRUNCATION); + const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION; + /* __GDPR__ + "copilot.codingAgent.truncation" : { + "isCancelled" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" } + } + */ + this.telemetry.sendTelemetryEvent('copilot.codingAgent.truncation', { microsoft: true, github: false }, { + isCancelled: String(userCancelled), + }); + if (userCancelled) { + return { error: vscode.l10n.t('User cancelled due to truncation.'), state: 'error' }; + } + } + + const payload: RemoteAgentJobPayload = { + problem_statement: problemStatement, + event_type: 'visual_studio_code_remote_agent_tool_invoked', + pull_request: { + title, + body_placeholder: formatBodyPlaceholder(title), + base_ref, + body_suffix, + ...(head_ref && { head_ref }) + } + }; + + try { + chatStream?.progress(vscode.l10n.t('Delegating to coding agent')); + const response = await this._octoKitService.postCopilotAgentJob(repoId.org, repoId.repo, JOBS_API_VERSION, payload); + + // 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 this.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.`; + + chatStream?.progress(vscode.l10n.t('Attaching to session')); + await this.waitForQueuedToInProgress(response.session_id, token); + 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 coding agent. Please try again later.'), innerError: error.message, state: 'error' }; + } + } } + diff --git a/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts b/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts new file mode 100644 index 00000000000..180d41e07e8 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode/copilotCodingAgentUtils.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ILogService } from '../../../platform/log/common/logService'; + +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 via the [GitHub Pull Request](https://marketplace.visualstudio.com/items?itemName=GitHub.vscode-pull-request-github) extension.'); +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. + * Truncation is not ideal. The caller providing the prompt/context should be summarizing so this is a no-op whenever possible. + * + * @param prompt The final message submitted by the user + * @param context Any additional context collected by the caller (chat history, open files, etc...) + * @returns A complete 'problem statement' string that is under the maximum length, and a flag indicating if truncation occurred + */ +export function truncatePrompt(logService: ILogService, prompt: string, context?: string): { problemStatement: string; isTruncated: boolean } { + // Prioritize the userPrompt + // Take the last n characters that fit within the limit + if (prompt.length >= MAX_PROBLEM_STATEMENT_LENGTH) { + logService.warn(`Truncation: Prompt length ${prompt.length} exceeds max of ${MAX_PROBLEM_STATEMENT_LENGTH}`); + prompt = prompt.slice(-MAX_PROBLEM_STATEMENT_LENGTH); + return { problemStatement: prompt, isTruncated: true }; + } + + if (context && (prompt.length + context.length >= MAX_PROBLEM_STATEMENT_LENGTH)) { + const availableLength = MAX_PROBLEM_STATEMENT_LENGTH - prompt.length - 2 /* new lines */; + logService.warn(`Truncation: Combined prompt and context length ${prompt.length + context.length} exceeds max of ${MAX_PROBLEM_STATEMENT_LENGTH}`); + context = context.slice(-availableLength); + return { + problemStatement: prompt + (context ? `\n\n${context}` : ''), + isTruncated: true + }; + } + + // No truncation occurred + return { + problemStatement: prompt + (context ? `\n\n${context}` : ''), + isTruncated: false + }; +} + +export function extractTitle(prompt: string, context: string | undefined): string | undefined { + const fromTitle = () => { + if (!prompt) { + return; + } + if (prompt.length <= 20) { + return prompt; + } + return prompt.substring(0, 20) + '...'; + }; + const titleMatch = context?.match(/TITLE: \s*(.*)/i); + if (titleMatch && titleMatch[1]) { + return titleMatch[1].trim(); + } + return fromTitle(); + +} + +export function formatBodyPlaceholder(title: string | undefined): string { + return vscode.l10n.t('Coding agent has begun work on **{0}** and will update this pull request as work progresses.', title || vscode.l10n.t('your request')); +} \ No newline at end of file diff --git a/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts b/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts index 05b2b054ac8..4ef1ed94055 100644 --- a/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts +++ b/extensions/copilot/src/extension/vscode.proposed.chatSessionsProvider.d.ts @@ -194,6 +194,10 @@ declare module 'vscode' { export interface ChatContext { readonly chatSessionContext?: ChatSessionContext; + readonly chatSummary?: { + readonly prompt?: string; + readonly history?: string; + }; } export interface ChatSessionContext { diff --git a/extensions/copilot/src/platform/github/common/githubAPI.ts b/extensions/copilot/src/platform/github/common/githubAPI.ts index 77cf7723840..d9248861699 100644 --- a/extensions/copilot/src/platform/github/common/githubAPI.ts +++ b/extensions/copilot/src/platform/github/common/githubAPI.ts @@ -8,6 +8,7 @@ import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; export interface PullRequestSearchItem { + id: number; number: number; title: string; state: string; @@ -61,7 +62,18 @@ export interface SessionInfo { error: string | null; } -export async function makeGitHubAPIRequest(fetcherService: IFetcherService, logService: ILogService, telemetry: ITelemetryService, host: string, routeSlug: string, method: 'GET' | 'POST', token: string | undefined, body?: { [key: string]: any }, version?: string, type: 'json' | 'text' = 'json') { +export async function makeGitHubAPIRequest( + fetcherService: IFetcherService, + logService: ILogService, + telemetry: ITelemetryService, + host: string, + routeSlug: string, + method: 'GET' | 'POST', + token: string | undefined, + body?: { [key: string]: any }, + version?: string, + type: 'json' | 'text' = 'json', + userAgent?: string) { const headers: any = { 'Accept': 'application/vnd.github+json', }; @@ -71,6 +83,9 @@ export async function makeGitHubAPIRequest(fetcherService: IFetcherService, logS if (version) { headers['X-GitHub-Api-Version'] = version; } + if (userAgent) { + headers['User-Agent'] = userAgent; + } const response = await fetcherService.fetch(`${host}/${routeSlug}`, { method, diff --git a/extensions/copilot/src/platform/github/common/githubService.ts b/extensions/copilot/src/platform/github/common/githubService.ts index 741f831e668..d66cf551d05 100644 --- a/extensions/copilot/src/platform/github/common/githubService.ts +++ b/extensions/copilot/src/platform/github/common/githubService.ts @@ -25,6 +25,34 @@ export type GithubRepositoryItem = { type: 'file' | 'dir'; }; +export interface JobInfo { + job_id: string; + session_id: string; + problem_statement: string; + content_filter_mode?: string; + status: string; + result?: string; + actor: { + id: number; + login: string; + }; + created_at: string; + updated_at: string; + pull_request: { + id: number; + number: number; + }; + workflow_run?: { + id: number; + }; + error?: { + message: string; + }; + event_type?: string; + event_url?: string; + event_identifiers?: string[]; +} + export interface IGithubRepositoryService { _serviceBrand: undefined; @@ -76,6 +104,30 @@ export interface IOctoKitPullRequestInfo { }; } +export interface RemoteAgentJobResponse { + job_id: string; + session_id: string; + actor: { + id: number; + login: string; + }; + created_at: string; + updated_at: string; +} + +export interface RemoteAgentJobPayload { + problem_statement: string; + event_type: string; + pull_request?: { + title?: string; + body_placeholder?: string; + body_suffix?: string; + base_ref?: string; + head_ref?: string; + }; + run_name?: string; +} + export interface IOctoKitService { _serviceBrand: undefined; @@ -105,6 +157,26 @@ export interface IOctoKitService { * Returns the logs for a specific Copilot session. */ getSessionLogs(sessionId: string): Promise; + + /** + * Returns the information for a specific Copilot session. + */ + getSessionInfo(sessionId: string): Promise; + + /** + * Posts a new Copilot agent job. + */ + postCopilotAgentJob( + owner: string, + name: string, + apiVersion: string, + payload: RemoteAgentJobPayload, + ): Promise; + + /** + * Gets a job by its job ID. + */ + getJobByJobId(owner: string, repo: string, jobId: string, userAgent: string): Promise; } /** @@ -145,4 +217,16 @@ export class BaseOctoKitService { protected async getSessionLogsWithToken(sessionId: string, token: string) { return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/${sessionId}/logs`, 'GET', token, undefined, undefined, 'text'); } + + protected async getSessionInfoWithToken(sessionId: string, token: string) { + return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/sessions/${sessionId}`, 'GET', token, undefined, undefined, 'text'); + } + + protected async postCopilotAgentJobWithToken(owner: string, name: string, apiVersion: string, userAgent: string, payload: RemoteAgentJobPayload, token: string): Promise { + return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/${apiVersion}/jobs/${owner}/${name}`, 'POST', token, payload, undefined, undefined, userAgent); + } + + protected async getJobByJobIdWithToken(owner: string, repo: string, jobId: string, userAgent: string, token: string): Promise { + return makeGitHubAPIRequest(this._fetcherService, this._logService, this._telemetryService, 'https://api.githubcopilot.com', `agents/swe/v1/jobs/${owner}/${repo}/${jobId}`, 'GET', token, undefined, undefined, undefined, userAgent); + } } diff --git a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts index 56e061d5995..20ea8211788 100644 --- a/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts +++ b/extensions/copilot/src/platform/github/common/octoKitServiceImpl.ts @@ -8,7 +8,7 @@ import { ILogService } from '../../log/common/logService'; import { IFetcherService } from '../../networking/common/fetcherService'; import { ITelemetryService } from '../../telemetry/common/telemetry'; import { PullRequestSearchItem, SessionInfo } from './githubAPI'; -import { BaseOctoKitService, IOctoKitService, IOctoKitUser } from './githubService'; +import { BaseOctoKitService, IOctoKitService, IOctoKitUser, JobInfo, RemoteAgentJobPayload, RemoteAgentJobResponse } from './githubService'; export class OctoKitService extends BaseOctoKitService implements IOctoKitService { declare readonly _serviceBrand: undefined; @@ -79,4 +79,32 @@ export class OctoKitService extends BaseOctoKitService implements IOctoKitServic ); return response; } -} + + async getSessionInfo(sessionId: string): Promise { + const authToken = (await this._authService.getAnyGitHubSession())?.accessToken; + if (!authToken) { + throw new Error('No authentication token available'); + } + const response = await this.getSessionInfoWithToken( + sessionId, + authToken, + ); + return response; + } + + async postCopilotAgentJob(owner: string, name: string, apiVersion: string, payload: RemoteAgentJobPayload): Promise { + const authToken = (await this._authService.getAnyGitHubSession())?.accessToken; + if (!authToken) { + throw new Error('No authentication token available'); + } + return this.postCopilotAgentJobWithToken(owner, name, apiVersion, 'vscode-copilot-chat', payload, authToken); + } + + async getJobByJobId(owner: string, repo: string, jobId: string, userAgent: string): Promise { + const authToken = (await this._authService.getAnyGitHubSession())?.accessToken; + if (!authToken) { + throw new Error('No authentication token available'); + } + return this.getJobByJobIdWithToken(owner, repo, jobId, userAgent, authToken); + } +} \ No newline at end of file