Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts
13399 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { RemoteAgentJobPayload } from '@vscode/copilot-api';
7
import * as pathLib from 'path';
8
import * as vscode from 'vscode';
9
import { l10n, Uri } from 'vscode';
10
import { IAuthenticationService } from '../../../platform/authentication/common/authentication';
11
import { IDomainService } from '../../../platform/endpoint/common/domainService';
12
import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';
13
import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';
14
import { FileType } from '../../../platform/filesystem/common/fileTypes';
15
import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';
16
import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService';
17
import { derivePullRequestState, PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI';
18
import { AuthOptions, CCAEnabledResult, IGithubRepositoryService, IOctoKitService, JobInfo, RemoteAgentJobResponse } from '../../../platform/github/common/githubService';
19
import { ILogService } from '../../../platform/log/common/logService';
20
import { emitCloudSessionInvokeEvent } from '../../../platform/otel/common/genAiEvents';
21
import { GenAiMetrics } from '../../../platform/otel/common/genAiMetrics';
22
import { IOTelService } from '../../../platform/otel/common/otelService';
23
import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';
24
import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';
25
import { DeferredPromise, retry, RunOnceScheduler } from '../../../util/vs/base/common/async';
26
import { Event } from '../../../util/vs/base/common/event';
27
import { Disposable, DisposableStore, toDisposable } from '../../../util/vs/base/common/lifecycle';
28
import { ResourceMap } from '../../../util/vs/base/common/map';
29
import { joinPath } from '../../../util/vs/base/common/resources';
30
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
31
import { SingleSlotTtlCache, TtlCache } from '../common/ttlCache';
32
import { isUntitledSessionId } from '../common/utils';
33
import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';
34
import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils';
35
import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager';
36
import { ChatSessionContentBuilder, SessionResponseLogChunk } from './copilotCloudSessionContentBuilder';
37
import { IPullRequestFileChangesService } from './pullRequestFileChangesService';
38
import MarkdownIt = require('markdown-it');
39
40
const CLOUD_SESSIONS_AUTH_OPTIONS: AuthOptions = { createIfNone: { detail: l10n.t('Sign in to GitHub to access Copilot cloud sessions.') } };
41
42
interface ConfirmationMetadata {
43
prompt: string;
44
references?: readonly vscode.ChatPromptReference[];
45
chatContext: vscode.ChatContext;
46
}
47
48
type InitialSessionOption = {
49
readonly optionId: string;
50
readonly value: string | vscode.ChatSessionProviderOptionItem;
51
};
52
53
function validateMetadata(metadata: unknown): asserts metadata is ConfirmationMetadata {
54
if (typeof metadata !== 'object') {
55
throw new Error('Invalid confirmation metadata: not an object.');
56
}
57
if (metadata === null) {
58
throw new Error('Invalid confirmation metadata: null value.');
59
}
60
if (typeof (metadata as ConfirmationMetadata).prompt !== 'string') {
61
throw new Error('Invalid confirmation metadata: missing or invalid prompt.');
62
}
63
if (typeof (metadata as ConfirmationMetadata).chatContext !== 'object' || (metadata as ConfirmationMetadata).chatContext === null) {
64
throw new Error('Invalid confirmation metadata: missing or invalid chatContext.');
65
}
66
}
67
68
function describeRuntimeValue(value: unknown): string {
69
if (Array.isArray(value)) {
70
return `array(length=${value.length})`;
71
}
72
73
if (value === null) {
74
return 'null';
75
}
76
77
if (value === undefined) {
78
return 'undefined';
79
}
80
81
if (typeof value === 'object') {
82
const keys = Object.keys(value);
83
return `object(keys=${keys.slice(0, 5).join(',')}${keys.length > 5 ? ',…' : ''})`;
84
}
85
86
return typeof value;
87
}
88
89
function isOptionItemValue(value: unknown): value is vscode.ChatSessionProviderOptionItem {
90
return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';
91
}
92
93
function isInitialSessionOption(value: unknown): value is InitialSessionOption {
94
if (typeof value !== 'object' || value === null || !('optionId' in value) || typeof value.optionId !== 'string' || !('value' in value)) {
95
return false;
96
}
97
98
return typeof value.value === 'string' || isOptionItemValue(value.value);
99
}
100
101
export function normalizeInitialSessionOptions(initialOptions: unknown, logService?: ILogService, chatResource?: vscode.Uri): readonly InitialSessionOption[] {
102
if (!initialOptions) {
103
return [];
104
}
105
106
if (Array.isArray(initialOptions)) {
107
const normalized = initialOptions.filter(isInitialSessionOption);
108
if (logService && normalized.length !== initialOptions.length) {
109
logService.warn(`[chatParticipantImpl] Ignoring ${initialOptions.length - normalized.length} malformed initialSessionOptions entries for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)}.`);
110
}
111
112
return normalized;
113
}
114
115
if (typeof initialOptions === 'object') {
116
const normalized: InitialSessionOption[] = [];
117
for (const [optionId, value] of Object.entries(initialOptions)) {
118
if (isInitialSessionOption(value)) {
119
normalized.push(value);
120
} else if (typeof value === 'string' || isOptionItemValue(value)) {
121
normalized.push({ optionId, value });
122
}
123
}
124
125
if (normalized.length > 0) {
126
logService?.warn(`[chatParticipantImpl] Coerced object-shaped initialSessionOptions for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)} and recovered ${normalized.length} entries.`);
127
return normalized;
128
}
129
}
130
131
logService?.warn(`[chatParticipantImpl] Ignoring unsupported initialSessionOptions for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)}.`);
132
return [];
133
}
134
135
export function parseSessionLogChunksSafely(rawText: string, logService: ILogService, parser: (value: string) => SessionResponseLogChunk[]): SessionResponseLogChunk[] {
136
try {
137
return parser(rawText);
138
} catch (error) {
139
logService.error(error instanceof Error ? error : new Error(String(error)), `[streamNewLogContent] Failed to parse streamed log content (${rawText.length} chars).`);
140
return [];
141
}
142
}
143
144
const CUSTOM_AGENTS_OPTION_GROUP_ID = 'customAgents';
145
const MODELS_OPTION_GROUP_ID = 'models';
146
const PARTNER_AGENTS_OPTION_GROUP_ID = 'partnerAgents';
147
const REPOSITORIES_OPTION_GROUP_ID = 'repositories';
148
149
const DEFAULT_CUSTOM_AGENT_ID = '___vscode_default___';
150
const DEFAULT_MODEL_ID = 'auto';
151
const DEFAULT_PARTNER_AGENT_ID = '___vscode_partner_agent_default___';
152
const DEFAULT_REPOSITORY_ID = '___vscode_repository_default___';
153
154
const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
155
const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore';
156
const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository';
157
const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches';
158
const USER_SELECTED_REPOS_KEY = 'userSelectedRepositories';
159
const USER_SELECTED_REPOS_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
160
161
// TTL for caching /enabled responses when CCA is enabled
162
const CCA_ENABLED_CACHE_TTL_MS = 30 * 60 * 1_000; // 30 minutes
163
// Shorter TTL for caching /enabled responses when CCA is disabled or undetermined,
164
// so users aren't stuck but we don't hammer the endpoint on every options query
165
const CCA_DISABLED_CACHE_TTL_MS = 5 * 60 * 1_000; // 5 minutes
166
// Status codes that are expected/handled by isCCAEnabled; anything else is unexpected
167
const CCA_KNOWN_STATUS_CODES = new Set([401, 403, 422]);
168
// TTL for caching session provider options (custom agents, models, partner agents, etc.)
169
const OPTIONS_CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes
170
171
interface UserSelectedRepository {
172
name: string;
173
timestamp: number;
174
}
175
176
// TODO: No API from GH yet.
177
const HARDCODED_PARTNER_AGENTS: { id: string; name: string; at?: string; assignableActorLogin?: string; codiconId?: string }[] = [
178
{ id: DEFAULT_PARTNER_AGENT_ID, name: 'Copilot', assignableActorLogin: 'copilot-swe-agent', codiconId: 'copilot' },
179
{ id: '2246796', name: 'Claude', at: 'claude[agent]', assignableActorLogin: 'anthropic-code-agent', codiconId: 'claude' },
180
{ id: '2248422', name: 'Codex', at: 'codex[agent]', assignableActorLogin: 'openai-code-agent', codiconId: 'openai' }
181
];
182
183
/**
184
* Custom renderer for markdown-it that converts markdown to plain text
185
*/
186
class PlainTextRenderer {
187
private md: MarkdownIt;
188
189
constructor() {
190
this.md = new MarkdownIt();
191
}
192
193
/**
194
* Renders markdown text as plain text by extracting text content from all tokens
195
*/
196
render(markdown: string): string {
197
const tokens = this.md.parse(markdown, {});
198
return this.renderTokens(tokens).trim();
199
}
200
201
private renderTokens(tokens: MarkdownIt.Token[]): string {
202
let result = '';
203
for (const token of tokens) {
204
// Process child tokens recursively
205
if (token.children) {
206
result += this.renderTokens(token.children);
207
}
208
209
// Handle different token types
210
switch (token.type) {
211
case 'text':
212
case 'code_inline':
213
// Only add content if no children were processed
214
if (!token.children) {
215
result += token.content;
216
}
217
break;
218
219
case 'softbreak':
220
case 'hardbreak':
221
result += ' '; // Space instead of newline to match original
222
break;
223
224
case 'paragraph_close':
225
result += '\n'; // Newline after paragraphs for separation
226
break;
227
228
case 'heading_close':
229
result += '\n'; // Newline after headings
230
break;
231
232
case 'list_item_close':
233
result += '\n'; // Newline after list items
234
break;
235
236
case 'fence':
237
case 'code_block':
238
case 'hr':
239
// Skip these entirely
240
break;
241
242
// Don't add default case - only explicitly handle what we want
243
}
244
}
245
return result;
246
}
247
}
248
249
export class CopilotCloudSessionsProvider extends Disposable implements vscode.ChatSessionContentProvider, vscode.ChatSessionItemProvider {
250
public static readonly TYPE = 'copilot-cloud-agent';
251
private readonly _onDidChangeChatSessionItems = this._register(new vscode.EventEmitter<void>());
252
public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event;
253
private readonly _onDidCommitChatSessionItem = this._register(new vscode.EventEmitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());
254
public readonly onDidCommitChatSessionItem = this._onDidCommitChatSessionItem.event;
255
private readonly _onDidChangeChatSessionProviderOptions = this._register(new vscode.EventEmitter<void>());
256
public readonly onDidChangeChatSessionProviderOptions = this._onDidChangeChatSessionProviderOptions.event;
257
private readonly _onDidChangeChatSessionOptions = this._register(new vscode.EventEmitter<vscode.ChatSessionOptionChangeEvent>());
258
public readonly onDidChangeChatSessionOptions = this._onDidChangeChatSessionOptions.event;
259
private chatSessions: Map<number, PullRequestSearchItem> = new Map();
260
private chatSessionItemsPromise: Promise<vscode.ChatSessionItem[]> | undefined;
261
private readonly sessionCustomAgentMap = new ResourceMap<string>();
262
private readonly sessionModelMap = new ResourceMap<string>();
263
private readonly sessionPartnerAgentMap = new ResourceMap<string>();
264
private readonly sessionRepositoryMap = new ResourceMap<string>();
265
private readonly sessionReferencesMap = new ResourceMap<readonly vscode.ChatPromptReference[]>();
266
public chatParticipant = vscode.chat.createChatParticipant(CopilotCloudSessionsProvider.TYPE, async (request, context, stream, token) => {
267
await this.chatParticipantImpl(request, context, stream, token);
268
});
269
private cachedSessionsSize: number = 0;
270
// Cache for provideChatSessionItems
271
private cachedSessionItems: (vscode.ChatSessionItem & {
272
fullDatabaseId: string;
273
pullRequestDetails: PullRequestSearchItem;
274
})[] | undefined;
275
private activeSessionIds: Set<string> = new Set();
276
private activeSessionPollingInterval: ReturnType<typeof setInterval> | undefined;
277
private readonly plainTextRenderer = new PlainTextRenderer();
278
private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService);
279
280
// TTL cache for CCA enabled status per repository (key: "owner/repo")
281
// enabled=true cached for 30 min; disabled/undetermined cached for 5 min to reduce traffic
282
private _ccaEnabledCache = new TtlCache<CCAEnabledResult>(CCA_ENABLED_CACHE_TTL_MS);
283
284
// Single-slot TTL cache for the full session provider options result (custom agents, models, partner agents, etc.)
285
// Caches the most recently computed options regardless of repo/workspace context
286
private _optionsCache = new SingleSlotTtlCache<vscode.ChatSessionProviderOptions>(OPTIONS_CACHE_TTL_MS);
287
288
// Title
289
private TITLE = vscode.l10n.t('Delegate to cloud agent');
290
291
// Buttons (used for matching, be careful changing!)
292
private readonly AUTHORIZE = vscode.l10n.t('Authorize');
293
private readonly COMMIT = vscode.l10n.t('Commit Changes');
294
private readonly PUSH_BRANCH = vscode.l10n.t('Push Branch');
295
private readonly DELEGATE = vscode.l10n.t('Delegate');
296
private readonly CANCEL = vscode.l10n.t('Cancel');
297
298
// Messages
299
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.');
300
private readonly AUTHORIZE_MESSAGE = vscode.l10n.t('Cloud agent requires elevated GitHub access to proceed.');
301
private readonly COMMIT_MESSAGE = vscode.l10n.t('This workspace has uncommitted changes. Should these changes be pushed and included in cloud agent\'s work?');
302
private readonly PUSH_BRANCH_MESSAGE = (baseRef: string, defaultBranch: string) => vscode.l10n.t('Push your currently checked out branch `{0}`, or start from the default branch `{1}`?', baseRef, defaultBranch);
303
304
// Workspace storage keys
305
private readonly WORKSPACE_CONTEXT_PREFIX = 'copilot.cloudAgent';
306
307
constructor(
308
@IOctoKitService private readonly _octoKitService: IOctoKitService,
309
@IGitService private readonly _gitService: IGitService,
310
@ITelemetryService private readonly telemetry: ITelemetryService,
311
@ILogService private readonly logService: ILogService,
312
@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,
313
@IPullRequestFileChangesService private readonly _prFileChangesService: IPullRequestFileChangesService,
314
@IAuthenticationService private readonly _authenticationService: IAuthenticationService,
315
@IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext,
316
@IInstantiationService instantiationService: IInstantiationService,
317
@IGithubRepositoryService private readonly _githubRepositoryService: IGithubRepositoryService,
318
@IChatDelegationSummaryService private readonly _chatDelegationSummaryService: IChatDelegationSummaryService,
319
@IExperimentationService private readonly _experimentationService: IExperimentationService,
320
@IDomainService private readonly _domainService: IDomainService,
321
@IOTelService private readonly _otelService: IOTelService,
322
@IFileSystemService private readonly _fileSystemService: IFileSystemService,
323
) {
324
super();
325
this.registerCommands();
326
327
// Refresh when CAPI URL changes (e.g., when GHE Copilot token arrives and updates the base URL)
328
this._register(this._domainService.onDidChangeDomains(e => {
329
if (e.capiUrlChanged) {
330
this.logService.debug('copilotCloudSessionsProvider: CAPI URL changed, refreshing sessions');
331
this.clearOptionsCaches();
332
this.refresh();
333
this._onDidChangeChatSessionProviderOptions.fire();
334
}
335
}));
336
337
// Background refresh for Copilot cloud agent sessions based on repository and authentication state
338
getRepoId(this._gitService).then(async repoIds => {
339
const telemetryObj: {
340
intervalMs?: number;
341
hasHistoricalSessions?: boolean;
342
error?: string;
343
isEmptyWindow: boolean;
344
} = {
345
isEmptyWindow: !vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0
346
};
347
if (repoIds && repoIds.length > 0) {
348
let intervalMs: number;
349
let hasHistoricalSessions: boolean;
350
try {
351
const sessions = await Promise.all(repoIds.map(repoId => this._octoKitService.getAllSessions(`${repoId.org}/${repoId.repo}`, false, {})));
352
hasHistoricalSessions = sessions.some(s => s.length > 0);
353
intervalMs = this.getRefreshIntervalTime(hasHistoricalSessions);
354
} catch (e) {
355
this.logService.error(`Error during background refresh setup: ${e instanceof Error ? e.message : String(e)}`);
356
hasHistoricalSessions = false;
357
intervalMs = this.getRefreshIntervalTime(hasHistoricalSessions);
358
telemetryObj.error = e instanceof Error ? e.message : String(e);
359
}
360
telemetryObj.intervalMs = intervalMs;
361
telemetryObj.hasHistoricalSessions = hasHistoricalSessions;
362
const schedulerCallback = async () => {
363
let sessions = [];
364
try {
365
sessions = await Promise.all(repoIds.map(repoId => this._octoKitService.getAllSessions(`${repoId.org}/${repoId.repo}`, true, {})));
366
sessions = sessions.flat();
367
if (this.cachedSessionsSize !== sessions.length) {
368
this.refresh();
369
}
370
} catch (e) {
371
logService.error(`Error during background refresh: ${e}`);
372
}
373
scheduler.schedule();
374
};
375
let lastRefreshedAt = 0;
376
const scheduler = this._register(new RunOnceScheduler(() => {
377
lastRefreshedAt = Date.now();
378
schedulerCallback();
379
}, intervalMs));
380
scheduler.schedule();
381
this._register(vscode.window.onDidChangeWindowState((e) => {
382
if (!e.active) {
383
scheduler.cancel();
384
} else if (!scheduler.isScheduled()) {
385
scheduler.schedule(Math.max(0, intervalMs - (Date.now() - lastRefreshedAt)));
386
}
387
}));
388
389
}
390
const onDebouncedAuthRefresh = Event.debounce(this._authenticationService.onDidAuthenticationChange, () => { }, 500);
391
this._register(onDebouncedAuthRefresh(() => {
392
this.clearOptionsCaches();
393
this.refresh();
394
}));
395
this.telemetry.sendTelemetryEvent('copilotCloudSessions.refreshInterval', { microsoft: true, github: false }, telemetryObj);
396
});
397
}
398
399
private registerCommands() {
400
const executePullRequestActionWithExtensionInstall = async (
401
sessionItemOrResource: vscode.ChatSessionItem | vscode.Uri | number | undefined,
402
options: {
403
actionLabel: string;
404
noRepoErrorMessage: string;
405
installPromptMessage: string;
406
executeAction: (repoId: { org: string; repo: string }, pullRequestNumber: number) => Promise<void>;
407
}
408
): Promise<void> => {
409
let pullRequestNumber: number | undefined;
410
if (typeof sessionItemOrResource === 'number') {
411
pullRequestNumber = sessionItemOrResource;
412
} else {
413
const resource = sessionItemOrResource instanceof vscode.Uri
414
? sessionItemOrResource
415
: sessionItemOrResource?.resource;
416
if (!resource) {
417
return;
418
}
419
pullRequestNumber = SessionIdForPr.parsePullRequestNumber(resource);
420
}
421
422
423
if (!pullRequestNumber) {
424
return;
425
}
426
const repoIds = await getRepoId(this._gitService);
427
if (!repoIds || repoIds.length === 0) {
428
vscode.window.showErrorMessage(options.noRepoErrorMessage);
429
return;
430
}
431
432
const extensionId = 'github.vscode-pull-request-github';
433
const isExtensionInstalled = vscode.extensions.getExtension(extensionId) !== undefined;
434
435
if (!isExtensionInstalled) {
436
const result = await vscode.window.showInformationMessage(
437
options.installPromptMessage,
438
{ modal: true },
439
options.actionLabel
440
);
441
442
if (result !== options.actionLabel) {
443
return;
444
}
445
446
await vscode.commands.executeCommand('workbench.extensions.installExtension', extensionId, { enable: true });
447
}
448
449
await options.executeAction(repoIds[0], pullRequestNumber);
450
};
451
452
const checkoutPullRequestReroute = (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) =>
453
executePullRequestActionWithExtensionInstall(sessionItemOrResource, {
454
actionLabel: l10n.t('Install and Checkout'),
455
noRepoErrorMessage: l10n.t('No active repository found to checkout pull request.'),
456
installPromptMessage: l10n.t('The GitHub Pull Requests extension is required to checkout this PR. Would you like to install and checkout?'),
457
executeAction: async (repoId, pullRequestNumber) => {
458
await vscode.commands.executeCommand('pr.checkoutFromDescription', { owner: repoId.org, repo: repoId.repo, number: pullRequestNumber });
459
},
460
});
461
this._register(vscode.commands.registerCommand('github.copilot.chat.checkoutPullRequestReroute', checkoutPullRequestReroute));
462
463
const openPullRequestReroute = (sessionItemOrResource?: vscode.ChatSessionItem | number | vscode.Uri) =>
464
executePullRequestActionWithExtensionInstall(sessionItemOrResource, {
465
actionLabel: l10n.t('Install and Open'),
466
noRepoErrorMessage: l10n.t('No active repository found to open pull request.'),
467
installPromptMessage: l10n.t('The GitHub Pull Requests extension is required to open this PR. Would you like to install and open?'),
468
executeAction: async (repoId, pullRequestNumber) => {
469
await vscode.commands.executeCommand('pr.openDescription', {
470
pullRequestDetails: {
471
number: pullRequestNumber,
472
repository: {
473
owner: {
474
login: repoId.org,
475
},
476
name: repoId.repo,
477
},
478
},
479
});
480
},
481
});
482
this._register(vscode.commands.registerCommand('github.copilot.chat.openPullRequestReroute', openPullRequestReroute));
483
484
// Command for browsing repositories in the repository picker
485
const openRepositoryCommand = async (sessionItemResource?: vscode.Uri): Promise<string | undefined> => {
486
const quickPick = vscode.window.createQuickPick();
487
const quickPickDisposables = new DisposableStore();
488
quickPick.placeholder = l10n.t('Search for a repository...');
489
quickPick.matchOnDescription = true;
490
quickPick.matchOnDetail = true;
491
quickPick.busy = true;
492
quickPick.show();
493
494
// Load initial repositories
495
try {
496
const repos = await this.fetchAllRepositoriesFromGitHub();
497
quickPick.items = repos.map(repo => ({ label: repo.name }));
498
} catch (error) {
499
this.logService.error(`Error fetching initial repositories: ${error}`);
500
} finally {
501
quickPick.busy = false;
502
}
503
504
// Handle dynamic search
505
let searchTimeout: ReturnType<typeof setTimeout> | undefined;
506
507
return new Promise<string | undefined>(resolve => {
508
let resolved = false;
509
const doResolve = (value: string | undefined) => {
510
if (!resolved) {
511
resolved = true;
512
resolve(value);
513
}
514
};
515
516
quickPickDisposables.add(quickPick.onDidChangeValue(async (value) => {
517
if (searchTimeout) {
518
clearTimeout(searchTimeout);
519
}
520
searchTimeout = setTimeout(async () => {
521
quickPick.busy = true;
522
try {
523
const searchResults = await this.fetchAllRepositoriesFromGitHub(value);
524
quickPick.items = searchResults.map(repo => ({ label: repo.name }));
525
} finally {
526
quickPick.busy = false;
527
}
528
}, 300);
529
}));
530
531
quickPickDisposables.add(quickPick.onDidAccept(() => {
532
const selected = quickPick.selectedItems[0];
533
if (selected && sessionItemResource) {
534
this.sessionRepositoryMap.set(sessionItemResource, selected.label);
535
// Save user-selected repo so it appears in the recent repos list
536
this.saveUserSelectedRepository(selected.label);
537
this._onDidChangeChatSessionOptions.fire({
538
resource: sessionItemResource,
539
updates: [{
540
optionId: REPOSITORIES_OPTION_GROUP_ID,
541
value: { id: selected.label, name: selected.label, icon: new vscode.ThemeIcon('repo') }
542
}]
543
});
544
}
545
doResolve(selected?.label);
546
quickPick.hide();
547
}));
548
549
quickPickDisposables.add(quickPick.onDidHide(() => {
550
if (searchTimeout) {
551
clearTimeout(searchTimeout);
552
}
553
quickPickDisposables.dispose();
554
quickPick.dispose();
555
doResolve(undefined);
556
}));
557
});
558
};
559
this._register(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, openRepositoryCommand));
560
561
this._register(vscode.commands.registerCommand(CLEAR_CACHES_COMMAND_ID, () => {
562
this.logService.debug('copilotCloudSessionsProvider#clearCaches: clearing all cloud agent caches');
563
this.clearOptionsCaches();
564
this.refresh();
565
this._onDidChangeChatSessionProviderOptions.fire();
566
}));
567
}
568
569
private getRefreshIntervalTime(hasHistoricalSessions: boolean): number {
570
// Check for experiment overrides
571
const expRefreshInterval = this._experimentationService.getTreatmentVariable<number>('copilotCloudSessions.refreshInterval');
572
if (expRefreshInterval !== undefined) {
573
return expRefreshInterval;
574
}
575
576
// Default intervals
577
const fiveMinInterval = 5 * 60 * 1000; // 5 minutes
578
const tenMinInterval = 10 * 60 * 1000; // 10 minutes
579
if (hasHistoricalSessions) {
580
return fiveMinInterval;
581
} else {
582
return tenMinInterval;
583
}
584
}
585
586
public refresh(): void {
587
this.cachedSessionItems = undefined;
588
this.chatSessionItemsPromise = undefined;
589
this.activeSessionIds.clear();
590
this.stopActiveSessionPolling();
591
// Note: _ccaEnabledCache and _optionsCache are TTL-based and NOT cleared on refresh.
592
// Use clearOptionsCaches() to force-clear them (e.g. on auth change).
593
this._onDidChangeChatSessionItems.fire();
594
}
595
596
/**
597
* Force-clears the TTL-based caches for /enabled and session provider options.
598
* Use for auth changes or explicit user-initiated refresh where stale data is unacceptable.
599
*/
600
private clearOptionsCaches(): void {
601
this._ccaEnabledCache.clear();
602
this._optionsCache.clear();
603
}
604
605
/**
606
* Checks if the Copilot cloud agent is enabled for a repository.
607
* Results are cached with a TTL: enabled=true results are cached for {@link CCA_ENABLED_CACHE_TTL_MS},
608
* while disabled/undetermined results are cached for a shorter {@link CCA_DISABLED_CACHE_TTL_MS}
609
* to balance responsiveness with reducing endpoint traffic.
610
* @param owner Repository owner
611
* @param repo Repository name
612
* @returns CCAEnabledResult with enabled status and optional status code
613
*/
614
private async checkCCAEnabled(owner: string, repo: string): Promise<CCAEnabledResult> {
615
const cacheKey = `${owner}/${repo}`;
616
617
const cached = this._ccaEnabledCache.get(cacheKey);
618
if (cached !== undefined) {
619
this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: using cached CCA enabled status for ${owner}/${repo}: ${cached.enabled}`);
620
return cached;
621
}
622
623
const result = await this._octoKitService.isCCAEnabled(owner, repo, {});
624
625
// Cache all results: enabled=true uses the default 30 min TTL,
626
// disabled/undetermined uses a shorter 5 min TTL so users who just
627
// enabled CCA aren't stuck for too long
628
if (result.enabled === true) {
629
this._ccaEnabledCache.set(cacheKey, result);
630
} else {
631
this._ccaEnabledCache.set(cacheKey, result, CCA_DISABLED_CACHE_TTL_MS);
632
}
633
634
this.telemetry.sendTelemetryEvent('copilot.codingAgent.CCAIsEnabledCheck', { microsoft: true, github: false }, {
635
enabled: String(result.enabled),
636
statusCode: String(result.statusCode ?? 'none'),
637
cacheHit: 'false',
638
});
639
640
// Track unexpected status codes (429 rate-limit, 5xx, etc.) as errors so they surface in dashboards
641
if (result.statusCode !== undefined && !CCA_KNOWN_STATUS_CODES.has(result.statusCode)) {
642
/* __GDPR__
643
"copilot.codingAgent.CCAIsEnabledUnexpectedStatus" : {
644
"owner": "joshspicer",
645
"comment": "Fired when the /enabled endpoint returns an unexpected HTTP status code (e.g. 429 rate-limit or 5xx).",
646
"statusCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The unexpected HTTP status code returned by the /enabled endpoint." },
647
"isRateLimited": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "True if the status code is 429 (rate limited)." }
648
}
649
*/
650
this.telemetry.sendTelemetryErrorEvent('copilot.codingAgent.CCAIsEnabledUnexpectedStatus', { microsoft: true, github: false }, {
651
statusCode: String(result.statusCode),
652
isRateLimited: String(result.statusCode === 429),
653
});
654
}
655
656
this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: fetched CCA enabled status for ${owner}/${repo}: ${result.enabled}`);
657
return result;
658
}
659
660
/**
661
* Gets user-friendly error message for disabled CCA status.
662
* @param result The CCAEnabledResult to get message for
663
* @returns User-friendly error message
664
*/
665
private getCCADisabledMessage(result: CCAEnabledResult, host: string = 'github.com'): string {
666
if (result.statusCode === 422) {
667
return vscode.l10n.t('Cloud agent is unable to create pull requests in this repository. Please verify repository rules allow this operation.');
668
}
669
if (result.statusCode === 401) {
670
return vscode.l10n.t('Cloud agent is not authorized to run on this repository. This may be because the Copilot coding agent is disabled for your organization, or your active GitHub account does not have push access to the target repository.');
671
}
672
// Default to 403 'disabled' message
673
const settingsUrl = `https://${host}/settings/copilot/coding_agent`;
674
return vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', settingsUrl);
675
}
676
677
private stopActiveSessionPolling(): void {
678
if (this.activeSessionPollingInterval) {
679
clearInterval(this.activeSessionPollingInterval);
680
this.activeSessionPollingInterval = undefined;
681
}
682
}
683
684
private startActiveSessionPolling(): void {
685
// Don't start if already polling
686
if (this.activeSessionPollingInterval) {
687
return;
688
}
689
690
this.activeSessionPollingInterval = setInterval(async () => {
691
await this.updateActiveSessionsOnly();
692
}, ACTIVE_SESSION_POLL_INTERVAL_MS);
693
694
// Register for disposal
695
this._register(toDisposable(() => this.stopActiveSessionPolling()));
696
}
697
698
private async updateActiveSessionsOnly(): Promise<void> {
699
if (this.activeSessionIds.size === 0) {
700
this.stopActiveSessionPolling();
701
return;
702
}
703
704
try {
705
// Fetch only the active sessions using allSettled to handle individual failures
706
const sessionResults = await Promise.allSettled(
707
Array.from(this.activeSessionIds).map(sessionId =>
708
this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS)
709
)
710
);
711
712
const stillActiveSessions = new Set<string>();
713
714
for (const result of sessionResults) {
715
if (result.status === 'rejected') {
716
this.logService.warn(`Failed to fetch session info: ${result.reason}`);
717
continue;
718
}
719
720
const session = result.value;
721
if (!session) {
722
continue;
723
}
724
this.cachedSessionItems = this.cachedSessionItems?.map(item => {
725
if (item.fullDatabaseId === session.resource_global_id) {
726
return {
727
...item,
728
status: this.getSessionStatusFromSession(session),
729
};
730
}
731
return item;
732
});
733
734
if (session.state === 'in_progress' || session.state === 'queued') {
735
stillActiveSessions.add(session.id);
736
}
737
}
738
739
// Update the active sessions set
740
this.activeSessionIds = stillActiveSessions;
741
742
// If there are changes or no more active sessions, invalidate cache and notify
743
if (this.activeSessionIds.size === 0) {
744
this.cachedSessionItems = undefined;
745
this.stopActiveSessionPolling();
746
}
747
this._onDidChangeChatSessionItems.fire();
748
} catch (error) {
749
this.logService.error(`Error updating active sessions: ${error}`);
750
}
751
}
752
753
/**
754
* Queries for available partner agents by checking if known CCA logins are assignable in the repository.
755
* TODO: Remove once given a proper API
756
*/
757
private async getAvailablePartnerAgents(owner: string, repo: string): Promise<{ id: string; name: string; at?: string; codiconId?: string }[]> {
758
try {
759
// Fetch assignable actors for the repository
760
const assignableActors = await this._octoKitService.getAssignableActors(owner, repo, {});
761
762
// Check which agents from HARDCODED_PARTNER_AGENTS are assignable
763
const availableAgents: { id: string; name: string; at?: string; codiconId?: string }[] = [];
764
765
for (const agent of HARDCODED_PARTNER_AGENTS) {
766
const { assignableActorLogin } = agent;
767
let isAssignable = false;
768
769
if (assignableActorLogin !== undefined) {
770
isAssignable = assignableActors.some(actor => actor.login === assignableActorLogin);
771
}
772
if (isAssignable) {
773
availableAgents.push(agent);
774
}
775
}
776
777
return availableAgents;
778
} catch (error) {
779
this.logService.error(`Error fetching partner agents: ${error}`);
780
return [];
781
}
782
}
783
784
/**
785
* Scans local .github/agents/ directory and categorizes agent files.
786
* Returns two groups:
787
* - matches: local files that correlate with remote agents (name exists in both)
788
* - localOnly: local files that don't have a corresponding remote agent
789
*/
790
private async getLocalCustomAgentFiles(remoteAgents: { name: string }[]): Promise<{
791
matches: Set<string>;
792
localOnly: { name: string; path: string }[];
793
}> {
794
const matches = new Set<string>();
795
const localOnly: { name: string; path: string }[] = [];
796
const remoteAgentNames = new Set(remoteAgents.map(a => a.name.toLowerCase()));
797
798
const workspaceFolders = vscode.workspace.workspaceFolders;
799
if (!workspaceFolders || workspaceFolders.length === 0) {
800
return { matches, localOnly };
801
}
802
803
// Only check the first workspace folder (consistent with how we query GitHub for custom agents)
804
// TODO: Expand to multi-root workspaces, etc...
805
const folder = workspaceFolders[0];
806
try {
807
// Find all .md files in .github/agents/ using the file system service
808
const agentsDir = joinPath(folder.uri, '.github/agents');
809
const entries = await this._fileSystemService.readDirectory(agentsDir);
810
811
for (const [name, type] of entries) {
812
// Only process .md files
813
if (!(type & FileType.File) || !name.toLowerCase().endsWith('.md')) {
814
continue;
815
}
816
817
// Extract agent name from filename (e.g., "my-agent.md" -> "my-agent" or "myagent.agent.md" -> "myagent")
818
const agentName = name.replace(/\.agent\.md$/i, '').replace(/\.md$/i, '');
819
820
if (!agentName) {
821
continue;
822
}
823
824
const fileUri = joinPath(agentsDir, name);
825
if (remoteAgentNames.has(agentName.toLowerCase())) {
826
// This local file matches a remote agent
827
matches.add(agentName.toLowerCase());
828
} else {
829
// This local file has no corresponding remote agent
830
localOnly.push({
831
name: agentName,
832
path: vscode.workspace.asRelativePath(fileUri)
833
});
834
}
835
}
836
} catch (error) {
837
if (error instanceof vscode.FileSystemError && error.code === 'FileNotFound') {
838
return { matches, localOnly };
839
}
840
this.logService.warn(`Error scanning for local agents in ${folder.uri.toString()}: ${error}`);
841
}
842
843
return { matches, localOnly };
844
}
845
846
async provideChatSessionProviderOptions(token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptions> {
847
this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions Start');
848
849
const repoIds = await getRepoId(this._gitService);
850
const repoId = repoIds?.[0];
851
852
const workspaceFolders = vscode.workspace.workspaceFolders;
853
const isSingleRepoWorkspace = workspaceFolders?.length === 1 && repoIds?.length === 1;
854
let ccaEnabledResult: { enabled?: boolean; statusCode?: number } | undefined;
855
let isCcaEnabled = true;
856
if (isSingleRepoWorkspace && repoId) {
857
ccaEnabledResult = await this.checkCCAEnabled(repoId.org, repoId.repo);
858
isCcaEnabled = ccaEnabledResult.enabled !== false;
859
}
860
if (!isCcaEnabled && repoId) {
861
this.logService.trace(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: CCA disabled for ${repoId.org}/${repoId.repo}, statusCode: ${ccaEnabledResult?.statusCode}`);
862
// Return empty options to disable the feature in the UI
863
return { optionGroups: [] };
864
}
865
866
// Check TTL-based options cache
867
const optionsCacheKey = repoIds && repoIds.length > 0
868
? repoIds.map(r => `${r.org}/${r.repo}`).sort().join(',')
869
: '';
870
const cachedOptions = this._optionsCache.get(optionsCacheKey);
871
if (cachedOptions) {
872
this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions: using cached options');
873
return cachedOptions;
874
}
875
876
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
877
try {
878
// Fetch agents (requires repo), models (global), and partner agents in parallel
879
const [customAgents, models, partnerAgents] = await Promise.allSettled([
880
repoId && repoIds?.length === 1 ? this._octoKitService.getCustomAgents(repoId.org, repoId.repo, { excludeInvalidConfig: true }, {}) : Promise.resolve([]),
881
this._octoKitService.getCopilotAgentModels({}),
882
repoId ? this.getAvailablePartnerAgents(repoId.org, repoId.repo) : Promise.resolve([])
883
]);
884
885
try {
886
const items = await this.getRepositoriesOptionItems(repoIds);
887
if (items.length !== 1) {
888
optionGroups.push({
889
id: REPOSITORIES_OPTION_GROUP_ID,
890
name: vscode.l10n.t('Repository'),
891
description: vscode.l10n.t('Select repository'),
892
icon: new vscode.ThemeIcon('repo'),
893
items,
894
commands: [{
895
command: OPEN_REPOSITORY_COMMAND_ID,
896
title: vscode.l10n.t('Browse repositories...'),
897
}]
898
});
899
}
900
901
} catch (error) {
902
this.logService.error(`Error fetching repositories: ${error}`);
903
}
904
905
// Partner agents
906
// Only show if repo provides a choice of agent (>1)
907
if (partnerAgents.status === 'fulfilled' && partnerAgents.value.length > 1) {
908
const partnerAgentItems: vscode.ChatSessionProviderOptionItem[] = partnerAgents.value.map(agent => ({
909
id: agent.id,
910
name: agent.name,
911
...(agent.id === DEFAULT_PARTNER_AGENT_ID && { default: true }),
912
icon: agent.codiconId ? new vscode.ThemeIcon(agent.codiconId) : undefined
913
}));
914
optionGroups.push({
915
id: PARTNER_AGENTS_OPTION_GROUP_ID,
916
name: vscode.l10n.t('Partner Agents'),
917
description: vscode.l10n.t('Select which partner agent to use'),
918
items: partnerAgentItems,
919
});
920
}
921
922
// Find local agent files and categorize them
923
const { matches, localOnly } = await this.getLocalCustomAgentFiles(
924
customAgents.status === 'fulfilled' ? customAgents.value : []
925
);
926
927
if ((customAgents.status === 'fulfilled' && customAgents.value.length > 0) || (repoIds?.length === 1 && localOnly.length > 0)) {
928
const agentItems: vscode.ChatSessionProviderOptionItem[] = [
929
{
930
id: DEFAULT_CUSTOM_AGENT_ID,
931
default: true,
932
name: vscode.l10n.t('Agent'),
933
icon: new vscode.ThemeIcon('agent')
934
},
935
...(customAgents.status === 'fulfilled' ? customAgents.value.map(agent => ({
936
id: agent.name,
937
name: agent.display_name || agent.name,
938
...(matches.has(agent.name.toLowerCase()) && { description: `${agent.name}.md` })
939
})) : []),
940
// Add local-only agents as disabled items with "push to remote" hint
941
...localOnly.map(localAgent => ({
942
id: localAgent.name,
943
name: localAgent.name,
944
description: vscode.l10n.t('Missing from {0}', repoId ? `${repoId.org}/${repoId.repo}` : 'remote repository'),
945
locked: true,
946
icon: new vscode.ThemeIcon('warning')
947
}) satisfies vscode.ChatSessionProviderOptionItem)
948
];
949
optionGroups.push({
950
id: CUSTOM_AGENTS_OPTION_GROUP_ID,
951
name: vscode.l10n.t('Custom Agents'),
952
description: vscode.l10n.t('Select which custom agent to use'),
953
items: agentItems,
954
when: `!chatSessionOption.partnerAgents || chatSessionOption.partnerAgents == ${DEFAULT_PARTNER_AGENT_ID}`
955
});
956
}
957
958
if (models.status === 'fulfilled' && models.value.length > 0) {
959
const modelItems: vscode.ChatSessionProviderOptionItem[] = models.value.map(model => ({
960
id: model.id,
961
name: model.name,
962
...(model.billing?.multiplier !== undefined ? { description: `${model.billing.multiplier}x` } : {}),
963
}));
964
if (!models.value.find(m => m.id === DEFAULT_MODEL_ID)) {
965
modelItems.unshift({ id: DEFAULT_MODEL_ID, name: vscode.l10n.t('Auto'), description: vscode.l10n.t('Automatically select the best model') });
966
}
967
optionGroups.push({
968
id: MODELS_OPTION_GROUP_ID,
969
name: vscode.l10n.t('Model'),
970
description: vscode.l10n.t('Select which model to use'),
971
items: modelItems,
972
when: `!chatSessionOption.partnerAgents || chatSessionOption.partnerAgents == ${DEFAULT_PARTNER_AGENT_ID}`
973
});
974
}
975
976
const result: vscode.ChatSessionProviderOptions = { optionGroups };
977
978
// Cache the full options result with TTL
979
this._optionsCache.set(optionsCacheKey, result);
980
981
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: Returning options: ${JSON.stringify(optionGroups, undefined, 2)}`);
982
return result;
983
} catch (error) {
984
this.logService.error(`[copilotCloudSessionsProvider#provideChatSessionProviderOptions] Error fetching options: ${error}`);
985
return { optionGroups: [] };
986
}
987
}
988
989
private async getRepositoriesOptionItems(repoIds?: GithubRepoId[], fetchAll: boolean = false): Promise<vscode.ChatSessionProviderOptionItem[]> {
990
const items: vscode.ChatSessionProviderOptionItem[] = [];
991
if (!fetchAll) {
992
if (repoIds && repoIds.length > 0) {
993
repoIds.forEach((repoId, index) => {
994
items.push({
995
id: `${repoId.org}/${repoId.repo}`,
996
name: `${repoId.org}/${repoId.repo}`,
997
default: index === 0,
998
icon: new vscode.ThemeIcon('repo'),
999
});
1000
});
1001
} else {
1002
// Fetch repos from recent push events (repos user has recently committed to)
1003
try {
1004
const recentlyCommittedRepos = await this._octoKitService.getRecentlyCommittedRepositories({});
1005
for (const repo of recentlyCommittedRepos) {
1006
const nwo = `${repo.owner}/${repo.name}`;
1007
items.push({
1008
id: nwo,
1009
name: nwo,
1010
icon: new vscode.ThemeIcon('repo'),
1011
});
1012
}
1013
} catch (error) {
1014
this.logService.trace(`Failed to fetch recently committed repos: ${error}`);
1015
}
1016
1017
// Add user-selected repos that aren't already in the list
1018
const userSelectedRepos = this.getUserSelectedRepositories();
1019
const existingIds = new Set(items.map(item => item.id));
1020
for (const repo of userSelectedRepos) {
1021
if (!existingIds.has(repo.name)) {
1022
items.push({
1023
id: repo.name,
1024
name: repo.name,
1025
icon: new vscode.ThemeIcon('repo'),
1026
});
1027
}
1028
}
1029
}
1030
} else {
1031
const fetchedItems = await this.fetchAllRepositoriesFromGitHub();
1032
items.push(...fetchedItems);
1033
}
1034
return items;
1035
}
1036
1037
private async fetchAllRepositoriesFromGitHub(query?: string): Promise<vscode.ChatSessionProviderOptionItem[]> {
1038
try {
1039
// Fetch repos user has access to, optionally filtered by search query
1040
const repos = await this._octoKitService.getUserRepositories({}, query);
1041
1042
// Sort alphabetically and convert to option items
1043
return repos
1044
.map(repo => ({ id: `${repo.owner}/${repo.name}`, name: `${repo.owner}/${repo.name}` }))
1045
.sort((a, b) => a.name.localeCompare(b.name));
1046
} catch (error) {
1047
this.logService.error(`Error fetching repositories from GitHub: ${error}`);
1048
return [];
1049
}
1050
}
1051
1052
provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, token: vscode.CancellationToken): void {
1053
for (const update of updates) {
1054
if (update.optionId === CUSTOM_AGENTS_OPTION_GROUP_ID) {
1055
if (update.value) {
1056
this.sessionCustomAgentMap.set(resource, update.value);
1057
this.logService.info(`Custom agent changed for session ${resource}: ${update.value}`);
1058
} else {
1059
this.sessionCustomAgentMap.delete(resource);
1060
this.logService.info(`Custom agent cleared for session ${resource}`);
1061
}
1062
} else if (update.optionId === MODELS_OPTION_GROUP_ID) {
1063
if (update.value) {
1064
this.sessionModelMap.set(resource, update.value);
1065
this.logService.info(`Model changed for session ${resource}: ${update.value}`);
1066
} else {
1067
this.sessionModelMap.delete(resource);
1068
this.logService.info(`Model cleared for session ${resource}`);
1069
}
1070
} else if (update.optionId === PARTNER_AGENTS_OPTION_GROUP_ID) {
1071
if (update.value) {
1072
this.sessionPartnerAgentMap.set(resource, update.value);
1073
this.logService.info(`Partner agent changed for session ${resource}: ${update.value}`);
1074
} else {
1075
this.sessionPartnerAgentMap.delete(resource);
1076
this.logService.info(`Partner agent cleared for session ${resource}`);
1077
}
1078
} else if (update.optionId === REPOSITORIES_OPTION_GROUP_ID) {
1079
if (update.value) {
1080
this.sessionRepositoryMap.set(resource, update.value);
1081
// Refresh timestamp for user-selected repos when selected from the picker
1082
this.saveUserSelectedRepository(update.value);
1083
this.logService.info(`Repository changed for session ${resource}: ${update.value}`);
1084
} else {
1085
this.sessionRepositoryMap.delete(resource);
1086
this.logService.info(`Repository cleared for session ${resource}`);
1087
}
1088
}
1089
}
1090
}
1091
1092
async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {
1093
// Return cached items if available
1094
if (this.cachedSessionItems) {
1095
return this.cachedSessionItems;
1096
}
1097
1098
if (this.chatSessionItemsPromise) {
1099
return this.chatSessionItemsPromise;
1100
}
1101
this.chatSessionItemsPromise = (async () => {
1102
const repoIds = await getRepoId(this._gitService);
1103
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: repoIds=${JSON.stringify(repoIds?.map(r => ({ org: r.org, repo: r.repo, host: r.host })))}, isAgentSessionsWorkspace=${vscode.workspace.isAgentSessionsWorkspace}`);
1104
// Make sure if it's not a github repo we don't show any sessions
1105
// (unless we're in an agent sessions workspace)
1106
if (!vscode.workspace.isAgentSessionsWorkspace && !this.isGitHubRepoOrEmpty(repoIds)) {
1107
this.logService.debug('copilotCloudSessionsProvider#provideChatSessionItems: not a GitHub repo, returning empty');
1108
return [];
1109
}
1110
let sessions = [];
1111
if (vscode.workspace.isAgentSessionsWorkspace || !repoIds || repoIds.length === 0) {
1112
sessions = await this._octoKitService.getAllSessions(undefined, true, {});
1113
} else {
1114
sessions = (await Promise.all(repoIds.map(repo => this._octoKitService.getAllSessions(`${repo.org}/${repo.repo}`, true, {})))).flat();
1115
}
1116
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: fetched ${sessions.length} sessions`);
1117
this.cachedSessionsSize = sessions.length;
1118
1119
// Group sessions by resource_id and keep only the latest per resource_id
1120
const latestSessionsMap = new Map<number, SessionInfo>();
1121
for (const session of sessions) {
1122
const existing = latestSessionsMap.get(session.resource_id);
1123
if (!existing || this.shouldPushSession(session, existing)) {
1124
latestSessionsMap.set(session.resource_id, session);
1125
}
1126
}
1127
1128
// Track active sessions for background polling
1129
const newActiveSessionIds = new Set<string>();
1130
for (const session of latestSessionsMap.values()) {
1131
if (session.state === 'in_progress' || session.state === 'queued') {
1132
newActiveSessionIds.add(session.id);
1133
}
1134
}
1135
1136
// Update active sessions and start polling if needed
1137
this.activeSessionIds = newActiveSessionIds;
1138
if (this.activeSessionIds.size > 0) {
1139
this.startActiveSessionPolling();
1140
} else {
1141
this.stopActiveSessionPolling();
1142
}
1143
1144
// Fetch PRs for all unique resource_global_ids in parallel
1145
const uniqueGlobalIds = new Set(Array.from(latestSessionsMap.values()).map(s => s.resource_global_id));
1146
const prFetches = Array.from(uniqueGlobalIds).map(async globalId => {
1147
try {
1148
const pr = await this._octoKitService.getPullRequestFromGlobalId(globalId, {});
1149
return { globalId, pr };
1150
} catch (e) {
1151
this.logService.warn(`Failed to fetch PR for global ID ${globalId}: ${e instanceof Error ? e.message : String(e)}`);
1152
return { globalId, pr: null };
1153
}
1154
});
1155
const prResults = await Promise.all(prFetches);
1156
const prMap = new Map(prResults.filter(r => r.pr).map(r => [r.globalId, r.pr!]));
1157
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: resolved ${prMap.size}/${uniqueGlobalIds.size} PRs from global IDs`);
1158
1159
const validateISOTimestamp = (date: string | undefined): number | undefined => {
1160
try {
1161
if (!date) {
1162
return;
1163
}
1164
const time = new Date(date)?.getTime();
1165
if (time > 0) {
1166
return time;
1167
}
1168
} catch { }
1169
};
1170
1171
// Create session items from latest sessions
1172
const sessionItems = await Promise.all(Array.from(latestSessionsMap.values()).map(async sessionItem => {
1173
const pr = prMap.get(sessionItem.resource_global_id);
1174
if (!pr) {
1175
return undefined;
1176
}
1177
1178
const multiDiffPart = await this._prFileChangesService.getFileChangesMultiDiffPart(pr);
1179
const changes = multiDiffPart?.value?.map(change => new vscode.ChatSessionChangedFile(
1180
change.goToFileUri!,
1181
change.originalUri,
1182
change.modifiedUri,
1183
change.added ?? 0,
1184
change.removed ?? 0));
1185
1186
const metadata = {
1187
name: pr.repository?.name,
1188
owner: pr.repository?.owner?.login,
1189
branch: pr.headRefName,
1190
baseBranch: pr.baseRefName,
1191
pullRequestUrl: pr.url,
1192
pullRequestState: derivePullRequestState(pr),
1193
} satisfies { readonly [key: string]: unknown };
1194
1195
const createdAt = validateISOTimestamp(sessionItem.created_at);
1196
const session = {
1197
resource: vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + pr.number }),
1198
label: pr.title,
1199
status: this.getSessionStatusFromSession(sessionItem),
1200
badge: this.getPullRequestBadge(repoIds, pr),
1201
tooltip: this.createPullRequestTooltip(pr),
1202
...(createdAt ? {
1203
timing: {
1204
created: createdAt,
1205
startTime: createdAt,
1206
endTime: validateISOTimestamp(sessionItem.completed_at),
1207
}
1208
} : {}),
1209
changes,
1210
metadata,
1211
fullDatabaseId: pr.fullDatabaseId.toString(),
1212
pullRequestDetails: pr
1213
} satisfies vscode.ChatSessionItem & {
1214
fullDatabaseId: string;
1215
pullRequestDetails: PullRequestSearchItem;
1216
};
1217
this.chatSessions.set(pr.number, pr);
1218
return session;
1219
}));
1220
const filteredSessions = sessionItems
1221
// Remove any undefined sessions
1222
.filter(item => item !== undefined)
1223
// Only keep sessions with attached PRs not CLOSED or MERGED
1224
.filter(item => {
1225
const pr = item.pullRequestDetails;
1226
const state = pr.state.toUpperCase();
1227
return state !== 'CLOSED' && state !== 'MERGED';
1228
});
1229
1230
vscode.commands.executeCommand('setContext', 'github.copilot.chat.cloudSessionsEmpty', filteredSessions.length === 0);
1231
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: returning ${filteredSessions.length} sessions (${sessionItems.length - filteredSessions.length} filtered out)`);
1232
1233
// Cache the results
1234
this.cachedSessionItems = filteredSessions;
1235
1236
return filteredSessions;
1237
})().finally(() => {
1238
this.chatSessionItemsPromise = undefined;
1239
});
1240
return this.chatSessionItemsPromise;
1241
}
1242
1243
private isGitHubRepoOrEmpty(repoIds: GithubRepoId[] | undefined) {
1244
const hasOpenedFolder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0;
1245
if (!hasOpenedFolder) {
1246
return true;
1247
}
1248
const hasGitHubRepo = repoIds && repoIds.length > 0;
1249
return hasGitHubRepo;
1250
}
1251
1252
private shouldPushSession(sessionItem: SessionInfo, existing: SessionInfo | undefined): boolean {
1253
if (!existing) {
1254
return true;
1255
}
1256
const existingDate = new Date(existing.last_updated_at);
1257
const newDate = new Date(sessionItem.last_updated_at);
1258
return newDate > existingDate;
1259
}
1260
1261
async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {
1262
const indexedSessionId = SessionIdForPr.parse(resource);
1263
let pullRequestNumber: number | undefined;
1264
if (indexedSessionId) {
1265
pullRequestNumber = indexedSessionId.prNumber;
1266
}
1267
if (typeof pullRequestNumber === 'undefined') {
1268
pullRequestNumber = SessionIdForPr.parsePullRequestNumber(resource);
1269
if (isNaN(pullRequestNumber)) {
1270
this.logService.error(`Invalid pull request number: ${resource}`);
1271
return this.createEmptySession(resource);
1272
}
1273
}
1274
1275
const pr = await this.findPR(pullRequestNumber);
1276
const summaryReference = new DeferredPromise<vscode.ChatPromptReference | undefined>();
1277
const getProblemStatement = async (repoOwner: string, repoName: string, sessions: SessionInfo[]) => {
1278
if (sessions.length === 0) {
1279
summaryReference.complete(undefined);
1280
return undefined;
1281
}
1282
if (!repoOwner || !repoName) {
1283
summaryReference.complete(undefined);
1284
return undefined;
1285
}
1286
const jobInfo = await this._octoKitService.getJobBySessionId(repoOwner, repoName, sessions[0].id, 'vscode-copilot-chat', CLOUD_SESSIONS_AUTH_OPTIONS);
1287
let prompt = jobInfo?.problem_statement || 'Initial Implementation';
1288
// When delegating, we append the summary to the prompt, & that can be very large and doesn't look great.
1289
// Turn the summary into a reference instead.
1290
const info = this._chatDelegationSummaryService.extractPrompt(sessions[0].id, prompt);
1291
if (info) {
1292
summaryReference.complete(info.reference);
1293
prompt = info.prompt;
1294
} else {
1295
summaryReference.complete(undefined);
1296
}
1297
const titleMatch = prompt.match(/TITLE: \s*(.*)/i);
1298
if (titleMatch && titleMatch[1]) {
1299
prompt = titleMatch[1].trim();
1300
} else {
1301
const split = prompt.split('\n');
1302
if (split.length > 0) {
1303
prompt = split[0].trim();
1304
}
1305
}
1306
return prompt.replace(/@copilot\s*/gi, '').trim();
1307
};
1308
if (!pr) {
1309
this.logService.error(`Session not found for ID: ${resource}`);
1310
return this.createEmptySession(resource);
1311
}
1312
1313
const resolvePartnerAgent = (sessions: SessionInfo[]): { id: string; name: string; at?: string | undefined } | undefined => {
1314
const getDefault = () => {
1315
return HARDCODED_PARTNER_AGENTS.find(agent => agent.id === DEFAULT_PARTNER_AGENT_ID) ?? undefined;
1316
};
1317
const agentId = sessions.find(s => s.agent_id)?.agent_id;
1318
if (!agentId) {
1319
return getDefault();
1320
}
1321
// See if this matches any of the known partner agents
1322
// TODO: Currently hardcoded, no API from GitHub.
1323
const match = HARDCODED_PARTNER_AGENTS.find(agent => Number(agent.id) === agentId);
1324
return match ?? getDefault();
1325
};
1326
1327
const sessions = await this._octoKitService.getCopilotSessionsForPR(pr.fullDatabaseId.toString(), CLOUD_SESSIONS_AUTH_OPTIONS);
1328
const sortedSessions = sessions
1329
.filter((session, index, array) =>
1330
array.findIndex(s => s.id === session.id) === index
1331
)
1332
.slice().sort((a, b) =>
1333
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
1334
);
1335
1336
// Get stored references for this session
1337
const storedReferences = summaryReference.p.then(summaryRef => {
1338
return (this.sessionReferencesMap.get(resource) ?? []).concat(summaryRef ? [summaryRef] : []);
1339
});
1340
1341
const sessionContentBuilder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService);
1342
const history = await sessionContentBuilder.buildSessionHistory(getProblemStatement(pr.repository.owner.login, pr.repository.name, sortedSessions), sortedSessions, pr, (sessionId: string) => this._octoKitService.getSessionLogs(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS), storedReferences);
1343
1344
// const selectedCustomAgent = undefined; /* TODO: Needs API to support this. */
1345
// const selectedModel = undefined; /* TODO: Needs API to support this. */
1346
1347
const partnerAgent = resolvePartnerAgent(sortedSessions);
1348
if (partnerAgent) {
1349
this.sessionPartnerAgentMap.set(resource, partnerAgent.id);
1350
}
1351
1352
return {
1353
history,
1354
options: {
1355
// ...(selectedCustomAgent && { [CUSTOM_AGENTS_OPTION_GROUP_ID]: { id: selectedCustomAgent, locked: true, name: selectedCustomAgent } }),
1356
// ...(selectedModel && { [MODELS_OPTION_GROUP_ID]: { id: selectedModel, locked: true, name: selectedModel } }),
1357
...(partnerAgent && { [PARTNER_AGENTS_OPTION_GROUP_ID]: { id: partnerAgent.id, locked: true, name: partnerAgent.name } }),
1358
},
1359
activeResponseCallback: this.findActiveResponseCallback(sessions, pr),
1360
requestHandler: undefined
1361
};
1362
}
1363
1364
async openSessionInBrowser(chatSessionItem: vscode.ChatSessionItem): Promise<void> {
1365
const session = SessionIdForPr.parse(chatSessionItem.resource);
1366
let prNumber = session?.prNumber;
1367
if (typeof prNumber === 'undefined' || isNaN(prNumber)) {
1368
prNumber = SessionIdForPr.parsePullRequestNumber(chatSessionItem.resource);
1369
if (isNaN(prNumber)) {
1370
vscode.window.showErrorMessage(vscode.l10n.t('Invalid pull request number: {0}', '' + chatSessionItem.resource));
1371
this.logService.error(`Invalid pull request number: ${chatSessionItem.resource}`);
1372
return;
1373
}
1374
}
1375
1376
const pr = await this.findPR(prNumber);
1377
if (!pr) {
1378
vscode.window.showErrorMessage(vscode.l10n.t('Could not find pull request #{0}', prNumber));
1379
this.logService.error(`Could not find pull request #${prNumber}`);
1380
return;
1381
}
1382
1383
await vscode.env.openExternal(vscode.Uri.parse(pr.url));
1384
}
1385
1386
private findActiveResponseCallback(
1387
sessions: SessionInfo[],
1388
pr: PullRequestSearchItem
1389
): ((stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable<void>) | undefined {
1390
// Only the latest in-progress session gets activeResponseCallback
1391
const pendingSession = sessions
1392
.slice()
1393
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
1394
.find(session => session.state === 'in_progress' || session.state === 'queued');
1395
1396
if (pendingSession) {
1397
return this.createActiveResponseCallback(pr, pendingSession.id);
1398
}
1399
return undefined;
1400
}
1401
1402
private createActiveResponseCallback(pr: PullRequestSearchItem, sessionId: string): (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable<void> {
1403
return async (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => {
1404
await this.waitForQueuedToInProgress(sessionId, token);
1405
return this.streamSessionLogs(stream, pr, sessionId, token);
1406
};
1407
}
1408
1409
private createEmptySession(resource: Uri): vscode.ChatSession {
1410
const sessionId = resource ? resource.path.slice(1) : undefined;
1411
return {
1412
history: [],
1413
...(sessionId && isUntitledSessionId(sessionId)
1414
? {
1415
options: {
1416
[CUSTOM_AGENTS_OPTION_GROUP_ID]:
1417
this.sessionCustomAgentMap.get(resource)
1418
?? (this.sessionCustomAgentMap.set(resource, DEFAULT_CUSTOM_AGENT_ID), DEFAULT_CUSTOM_AGENT_ID),
1419
[MODELS_OPTION_GROUP_ID]:
1420
this.sessionModelMap.get(resource)
1421
?? (this.sessionModelMap.set(resource, DEFAULT_MODEL_ID), DEFAULT_MODEL_ID),
1422
[PARTNER_AGENTS_OPTION_GROUP_ID]:
1423
this.sessionPartnerAgentMap.get(resource)
1424
?? (this.sessionPartnerAgentMap.set(resource, DEFAULT_PARTNER_AGENT_ID), DEFAULT_PARTNER_AGENT_ID),
1425
[REPOSITORIES_OPTION_GROUP_ID]:
1426
this.sessionRepositoryMap.get(resource)
1427
?? (this.sessionRepositoryMap.set(resource, DEFAULT_REPOSITORY_ID), DEFAULT_REPOSITORY_ID)
1428
}
1429
}
1430
: {}),
1431
requestHandler: undefined
1432
};
1433
}
1434
1435
private async findPR(prNumber: number, options: { retries?: number; repository?: string } = {}) {
1436
const { retries = 1, repository } = options;
1437
let pr = this.chatSessions.get(prNumber);
1438
if (pr) {
1439
return pr;
1440
}
1441
let repoOwner: string;
1442
let repoName: string;
1443
if (repository && repository !== DEFAULT_REPOSITORY_ID) {
1444
const [owner, name] = repository.split('/');
1445
repoOwner = owner;
1446
repoName = name;
1447
} else {
1448
const repoIds = await getRepoId(this._gitService);
1449
const repoId = repoIds?.[0];
1450
if (!repoId) {
1451
this.logService.warn('Failed to determine GitHub repo from workspace');
1452
return undefined;
1453
}
1454
repoOwner = repoId.org;
1455
repoName = repoId.repo;
1456
}
1457
try {
1458
pr = await retry(async () => {
1459
const pullRequests = await this._octoKitService.getOpenPullRequestsForUser(repoOwner, repoName, CLOUD_SESSIONS_AUTH_OPTIONS);
1460
const found = pullRequests.find(p => p.number === prNumber);
1461
if (!found) {
1462
this.logService.warn(`Pull request ${prNumber} is not visible yet, retrying...`);
1463
throw new Error(`PR ${prNumber} not yet visible`);
1464
}
1465
return found;
1466
}, 1500, retries);
1467
if (pr) {
1468
this.chatSessions.set(pr.number, pr);
1469
}
1470
return pr;
1471
} catch (error) {
1472
this.logService.warn(`Pull request not found for number: ${prNumber}. ${error instanceof Error ? error.message : String(error)}`);
1473
return undefined;
1474
}
1475
}
1476
1477
private getSessionStatusFromSession(session: SessionInfo): vscode.ChatSessionStatus {
1478
// Map session state to ChatSessionStatus
1479
switch (session.state) {
1480
case 'failed':
1481
return vscode.ChatSessionStatus.Failed;
1482
case 'in_progress':
1483
case 'queued':
1484
return vscode.ChatSessionStatus.InProgress;
1485
case 'completed':
1486
return vscode.ChatSessionStatus.Completed;
1487
default:
1488
return vscode.ChatSessionStatus.Completed;
1489
}
1490
}
1491
1492
private getPullRequestBadge(repoIds: GithubRepoId[] | undefined, pr: PullRequestSearchItem): vscode.MarkdownString | undefined {
1493
if (
1494
vscode.workspace.workspaceFolders === undefined || // empty window
1495
vscode.workspace.isAgentSessionsWorkspace || // agent sessions workspace
1496
(repoIds && repoIds.length > 1) // multiple repositories
1497
) {
1498
const badgeLabel = `${pr.repository.owner.login}/${pr.repository.name}`;
1499
const badge = new vscode.MarkdownString(`$(repo) ${badgeLabel}`, true);
1500
badge.supportThemeIcons = true;
1501
return badge;
1502
}
1503
1504
return undefined;
1505
}
1506
1507
private createPullRequestTooltip(pr: PullRequestSearchItem): vscode.MarkdownString {
1508
const markdown = new vscode.MarkdownString(undefined, true);
1509
markdown.supportHtml = true;
1510
1511
// Repository and date
1512
const date = new Date(pr.createdAt);
1513
const ownerName = `${pr.repository.owner.login}/${pr.repository.name}`;
1514
// Derive repo URL from the PR URL to support both github.com and GHE
1515
const repoUrl = pr.url.replace(/\/pull\/\d+$/, '');
1516
markdown.appendMarkdown(
1517
`[${ownerName}](${repoUrl}) on ${date.toLocaleString('default', {
1518
day: 'numeric',
1519
month: 'short',
1520
year: 'numeric',
1521
})} \n`
1522
);
1523
1524
// Icon, title, and PR number
1525
const icon = this.getIconMarkdown(pr);
1526
// Strip markdown from title for plain text display
1527
const title = this.plainTextRenderer.render(pr.title);
1528
markdown.appendMarkdown(
1529
`${icon} **${title}** [#${pr.number}](${pr.url}) \n`
1530
);
1531
1532
// Body/Description (truncated if too long)
1533
markdown.appendMarkdown(' \n');
1534
const maxBodyLength = 200;
1535
let body = this.plainTextRenderer.render(pr.body || '');
1536
// Convert plain text newlines to markdown line breaks (two spaces + newline)
1537
body = body.replace(/\n/g, ' \n');
1538
body = body.length > maxBodyLength ? body.substring(0, maxBodyLength) + '...' : body;
1539
markdown.appendMarkdown(body + ' \n');
1540
1541
return markdown;
1542
}
1543
1544
private getIconMarkdown(pr: PullRequestSearchItem): string {
1545
const state = pr.state.toUpperCase();
1546
return state === 'MERGED' ? '$(git-merge)' : '$(git-pull-request)';
1547
}
1548
1549
private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean {
1550
if (!history || history.length === 0) {
1551
return false;
1552
}
1553
const allResponsesEmpty = history.every(turn => {
1554
if (turn instanceof vscode.ChatResponseTurn) {
1555
return turn.response.length === 0;
1556
}
1557
return true;
1558
});
1559
return !allResponsesEmpty;
1560
}
1561
1562
async delegate(
1563
request: vscode.ChatRequest,
1564
stream: vscode.ChatResponseStream,
1565
context: vscode.ChatContext,
1566
token: vscode.CancellationToken,
1567
metadata: ConfirmationMetadata,
1568
base_ref?: string,
1569
head_ref?: string
1570
): Promise<vscode.ChatResponsePullRequestPart> {
1571
1572
let history: string | undefined;
1573
1574
// TODO: Do this async/optimistically before delegation triggered
1575
if (this.hasHistoryToSummarize(context.history)) {
1576
stream.progress(vscode.l10n.t('Analyzing chat history'));
1577
history = await this._chatDelegationSummaryService.summarize(context, token);
1578
}
1579
1580
// Get the chat resource from context or metadata
1581
const chatResource = context.chatSessionContext?.chatSessionItem?.resource
1582
?? metadata.chatContext.chatSessionContext?.chatSessionItem?.resource;
1583
1584
let customAgentName: string | undefined;
1585
let modelName: string | undefined;
1586
let partnerAgentName: string | undefined;
1587
let selectedRepository: string | undefined;
1588
if (chatResource) {
1589
this.logService.trace(`[delegate] Looking up options for chatResource=${chatResource.toString()}, partnerAgentMap.size=${this.sessionPartnerAgentMap.size}`);
1590
customAgentName = this.sessionCustomAgentMap.get(chatResource);
1591
modelName = this.sessionModelMap.get(chatResource);
1592
partnerAgentName = this.sessionPartnerAgentMap.get(chatResource);
1593
selectedRepository = this.sessionRepositoryMap.get(chatResource);
1594
this.logService.trace(`[delegate] Retrieved options for ${chatResource.toString()}: customAgent=${customAgentName}, model=${modelName}, partnerAgent=${partnerAgentName}`);
1595
} else {
1596
this.logService.trace(`[delegate] No chatResource available to retrieve session options`);
1597
}
1598
1599
const { result, processedReferences } = await this.extractReferences(metadata.references, !!head_ref);
1600
1601
const repoIds = await getRepoId(this._gitService);
1602
const repoId = repoIds?.[0];
1603
let repoOwner = repoId?.org;
1604
let repoName = repoId?.repo;
1605
const [selectedRepoOwner, selectedRepoName] = (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) ? selectedRepository.split('/') : [];
1606
if (!base_ref || repoOwner !== selectedRepoOwner || repoName !== selectedRepoName) {
1607
if (selectedRepoOwner && selectedRepoName) {
1608
repoOwner = selectedRepoOwner;
1609
repoName = selectedRepoName;
1610
} else {
1611
if (!repoId) {
1612
throw new Error(vscode.l10n.t('Open a GitHub repository to use the cloud agent.'));
1613
}
1614
repoOwner = repoId.org;
1615
repoName = repoId.repo;
1616
}
1617
const { default_branch } = await this._githubRepositoryService.getRepositoryInfo(repoOwner, repoName);
1618
base_ref = default_branch;
1619
}
1620
1621
const { number, sessionId } = await this.invokeRemoteAgent(
1622
metadata.prompt,
1623
[result, history].filter(Boolean).join('\n\n').trim(),
1624
token,
1625
stream,
1626
base_ref,
1627
head_ref,
1628
customAgentName,
1629
modelName,
1630
partnerAgentName,
1631
selectedRepository
1632
);
1633
if (history) {
1634
void this._chatDelegationSummaryService.trackSummaryUsage(sessionId, history);
1635
}
1636
this.logService.debug(`Delegated to cloud agent for PR #${number} with session ID ${sessionId}`);
1637
1638
// Store references for this session
1639
const sessionUri = vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + number });
1640
1641
// Cache the processed references for presentation later
1642
if (processedReferences.length > 0) {
1643
this.sessionReferencesMap.set(sessionUri, processedReferences);
1644
}
1645
1646
stream.progress(vscode.l10n.t('Fetching pull request details'));
1647
const pullRequest = await this.findPR(number, { retries: 7, repository: selectedRepository });
1648
if (!pullRequest) {
1649
throw new Error(`Failed to find pull request #${number} after delegation.`);
1650
}
1651
const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.repository.owner.login, repo: pullRequest.repository.name, pullRequestNumber: pullRequest.number });
1652
1653
if (metadata.chatContext.chatSessionContext?.isUntitled) {
1654
// Untitled flow
1655
this._onDidCommitChatSessionItem.fire({
1656
original: metadata.chatContext.chatSessionContext.chatSessionItem,
1657
modified: {
1658
resource: sessionUri,
1659
label: `Pull Request ${number}`
1660
}
1661
});
1662
} else {
1663
// Delegated flow
1664
// NOTE: VS Code will now close the parent/source chat in most cases.
1665
stream.markdown(vscode.l10n.t('A cloud agent has begun working on your request. Follow its progress in the sessions list and associated pull request.'));
1666
}
1667
1668
// Return this for external callers, eg: CLI
1669
return {
1670
uri, // PR uri,
1671
command: {
1672
title: vscode.l10n.t('View Pull Request #{0}', pullRequest.number),
1673
command: 'github.copilot.chat.openPullRequestReroute',
1674
arguments: [pullRequest.number]
1675
},
1676
title: pullRequest.title,
1677
description: pullRequest.body || '',
1678
author: getAuthorDisplayName(pullRequest.author),
1679
linkTag: `#${pullRequest.number}`
1680
};
1681
}
1682
1683
private async handleConfirmationData(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, context: vscode.ChatContext, token: vscode.CancellationToken) {
1684
if (!request.prompt || request.prompt.indexOf(':') === -1) {
1685
this.logService.error('Invalid confirmation prompt format.');
1686
return {};
1687
}
1688
1689
// Parse out the button selected by the user
1690
const selection = (request.prompt?.split(':')[0] || '').trim().toUpperCase();
1691
const metadata: unknown = request.acceptedConfirmationData?.[0]?.metadata || request.rejectedConfirmationData?.[0]?.metadata;
1692
try {
1693
validateMetadata(metadata);
1694
} catch (error) {
1695
this.logService.error(`Invalid confirmation metadata: ${error}`);
1696
return {};
1697
}
1698
1699
// -- Process each button press in order of precedence
1700
1701
if (!selection || selection === this.CANCEL.toUpperCase() || token.isCancellationRequested) {
1702
/* __GDPR__
1703
"copilotcloud.chat.confirmationCancelled" : {
1704
"owner": "joshspicer",
1705
"comment": "Event sent when the cloud chat confirmation flow is cancelled.",
1706
"tokenCancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the cancellation token was already cancelled." }
1707
}
1708
*/
1709
this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.confirmationCancelled', {
1710
tokenCancelled: String(token.isCancellationRequested)
1711
});
1712
stream.markdown(vscode.l10n.t('Cloud agent cancelled'));
1713
return {};
1714
}
1715
1716
if (selection.includes(this.AUTHORIZE.toUpperCase())) {
1717
stream.progress(vscode.l10n.t('Authorizing'));
1718
try {
1719
await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } });
1720
if (!this._authenticationService.permissiveGitHubSession) {
1721
throw new Error('Failed to obtain permissive GitHub session');
1722
}
1723
} catch (error) {
1724
this.logService.error(`Authorization failed: ${error}`);
1725
throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));
1726
1727
}
1728
}
1729
1730
let head_ref: string | undefined; // If set, this is the branch we pushed pending changes to.
1731
1732
if (selection.includes(this.COMMIT.toUpperCase())) {
1733
try {
1734
stream.progress(vscode.l10n.t('Committing and pushing local changes'));
1735
head_ref = await this.gitOperationsManager.commitAndPushChanges();
1736
stream.markdown(vscode.l10n.t('Local changes pushed to remote branch `{0}`.', head_ref));
1737
} catch (error) {
1738
this.logService.error(`Commit and push failed: ${error}`);
1739
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.'));
1740
}
1741
} else if (selection.includes(this.PUSH_BRANCH.toUpperCase())) {
1742
try {
1743
stream.progress(vscode.l10n.t('Pushing base branch to remote'));
1744
const baseBranch = await this.gitOperationsManager.pushBaseRefToRemote();
1745
stream.markdown(vscode.l10n.t('Base branch `{0}` pushed to remote.', baseBranch));
1746
} catch (error) {
1747
this.logService.error(`Push branch failed: ${error}`);
1748
throw vscode.l10n.t('{0}. Push the current branch to remote and try again.', (error instanceof Error ? error.message : String(error)) ?? vscode.l10n.t('Failed to push current branch.'));
1749
}
1750
}
1751
1752
// Get the selected repository from the chat context for multiroot workspace support
1753
const chatResource = metadata.chatContext.chatSessionContext?.chatSessionItem?.resource;
1754
const selectedRepository = chatResource ? this.sessionRepositoryMap.get(chatResource) : undefined;
1755
1756
const base_ref: string = await (async () => {
1757
const res = await this.checkBaseBranchPresentOnRemote(selectedRepository);
1758
if (!res) {
1759
// Unexpected
1760
throw new Error(vscode.l10n.t('Repo base branch is not detected on remote. Push your branch and try again.'));
1761
}
1762
return (res?.missingOnRemote || !res?.baseRef) ? res.repoDefaultBranch : res?.baseRef;
1763
})();
1764
stream.progress(vscode.l10n.t('Validating branch base branch exists on remote'));
1765
1766
// Now trigger delegation
1767
try {
1768
await this.delegate(request, stream, context, token, metadata, base_ref, head_ref);
1769
} catch (error) {
1770
this.logService.error(`Failure in delegation: ${error}`);
1771
throw new Error(vscode.l10n.t('{0}', (error instanceof Error ? error.message : String(error))));
1772
}
1773
}
1774
1775
private setWorkspaceContext(key: string, value: string) {
1776
this._extensionContext.workspaceState.update(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`, value);
1777
}
1778
1779
private getWorkspaceContext(key: string): string | undefined {
1780
return this._extensionContext.workspaceState.get<string>(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`);
1781
}
1782
1783
resetWorkspaceContext() {
1784
const keys =
1785
this._extensionContext.workspaceState.keys()
1786
.filter(key => key.startsWith(this.WORKSPACE_CONTEXT_PREFIX));
1787
for (const key of keys) {
1788
this.logService.debug(`[resetWorkspaceContext] ${key}`);
1789
this._extensionContext.workspaceState.update(key, undefined);
1790
}
1791
}
1792
1793
/**
1794
* Saves a user-selected repository to global state with current timestamp.
1795
* If the repo already exists, the timestamp is refreshed.
1796
*/
1797
private saveUserSelectedRepository(repoName: string): void {
1798
const repos = this.getUserSelectedRepositories();
1799
const existingIndex = repos.findIndex(r => r.name === repoName);
1800
if (existingIndex >= 0) {
1801
repos[existingIndex].timestamp = Date.now();
1802
} else {
1803
repos.push({ name: repoName, timestamp: Date.now() });
1804
}
1805
this._extensionContext.globalState.update(USER_SELECTED_REPOS_KEY, repos);
1806
this._onDidChangeChatSessionProviderOptions.fire();
1807
}
1808
1809
/**
1810
* Gets user-selected repositories, filtering out expired entries (older than 1 week).
1811
* Expired entries are automatically cleaned up.
1812
*/
1813
private getUserSelectedRepositories(): UserSelectedRepository[] {
1814
const repos = this._extensionContext.globalState.get<UserSelectedRepository[]>(USER_SELECTED_REPOS_KEY, []);
1815
const now = Date.now();
1816
const validRepos = repos.filter(r => (now - r.timestamp) < USER_SELECTED_REPOS_EXPIRY_MS);
1817
1818
// Clean up expired repos if any were filtered out
1819
if (validRepos.length !== repos.length) {
1820
this._extensionContext.globalState.update(USER_SELECTED_REPOS_KEY, validRepos);
1821
}
1822
1823
return validRepos;
1824
}
1825
1826
private async detectedUncommittedChanges(): Promise<boolean> {
1827
const currentRepository = this._gitService.activeRepository?.get();
1828
if (!currentRepository) {
1829
return false;
1830
}
1831
const git = this._gitExtensionService.getExtensionApi();
1832
const repo = git?.getRepository(currentRepository?.rootUri);
1833
if (!repo) {
1834
return false;
1835
}
1836
return repo.state.workingTreeChanges.length > 0 || repo.state.indexChanges.length > 0;
1837
}
1838
1839
/**
1840
* Checks if the current base branch exists on the remote repository.
1841
* Returns branch information including whether it's missing from remote, the base ref name, and the repository's default branch.
1842
* @param selectedRepository - Optional repository in `org/repo` format. If provided, uses this specific repository
1843
* instead of defaulting to the first one. This enables multiroot workspace support.
1844
*/
1845
private async checkBaseBranchPresentOnRemote(selectedRepository?: string): Promise<{ missingOnRemote: boolean; baseRef: string; repoDefaultBranch: string } | undefined> {
1846
try {
1847
const repoIds = await getRepoId(this._gitService);
1848
if (!repoIds || repoIds.length === 0) {
1849
return undefined;
1850
}
1851
1852
// In multiroot workspaces, use the selected repository if provided
1853
let repoId = repoIds[0];
1854
if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) {
1855
const [selectedOrg, selectedRepo] = selectedRepository.split('/');
1856
const matchingRepoId = repoIds.find(id => id.org === selectedOrg && id.repo === selectedRepo);
1857
repoId = matchingRepoId ?? new GithubRepoId(selectedOrg, selectedRepo);
1858
}
1859
1860
const { baseRef, repository, remoteName } = await this.gitOperationsManager.repoInfo();
1861
const remoteRepoInfo = await this._githubRepositoryService.getRepositoryInfo(repoId.org, repoId.repo);
1862
const remoteHasRef = await this.gitOperationsManager.checkIfRemoteHasRef(repository, remoteName, baseRef);
1863
if (remoteHasRef) {
1864
// Remote HAS the base branch, no action needed.
1865
return { missingOnRemote: false, baseRef, repoDefaultBranch: remoteRepoInfo.default_branch };
1866
}
1867
// Remote is MISSING the base branch
1868
return { missingOnRemote: true, baseRef, repoDefaultBranch: remoteRepoInfo.default_branch };
1869
} catch (error) {
1870
this.logService.debug(`Failed to check default branch: ${error}`);
1871
return undefined;
1872
}
1873
}
1874
1875
/**
1876
* Returns either all the data for a confirmation dialog, or undefined if no confirmation is needed.
1877
* */
1878
private async buildConfirmation(context: vscode.ChatContext): Promise<{ title: string; message: string; buttons: string[] } | undefined> {
1879
const title: string = this.TITLE;
1880
const buttons: string[] = [this.CANCEL];
1881
let message: string = this.BASE_MESSAGE;
1882
1883
// Get the selected repository from the chat context for multiroot workspace support
1884
const chatResource = context.chatSessionContext?.chatSessionItem?.resource;
1885
const selectedRepository = chatResource ? this.sessionRepositoryMap.get(chatResource) : undefined;
1886
1887
const needsPermissiveAuth = !this._authenticationService.permissiveGitHubSession;
1888
const hasUncommittedChanges = await this.detectedUncommittedChanges();
1889
const baseBranchInfo = await this.checkBaseBranchPresentOnRemote(selectedRepository);
1890
1891
if (needsPermissiveAuth && hasUncommittedChanges) {
1892
message += '\n\n' + this.AUTHORIZE_MESSAGE;
1893
message += '\n\n' + this.COMMIT_MESSAGE;
1894
buttons.unshift(
1895
vscode.l10n.t('{0} and {1}', this.AUTHORIZE, this.COMMIT),
1896
this.AUTHORIZE,
1897
);
1898
} else if (needsPermissiveAuth && baseBranchInfo?.missingOnRemote) {
1899
const { baseRef, repoDefaultBranch } = baseBranchInfo;
1900
message += '\n\n' + this.AUTHORIZE_MESSAGE;
1901
message += '\n\n' + this.PUSH_BRANCH_MESSAGE(baseRef, repoDefaultBranch);
1902
buttons.unshift(
1903
vscode.l10n.t('{0} and {1}', this.AUTHORIZE, this.PUSH_BRANCH),
1904
this.AUTHORIZE,
1905
);
1906
} else if (needsPermissiveAuth) {
1907
message += '\n\n' + this.AUTHORIZE_MESSAGE;
1908
buttons.unshift(
1909
this.AUTHORIZE,
1910
);
1911
} else if (hasUncommittedChanges) {
1912
message += '\n\n' + this.COMMIT_MESSAGE;
1913
buttons.unshift(
1914
vscode.l10n.t('{0} and {1}', this.COMMIT, this.DELEGATE),
1915
this.DELEGATE,
1916
);
1917
} else if (baseBranchInfo?.missingOnRemote) {
1918
const { baseRef, repoDefaultBranch } = baseBranchInfo;
1919
message += '\n\n' + this.PUSH_BRANCH_MESSAGE(baseRef, repoDefaultBranch);
1920
buttons.unshift(
1921
vscode.l10n.t('{0} and {1}', this.PUSH_BRANCH, this.DELEGATE),
1922
this.DELEGATE,
1923
);
1924
}
1925
1926
// Check if the message has been modified from the default
1927
const messageModified = message !== this.BASE_MESSAGE;
1928
1929
// Only skip confirmation if neither buttons were modified nor message was modified
1930
if (buttons.length === 1 && !messageModified) {
1931
if (context.chatSessionContext?.isUntitled) {
1932
return; // Don't show the confirmation
1933
}
1934
const seenDelegationPromptBefore = this.getWorkspaceContext(SEEN_DELEGATION_PROMPT_KEY);
1935
if (seenDelegationPromptBefore) {
1936
return; // Don't show the confirmation
1937
}
1938
}
1939
1940
if (buttons.length === 1) {
1941
// No other affirmative button added, so add generic one
1942
buttons.unshift(this.DELEGATE);
1943
}
1944
1945
return { title, message, buttons };
1946
}
1947
1948
private async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {
1949
if (token.isCancellationRequested) {
1950
stream.warning(vscode.l10n.t('Cloud session cancelled.'));
1951
return {};
1952
}
1953
1954
if (request.acceptedConfirmationData || request.rejectedConfirmationData) {
1955
await this.handleConfirmationData(request, stream, context, token);
1956
this.setWorkspaceContext(SEEN_DELEGATION_PROMPT_KEY, 'yes');
1957
return {};
1958
}
1959
1960
// Look up the partner agent and model for telemetry
1961
const chatResource = context.chatSessionContext?.chatSessionItem?.resource;
1962
1963
const initialOptions = context.chatSessionContext?.initialSessionOptions;
1964
if (chatResource) {
1965
this.logService.trace(`[chatParticipantImpl] initialSessionOptions for ${chatResource.toString()}: ${describeRuntimeValue(initialOptions)}`);
1966
}
1967
if (chatResource) {
1968
for (const opt of normalizeInitialSessionOptions(initialOptions, this.logService, chatResource)) {
1969
const value = typeof opt.value === 'string' ? opt.value : opt.value.id;
1970
if (opt.optionId === CUSTOM_AGENTS_OPTION_GROUP_ID) {
1971
this.sessionCustomAgentMap.set(chatResource, value);
1972
} else if (opt.optionId === MODELS_OPTION_GROUP_ID) {
1973
this.sessionModelMap.set(chatResource, value);
1974
} else if (opt.optionId === PARTNER_AGENTS_OPTION_GROUP_ID) {
1975
this.sessionPartnerAgentMap.set(chatResource, value);
1976
} else if (opt.optionId === REPOSITORIES_OPTION_GROUP_ID) {
1977
this.sessionRepositoryMap.set(chatResource, value);
1978
}
1979
}
1980
}
1981
1982
const partnerAgentId = chatResource ? this.sessionPartnerAgentMap.get(chatResource) : undefined;
1983
const partnerAgent = HARDCODED_PARTNER_AGENTS.find(agent => agent.id === partnerAgentId);
1984
const modelId = chatResource ? this.sessionModelMap.get(chatResource) : undefined;
1985
1986
/* __GDPR__
1987
"copilotcloud.chat.invoke" : {
1988
"owner": "joshspicer",
1989
"comment": "Event sent when a Copilot Cloud chat request is made.",
1990
"chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The unique chat request ID." },
1991
"hasChatSessionItem": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Invoked with a chat session item." },
1992
"isUntitled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the chat session is untitled." },
1993
"partnerAgent": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The partner agent name (e.g., Copilot, Claude, Codex)." },
1994
"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The selected model ID." }
1995
}
1996
*/
1997
this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.invoke', {
1998
chatRequestId: request.id,
1999
hasChatSessionItem: String(!!context.chatSessionContext?.chatSessionItem),
2000
isUntitled: String(context.chatSessionContext?.isUntitled),
2001
partnerAgent: partnerAgent?.name ?? 'unknown',
2002
model: modelId ?? 'unknown'
2003
});
2004
GenAiMetrics.incrementCloudSessionCount(this._otelService, partnerAgent?.name ?? 'unknown');
2005
emitCloudSessionInvokeEvent(this._otelService, partnerAgent?.name ?? 'unknown', modelId ?? 'unknown', request.id);
2006
2007
// Follow up
2008
if (context.chatSessionContext && !context.chatSessionContext.isUntitled && request.sessionResource.scheme === CopilotCloudSessionsProvider.TYPE) {
2009
await this.handleFollowUp(request, context, stream, token);
2010
return {};
2011
}
2012
2013
// New request
2014
const showConfirmation = await this.buildConfirmation(context);
2015
if (showConfirmation) {
2016
const { title, message, buttons } = showConfirmation;
2017
stream.confirmation(
2018
title,
2019
message,
2020
{
2021
metadata: {
2022
prompt: request.prompt,
2023
references: request.references,
2024
chatContext: context,
2025
} satisfies ConfirmationMetadata
2026
},
2027
buttons
2028
);
2029
} else {
2030
// No confirmation
2031
await this.delegate(
2032
request,
2033
stream,
2034
context,
2035
token,
2036
{
2037
prompt: request.prompt,
2038
references: request.references,
2039
chatContext: context
2040
} satisfies ConfirmationMetadata,
2041
);
2042
}
2043
}
2044
2045
private async handleFollowUp(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {
2046
if (!context.chatSessionContext || context.chatSessionContext.isUntitled) {
2047
return {};
2048
}
2049
const { prompt } = request;
2050
if (!prompt || prompt.trim().length === 0) {
2051
stream.markdown(vscode.l10n.t('Please provide a message for the cloud agent.'));
2052
return {};
2053
}
2054
2055
stream.progress(vscode.l10n.t('Preparing'));
2056
const session = SessionIdForPr.parse(context.chatSessionContext.chatSessionItem.resource);
2057
let prNumber = session?.prNumber;
2058
if (!prNumber) {
2059
prNumber = SessionIdForPr.parsePullRequestNumber(context.chatSessionContext.chatSessionItem.resource);
2060
if (!prNumber) {
2061
return {};
2062
}
2063
}
2064
const pullRequest = await this.findPR(prNumber);
2065
if (!pullRequest) {
2066
stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', '' + context.chatSessionContext.chatSessionItem.resource));
2067
return {};
2068
}
2069
2070
stream.progress(vscode.l10n.t('Delegating'));
2071
2072
const cachedPartnerAgentId = this.sessionPartnerAgentMap.get(context.chatSessionContext.chatSessionItem.resource);
2073
const partnerAgentAt = HARDCODED_PARTNER_AGENTS.find(agent => agent.id === cachedPartnerAgentId)?.at;
2074
2075
const result = await this.addFollowUpToExistingPR(pullRequest.number, prompt, undefined, partnerAgentAt);
2076
if (!result) {
2077
stream.markdown(vscode.l10n.t('Failed to add follow-up comment to the pull request.'));
2078
return {};
2079
}
2080
2081
// Show initial success message
2082
stream.markdown(result);
2083
stream.markdown('\n\n');
2084
2085
stream.progress(vscode.l10n.t('Attaching to session'));
2086
2087
// Wait for new session and stream its progress
2088
const newSession = await this.waitForNewSession(pullRequest, stream, token, true);
2089
if (!newSession) {
2090
return {};
2091
}
2092
2093
// Stream the new session logs
2094
stream.markdown(vscode.l10n.t('Cloud agent has begun work on your request'));
2095
stream.markdown('\n\n');
2096
2097
await this.streamSessionLogs(stream, pullRequest, newSession.id, token);
2098
return {};
2099
}
2100
2101
/**
2102
* Processes *supported* references, returning an LLM-friendly string representation and the filtered list of those references that were processed.
2103
*/
2104
private async extractReferences(references: readonly vscode.ChatPromptReference[] | undefined, pushedInProgressBranch: boolean): Promise<{ result: string; processedReferences: readonly vscode.ChatPromptReference[] }> {
2105
// 'file:///Users/jospicer/dev/joshbot/.github/workflows/build-vsix.yml' -> '.github/workflows/build-vsix.yml'
2106
const fileRefs: string[] = [];
2107
const fullFileParts: string[] = [];
2108
const processedReferences: vscode.ChatPromptReference[] = [];
2109
const git = this._gitExtensionService.getExtensionApi();
2110
for (const ref of references || []) {
2111
if (ref.value instanceof vscode.Uri && ref.value.scheme === 'file') {
2112
const fileUri = ref.value;
2113
const repositoryForFile = git?.getRepository(fileUri);
2114
if (repositoryForFile) {
2115
const relativePath = pathLib.relative(repositoryForFile.rootUri.fsPath, fileUri.fsPath);
2116
const isInWorkingTree = repositoryForFile.state.workingTreeChanges.some(change => change.uri.fsPath === fileUri.fsPath);
2117
const isInIndex = repositoryForFile.state.indexChanges.some(change => change.uri.fsPath === fileUri.fsPath);
2118
if (!pushedInProgressBranch && (isInWorkingTree || isInIndex)) {
2119
try {
2120
// Show only the file diffs for modified files
2121
let diff: string;
2122
if (isInIndex) {
2123
diff = await repositoryForFile.diffIndexWithHEAD(fileUri.fsPath);
2124
} else {
2125
diff = await repositoryForFile.diffWithHEAD(fileUri.fsPath);
2126
}
2127
2128
if (diff && diff.trim()) {
2129
fullFileParts.push(`<file-diff-start>${relativePath}</file-diff-start>`);
2130
fullFileParts.push(diff);
2131
fullFileParts.push(`<file-diff-end>${relativePath}</file-diff-end>`);
2132
} else {
2133
// If diff is empty, fall back to file reference
2134
fileRefs.push(` - ${relativePath}`);
2135
}
2136
processedReferences.push(ref);
2137
} catch (error) {
2138
this.logService.error(`Error reading file diff for reference: ${fileUri.toString()}: ${error}`);
2139
}
2140
} else {
2141
fileRefs.push(` - ${relativePath}`);
2142
processedReferences.push(ref);
2143
}
2144
}
2145
} else if (ref.value instanceof vscode.Uri && ref.value.scheme === 'github-remote-file') {
2146
// Virtual filesystem for cloud repos in the sessions window.
2147
// URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}
2148
const parts = ref.value.path.split('/').filter(Boolean); // ['owner', 'repo', 'ref', ...path]
2149
if (parts.length >= 4) {
2150
const relativePath = parts.slice(3).join('/');
2151
fileRefs.push(` - ${relativePath}`);
2152
processedReferences.push(ref);
2153
}
2154
} else if (ref.value instanceof vscode.Uri && ref.value.scheme === 'untitled') {
2155
// Get full content of untitled file
2156
try {
2157
const document = await vscode.workspace.openTextDocument(ref.value);
2158
const content = document.getText();
2159
fullFileParts.push(`<file-start>${ref.value.path}</file-start>`);
2160
fullFileParts.push(content);
2161
fullFileParts.push(`<file-end>${ref.value.path}</file-end>`);
2162
processedReferences.push(ref);
2163
} catch (error) {
2164
this.logService.error(`Error reading untitled file content for reference: ${ref.value.toString()}: ${error}`);
2165
}
2166
}
2167
}
2168
2169
const parts: string[] = [
2170
...(fullFileParts.length ? ['The user has attached the following uncommitted or modified files as relevant context:', ...fullFileParts] : []),
2171
...(fileRefs.length ? ['The user has attached the following file paths as relevant context:', ...fileRefs] : [])
2172
];
2173
2174
this.logService.debug(`Cloud agent knew how to process ${processedReferences.length} of the ${references?.length || 0} provided references.`);
2175
return { result: parts.join('\n'), processedReferences };
2176
}
2177
2178
private async streamSessionLogs(stream: vscode.ChatResponseStream, pullRequest: PullRequestSearchItem, sessionId: string, token: vscode.CancellationToken): Promise<void> {
2179
let lastLogLength = 0;
2180
let lastProcessedLength = 0;
2181
let hasActiveProgress = false;
2182
const pollingInterval = 3000; // 3 seconds
2183
2184
return new Promise<void>((resolve, reject) => {
2185
let isCompleted = false;
2186
2187
const complete = async () => {
2188
if (isCompleted) {
2189
return;
2190
}
2191
isCompleted = true;
2192
this.refresh();
2193
resolve();
2194
};
2195
2196
const pollForUpdates = async (): Promise<void> => {
2197
try {
2198
if (token.isCancellationRequested) {
2199
complete();
2200
return;
2201
}
2202
2203
// Get the specific session info
2204
const sessionInfo = await this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);
2205
if (!sessionInfo || token.isCancellationRequested) {
2206
complete();
2207
return;
2208
}
2209
2210
// Get session logs
2211
const logs = await this._octoKitService.getSessionLogs(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);
2212
2213
// Check if session is still in progress
2214
if (sessionInfo.state !== 'in_progress') {
2215
if (logs.length > lastProcessedLength) {
2216
const newLogContent = logs.slice(lastProcessedLength);
2217
const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent);
2218
if (streamResult.hasStreamedContent) {
2219
hasActiveProgress = false;
2220
}
2221
}
2222
hasActiveProgress = false;
2223
complete();
2224
return;
2225
}
2226
2227
if (logs.length > lastLogLength) {
2228
this.logService.trace(`New logs detected, attempting to stream content`);
2229
const newLogContent = logs.slice(lastProcessedLength);
2230
const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent);
2231
lastProcessedLength = logs.length;
2232
2233
if (streamResult.hasStreamedContent) {
2234
this.logService.trace(`Content was streamed, resetting hasActiveProgress to false`);
2235
hasActiveProgress = false;
2236
} else if (streamResult.hasSetupStepProgress) {
2237
this.logService.trace(`Setup step progress detected, keeping progress active`);
2238
// Keep hasActiveProgress as is, don't reset it
2239
} else {
2240
this.logService.trace(`No content was streamed, keeping hasActiveProgress as ${hasActiveProgress}`);
2241
}
2242
}
2243
2244
lastLogLength = logs.length;
2245
2246
if (!token.isCancellationRequested && sessionInfo.state === 'in_progress') {
2247
if (!hasActiveProgress) {
2248
this.logService.trace(`Showing progress indicator (hasActiveProgress was false)`);
2249
hasActiveProgress = true;
2250
} else {
2251
this.logService.trace(`NOT showing progress indicator (hasActiveProgress was true)`);
2252
}
2253
setTimeout(pollForUpdates, pollingInterval);
2254
} else {
2255
complete();
2256
}
2257
} catch (error) {
2258
this.logService.error(`Error polling for session updates: ${error}`);
2259
if (!token.isCancellationRequested) {
2260
setTimeout(pollForUpdates, pollingInterval);
2261
} else {
2262
reject(error);
2263
}
2264
}
2265
};
2266
2267
// Start polling
2268
setTimeout(pollForUpdates, pollingInterval);
2269
});
2270
}
2271
2272
private async streamNewLogContent(pullRequest: PullRequestSearchItem, stream: vscode.ChatResponseStream, newLogContent: string): Promise<{ hasStreamedContent: boolean; hasSetupStepProgress: boolean }> {
2273
try {
2274
if (!newLogContent.trim()) {
2275
return { hasStreamedContent: false, hasSetupStepProgress: false };
2276
}
2277
2278
// Parse the new log content
2279
const contentBuilder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService);
2280
const logChunks = parseSessionLogChunksSafely(newLogContent, this.logService, value => contentBuilder.parseSessionLogs(value));
2281
let hasStreamedContent = false;
2282
let hasSetupStepProgress = false;
2283
2284
for (const [chunkIndex, chunk] of logChunks.entries()) {
2285
if (!Array.isArray(chunk.choices)) {
2286
this.logService.warn(`[streamNewLogContent] Ignoring chunk ${chunkIndex} with non-array choices for PR #${pullRequest.number}.`);
2287
continue;
2288
}
2289
2290
for (const choice of chunk.choices) {
2291
if (!choice?.delta) {
2292
this.logService.warn(`[streamNewLogContent] Ignoring chunk ${chunkIndex} with missing delta for PR #${pullRequest.number}.`);
2293
continue;
2294
}
2295
2296
const delta = choice.delta;
2297
const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : undefined;
2298
if (delta.tool_calls && !toolCalls) {
2299
this.logService.warn(`[streamNewLogContent] Ignoring non-array tool_calls for PR #${pullRequest.number}.`);
2300
}
2301
2302
if (delta.role === 'assistant') {
2303
// Handle special case for run_custom_setup_step/run_setup
2304
if (choice.finish_reason === 'tool_calls' && toolCalls?.length && (toolCalls[0].function.name === 'run_custom_setup_step' || toolCalls[0].function.name === 'run_setup')) {
2305
const toolCall = toolCalls[0];
2306
let args: any = {};
2307
try {
2308
args = JSON.parse(toolCall.function.arguments);
2309
} catch {
2310
// fallback to empty args
2311
}
2312
2313
if (delta.content && delta.content.trim()) {
2314
// Finished setup step - create/update tool part
2315
const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content);
2316
if (toolPart) {
2317
stream.push(toolPart);
2318
hasStreamedContent = true;
2319
if (toolPart instanceof vscode.ChatResponseThinkingProgressPart) {
2320
stream.push(new vscode.ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));
2321
}
2322
}
2323
} else {
2324
// Running setup step - just track progress
2325
hasSetupStepProgress = true;
2326
this.logService.trace(`Setup step in progress: ${args.name || 'Unknown step'}`);
2327
}
2328
} else {
2329
if (delta.content) {
2330
if (!delta.content.startsWith('<pr_title>')) {
2331
stream.markdown(delta.content);
2332
hasStreamedContent = true;
2333
}
2334
}
2335
2336
if (toolCalls) {
2337
for (const toolCall of toolCalls) {
2338
const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, delta.content || '');
2339
if (toolPart) {
2340
stream.push(toolPart);
2341
hasStreamedContent = true;
2342
if (toolPart instanceof vscode.ChatResponseThinkingProgressPart) {
2343
stream.push(new vscode.ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));
2344
}
2345
}
2346
}
2347
}
2348
}
2349
}
2350
2351
// Handle finish reasons
2352
if (choice.finish_reason && choice.finish_reason !== 'null') {
2353
this.logService.trace(`Streaming finish_reason: ${choice.finish_reason}`);
2354
}
2355
}
2356
}
2357
2358
if (hasStreamedContent) {
2359
this.logService.trace(`Streamed content (markdown or tool parts), progress should be cleared`);
2360
} else if (hasSetupStepProgress) {
2361
this.logService.trace(`Setup step progress detected, keeping progress indicator`);
2362
} else {
2363
this.logService.trace(`No actual content streamed, progress may still be showing`);
2364
}
2365
return { hasStreamedContent, hasSetupStepProgress };
2366
} catch (error) {
2367
this.logService.error(`Error streaming new log content: ${error}`);
2368
return { hasStreamedContent: false, hasSetupStepProgress: false };
2369
}
2370
}
2371
2372
private async waitForQueuedToInProgress(
2373
sessionId: string,
2374
token?: vscode.CancellationToken
2375
): Promise<SessionInfo | undefined> {
2376
let sessionInfo: SessionInfo | undefined;
2377
2378
const waitForQueuedMaxRetries = 3;
2379
const waitForQueuedDelay = 5_000; // 5 seconds
2380
2381
// Allow for a short delay before the session is marked as 'queued'
2382
let waitForQueuedCount = 0;
2383
do {
2384
sessionInfo = await this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);
2385
if (sessionInfo && sessionInfo.state === 'queued') {
2386
this.logService.trace('Queued session found');
2387
break;
2388
}
2389
if (waitForQueuedCount < waitForQueuedMaxRetries) {
2390
this.logService.trace('Session not yet queued, waiting...');
2391
await new Promise(resolve => setTimeout(resolve, waitForQueuedDelay));
2392
}
2393
++waitForQueuedCount;
2394
} while (waitForQueuedCount <= waitForQueuedMaxRetries && (!token || !token.isCancellationRequested));
2395
2396
if (!sessionInfo || sessionInfo.state !== 'queued') {
2397
if (sessionInfo?.state === 'in_progress') {
2398
this.logService.trace('Session already in progress');
2399
this.refresh();
2400
return sessionInfo;
2401
}
2402
// Failure
2403
this.logService.trace('Failed to find queued session');
2404
return;
2405
}
2406
2407
const maxWaitTime = 2 * 60 * 1_000; // 2 minutes
2408
const pollInterval = 3_000; // 3 seconds
2409
const startTime = Date.now();
2410
2411
this.logService.trace(`Session ${sessionInfo.id} is queued, waiting for transition to in_progress...`);
2412
while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) {
2413
const sessionInfo = await this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);
2414
if (sessionInfo?.state === 'in_progress') {
2415
this.logService.trace(`Session ${sessionInfo.id} now in progress.`);
2416
this.refresh();
2417
return sessionInfo;
2418
}
2419
await new Promise(resolve => setTimeout(resolve, pollInterval));
2420
}
2421
this.logService.error(`Timed out waiting for session ${sessionId} to transition from queued to in_progress.`);
2422
}
2423
2424
private async waitForNewSession(
2425
pullRequest: PullRequestSearchItem,
2426
stream: vscode.ChatResponseStream,
2427
token: vscode.CancellationToken,
2428
waitForTransitionToInProgress: boolean = false
2429
): Promise<SessionInfo | undefined> {
2430
// Get the current number of sessions
2431
const initialSessions = await this._octoKitService.getCopilotSessionsForPR(pullRequest.fullDatabaseId.toString(), CLOUD_SESSIONS_AUTH_OPTIONS);
2432
const initialSessionCount = initialSessions.length;
2433
2434
// Poll for a new session to start
2435
const maxWaitTime = 5 * 60 * 1000; // 5 minutes
2436
const pollInterval = 3000; // 3 seconds
2437
const startTime = Date.now();
2438
2439
while (Date.now() - startTime < maxWaitTime && !token.isCancellationRequested) {
2440
const currentSessions = await this._octoKitService.getCopilotSessionsForPR(pullRequest.fullDatabaseId.toString(), CLOUD_SESSIONS_AUTH_OPTIONS);
2441
2442
// Check if a new session has started
2443
if (currentSessions.length > initialSessionCount) {
2444
const newSession = currentSessions
2445
.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];
2446
if (!waitForTransitionToInProgress) {
2447
return newSession;
2448
}
2449
const inProgressSession = await this.waitForQueuedToInProgress(newSession.id, token);
2450
if (!inProgressSession) {
2451
stream.markdown(vscode.l10n.t('Timed out waiting for cloud agent to begin work. Please try again shortly.'));
2452
return;
2453
}
2454
return inProgressSession;
2455
}
2456
2457
await new Promise(resolve => setTimeout(resolve, pollInterval));
2458
}
2459
2460
stream.markdown(vscode.l10n.t('Timed out waiting for the cloud agent to respond. The agent may still be processing your request.'));
2461
return;
2462
}
2463
2464
private async addFollowUpToExistingPR(pullRequestNumber: number, userPrompt: string, summary?: string, targetAgent = 'copilot'): Promise<string | undefined> {
2465
try {
2466
/* __GDPR__
2467
"copilotcloud.chat.followupComment" : {
2468
"owner": "joshspicer",
2469
"comment": "Event sent when a follow-up comment is delegated to an existing pull request.",
2470
"targetAgent": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The target @agent for the follow-up comment." }
2471
}
2472
*/
2473
this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.followupComment', {
2474
targetAgent,
2475
});
2476
2477
const pr = await this.findPR(pullRequestNumber);
2478
if (!pr) {
2479
this.logService.error(`Could not find pull request #${pullRequestNumber}`);
2480
return;
2481
}
2482
const commentBody = `@${targetAgent} ${userPrompt} ${summary ? '\n\n' + summary : ''}`;
2483
2484
const commentResult = await this._octoKitService.addPullRequestComment(pr.id, commentBody, CLOUD_SESSIONS_AUTH_OPTIONS);
2485
if (!commentResult) {
2486
this.logService.error(`Failed to add comment to PR #${pullRequestNumber}`);
2487
return;
2488
}
2489
// allow-any-unicode-next-line
2490
return vscode.l10n.t('🚀 Follow-up comment added to [#{0}]({1})', pullRequestNumber, commentResult.url);
2491
} catch (err) {
2492
this.logService.error(`Failed to add follow-up comment to PR #${pullRequestNumber}: ${err}`);
2493
return;
2494
}
2495
}
2496
2497
// https://github.com/github/sweagentd/blob/main/docs/adr/0001-create-job-api.md
2498
private validateRemoteAgentJobResponse(response: unknown): response is RemoteAgentJobResponse {
2499
return typeof response === 'object' && response !== null && 'job_id' in response && 'session_id' in response;
2500
}
2501
2502
private async waitForJobWithPullRequest(
2503
owner: string,
2504
repo: string,
2505
jobId: string,
2506
token?: vscode.CancellationToken
2507
): Promise<JobInfo | undefined> {
2508
const maxWaitTime = 30 * 1000; // 30 seconds
2509
const pollInterval = 2000; // 2 seconds
2510
const startTime = Date.now();
2511
2512
this.logService.trace(`Waiting for job ${jobId} to have pull request information...`);
2513
2514
while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) {
2515
const jobInfo = await this._octoKitService.getJobByJobId(owner, repo, jobId, 'vscode-copilot-chat', CLOUD_SESSIONS_AUTH_OPTIONS);
2516
if (jobInfo && jobInfo.pull_request && jobInfo.pull_request.number) {
2517
/* __GDPR__
2518
"copilotcloud.chat.remoteAgentJobPullRequestReady" : {
2519
"owner": "joshspicer",
2520
"comment": "Event sent when a remote agent job first returns pull request information."
2521
}
2522
*/
2523
this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.remoteAgentJobPullRequestReady');
2524
GenAiMetrics.incrementCloudPrReadyCount(this._otelService);
2525
this.logService.trace(`Job ${jobId} now has pull request #${jobInfo.pull_request.number}`);
2526
this.refresh();
2527
return jobInfo;
2528
}
2529
await new Promise(resolve => setTimeout(resolve, pollInterval));
2530
}
2531
2532
this.logService.warn(`Timed out waiting for job ${jobId} to have pull request information`);
2533
return undefined;
2534
}
2535
2536
private async invokeRemoteAgent(prompt: string, problemContext: string, token: vscode.CancellationToken, stream: vscode.ChatResponseStream, base_ref: string, head_ref?: string, customAgentName?: string, modelName?: string, partnerAgentName?: string, selectedRepository?: string): Promise<{ number: number; sessionId: string }> {
2537
const title = extractTitle(prompt, problemContext);
2538
const { problemStatement, isTruncated } = truncatePrompt(this.logService, prompt, problemContext);
2539
const repoIds = await getRepoId(this._gitService);
2540
2541
let repoOwner: string;
2542
let repoName: string;
2543
let repoHost: string = 'github.com';
2544
if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) {
2545
const [owner, repo] = selectedRepository.split('/');
2546
repoOwner = owner;
2547
repoName = repo;
2548
const matchingRepoId = repoIds?.find(id => id.org === owner && id.repo === repo);
2549
if (matchingRepoId) {
2550
repoHost = matchingRepoId.host;
2551
}
2552
} else {
2553
const repoId = repoIds?.[0];
2554
if (!repoId) {
2555
throw new Error(vscode.l10n.t('Unable to determine repository information. Please ensure you are working within a Git repository.'));
2556
}
2557
repoOwner = repoId.org;
2558
repoName = repoId.repo;
2559
repoHost = repoId.host;
2560
}
2561
2562
// Check if CCA is enabled before posting job
2563
const ccaEnabled = await this.checkCCAEnabled(repoOwner, repoName);
2564
if (ccaEnabled.enabled === false) {
2565
throw new Error(this.getCCADisabledMessage(ccaEnabled, repoHost));
2566
}
2567
2568
if (isTruncated) {
2569
stream.progress(vscode.l10n.t('Truncating context'));
2570
const truncationResult = await vscode.window.showWarningMessage(
2571
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);
2572
const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION;
2573
/* __GDPR__
2574
"copilot.codingAgent.truncation" : {
2575
"isCancelled" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
2576
}
2577
*/
2578
this.telemetry.sendTelemetryEvent('copilot.codingAgent.truncation', { microsoft: true, github: false }, {
2579
isCancelled: String(userCancelled),
2580
});
2581
if (userCancelled) {
2582
throw new Error(vscode.l10n.t('User cancelled due to truncation.'));
2583
}
2584
}
2585
2586
const resolvePartnerAgentName = (partnerAgentName?: string): { agent_id?: number } => {
2587
this.logService.trace(`Resolving partner agent from: ${partnerAgentName}`);
2588
if (!partnerAgentName || partnerAgentName === DEFAULT_PARTNER_AGENT_ID) {
2589
return {};
2590
}
2591
// try convert to number
2592
const partnerAgentIdNum = Number(partnerAgentName);
2593
if (isNaN(partnerAgentIdNum)) {
2594
this.logService.warn(`Invalid partner agent name/id provided: ${partnerAgentName}`);
2595
return {};
2596
}
2597
return { agent_id: partnerAgentIdNum };
2598
};
2599
2600
const payload: RemoteAgentJobPayload = {
2601
problem_statement: problemStatement,
2602
event_content: prompt,
2603
event_type: 'visual_studio_code_remote_agent_tool_invoked',
2604
...(customAgentName && customAgentName !== DEFAULT_CUSTOM_AGENT_ID && { custom_agent: customAgentName }),
2605
...(modelName && modelName !== DEFAULT_MODEL_ID && { model: modelName }),
2606
...(resolvePartnerAgentName(partnerAgentName)),
2607
pull_request: {
2608
title,
2609
body_placeholder: formatBodyPlaceholder(title),
2610
base_ref,
2611
body_suffix,
2612
...(head_ref && { head_ref }),
2613
}
2614
};
2615
2616
/* __GDPR__
2617
"copilotcloud.chat.remoteAgentJobInvoke" : {
2618
"owner": "joshspicer",
2619
"comment": "Event sent when a remote agent job invocation starts.",
2620
"hasHeadRef": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether a head ref was provided for delegation." }
2621
}
2622
*/
2623
this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.remoteAgentJobInvoke', {
2624
hasHeadRef: String(!!head_ref)
2625
});
2626
2627
stream?.progress(vscode.l10n.t('Delegating to cloud agent'));
2628
this.logService.debug(`[postCopilotAgentJob] Invoking cloud agent job with payload: ${JSON.stringify(payload)}`);
2629
const response = await this._octoKitService.postCopilotAgentJob(repoOwner, repoName, JOBS_API_VERSION, payload, CLOUD_SESSIONS_AUTH_OPTIONS);
2630
this.logService.debug(`[postCopilotAgentJob] Received response from cloud agent job invocation: ${JSON.stringify(response)}`);
2631
if (!this.validateRemoteAgentJobResponse(response)) {
2632
const statusCode = response?.status;
2633
switch (statusCode) {
2634
case 401:
2635
throw new Error(vscode.l10n.t('Cloud agent is not authorized to run on this repository. This may be because the Copilot coding agent is disabled for your organization, or your active GitHub account does not have push access to the target repository.'));
2636
case 403:
2637
throw new Error(vscode.l10n.t('Cloud agent is not enabled for this repository. You may need to enable it in [GitHub settings]({0}) or contact your organization administrator.', `https://${repoHost}/settings/copilot/coding_agent`));
2638
case 404:
2639
throw new Error(vscode.l10n.t('The repository `{0}/{1}` was not found or you do not have access to it.', repoOwner, repoName));
2640
case 422:
2641
// NOTE: Although earlier checks should prevent this, ensure that if we end up
2642
// with a 422 from the API, we give a useful error message
2643
throw new Error(vscode.l10n.t('Cloud agent was unable to create a pull request with the specified base branch `{0}`. Please push the branch to the remote and verify repository rules allow this operation. For empty repos, push an initial commit and try again.', base_ref));
2644
case 500:
2645
throw new Error(vscode.l10n.t('Cloud agent service encountered an internal error. Please try again later.'));
2646
default:
2647
throw new Error(vscode.l10n.t('Received invalid response {0} from cloud agent.', statusCode ? statusCode : ''));
2648
}
2649
}
2650
2651
stream.progress(vscode.l10n.t('Creating pull request'));
2652
const jobInfo = await this.waitForJobWithPullRequest(repoOwner, repoName, response.job_id, token);
2653
2654
if (!jobInfo || !jobInfo.pull_request) {
2655
throw new Error(vscode.l10n.t('Failed to retrieve pull request information from job'));
2656
}
2657
2658
const { number } = jobInfo.pull_request;
2659
if (!number || isNaN(number)) {
2660
throw new Error(vscode.l10n.t('Invalid pull request number received from cloud agent'));
2661
}
2662
return {
2663
number,
2664
sessionId: response.session_id
2665
};
2666
}
2667
}
2668
2669