Path: blob/main/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCloudSessionsProvider.ts
13399 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { RemoteAgentJobPayload } from '@vscode/copilot-api';6import * as pathLib from 'path';7import * as vscode from 'vscode';8import { l10n, Uri } from 'vscode';9import { IAuthenticationService } from '../../../platform/authentication/common/authentication';10import { IDomainService } from '../../../platform/endpoint/common/domainService';11import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext';12import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService';13import { FileType } from '../../../platform/filesystem/common/fileTypes';14import { IGitExtensionService } from '../../../platform/git/common/gitExtensionService';15import { GithubRepoId, IGitService } from '../../../platform/git/common/gitService';16import { derivePullRequestState, PullRequestSearchItem, SessionInfo } from '../../../platform/github/common/githubAPI';17import { AuthOptions, CCAEnabledResult, IGithubRepositoryService, IOctoKitService, JobInfo, RemoteAgentJobResponse } from '../../../platform/github/common/githubService';18import { ILogService } from '../../../platform/log/common/logService';19import { emitCloudSessionInvokeEvent } from '../../../platform/otel/common/genAiEvents';20import { GenAiMetrics } from '../../../platform/otel/common/genAiMetrics';21import { IOTelService } from '../../../platform/otel/common/otelService';22import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService';23import { ITelemetryService } from '../../../platform/telemetry/common/telemetry';24import { DeferredPromise, retry, RunOnceScheduler } from '../../../util/vs/base/common/async';25import { Event } from '../../../util/vs/base/common/event';26import { Disposable, DisposableStore, toDisposable } from '../../../util/vs/base/common/lifecycle';27import { ResourceMap } from '../../../util/vs/base/common/map';28import { joinPath } from '../../../util/vs/base/common/resources';29import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';30import { SingleSlotTtlCache, TtlCache } from '../common/ttlCache';31import { isUntitledSessionId } from '../common/utils';32import { IChatDelegationSummaryService } from '../copilotcli/common/delegationSummaryService';33import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils';34import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager';35import { ChatSessionContentBuilder, SessionResponseLogChunk } from './copilotCloudSessionContentBuilder';36import { IPullRequestFileChangesService } from './pullRequestFileChangesService';37import MarkdownIt = require('markdown-it');3839const CLOUD_SESSIONS_AUTH_OPTIONS: AuthOptions = { createIfNone: { detail: l10n.t('Sign in to GitHub to access Copilot cloud sessions.') } };4041interface ConfirmationMetadata {42prompt: string;43references?: readonly vscode.ChatPromptReference[];44chatContext: vscode.ChatContext;45}4647type InitialSessionOption = {48readonly optionId: string;49readonly value: string | vscode.ChatSessionProviderOptionItem;50};5152function validateMetadata(metadata: unknown): asserts metadata is ConfirmationMetadata {53if (typeof metadata !== 'object') {54throw new Error('Invalid confirmation metadata: not an object.');55}56if (metadata === null) {57throw new Error('Invalid confirmation metadata: null value.');58}59if (typeof (metadata as ConfirmationMetadata).prompt !== 'string') {60throw new Error('Invalid confirmation metadata: missing or invalid prompt.');61}62if (typeof (metadata as ConfirmationMetadata).chatContext !== 'object' || (metadata as ConfirmationMetadata).chatContext === null) {63throw new Error('Invalid confirmation metadata: missing or invalid chatContext.');64}65}6667function describeRuntimeValue(value: unknown): string {68if (Array.isArray(value)) {69return `array(length=${value.length})`;70}7172if (value === null) {73return 'null';74}7576if (value === undefined) {77return 'undefined';78}7980if (typeof value === 'object') {81const keys = Object.keys(value);82return `object(keys=${keys.slice(0, 5).join(',')}${keys.length > 5 ? ',…' : ''})`;83}8485return typeof value;86}8788function isOptionItemValue(value: unknown): value is vscode.ChatSessionProviderOptionItem {89return typeof value === 'object' && value !== null && 'id' in value && typeof value.id === 'string';90}9192function isInitialSessionOption(value: unknown): value is InitialSessionOption {93if (typeof value !== 'object' || value === null || !('optionId' in value) || typeof value.optionId !== 'string' || !('value' in value)) {94return false;95}9697return typeof value.value === 'string' || isOptionItemValue(value.value);98}99100export function normalizeInitialSessionOptions(initialOptions: unknown, logService?: ILogService, chatResource?: vscode.Uri): readonly InitialSessionOption[] {101if (!initialOptions) {102return [];103}104105if (Array.isArray(initialOptions)) {106const normalized = initialOptions.filter(isInitialSessionOption);107if (logService && normalized.length !== initialOptions.length) {108logService.warn(`[chatParticipantImpl] Ignoring ${initialOptions.length - normalized.length} malformed initialSessionOptions entries for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)}.`);109}110111return normalized;112}113114if (typeof initialOptions === 'object') {115const normalized: InitialSessionOption[] = [];116for (const [optionId, value] of Object.entries(initialOptions)) {117if (isInitialSessionOption(value)) {118normalized.push(value);119} else if (typeof value === 'string' || isOptionItemValue(value)) {120normalized.push({ optionId, value });121}122}123124if (normalized.length > 0) {125logService?.warn(`[chatParticipantImpl] Coerced object-shaped initialSessionOptions for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)} and recovered ${normalized.length} entries.`);126return normalized;127}128}129130logService?.warn(`[chatParticipantImpl] Ignoring unsupported initialSessionOptions for ${chatResource?.toString() ?? 'unknown-resource'}. Received ${describeRuntimeValue(initialOptions)}.`);131return [];132}133134export function parseSessionLogChunksSafely(rawText: string, logService: ILogService, parser: (value: string) => SessionResponseLogChunk[]): SessionResponseLogChunk[] {135try {136return parser(rawText);137} catch (error) {138logService.error(error instanceof Error ? error : new Error(String(error)), `[streamNewLogContent] Failed to parse streamed log content (${rawText.length} chars).`);139return [];140}141}142143const CUSTOM_AGENTS_OPTION_GROUP_ID = 'customAgents';144const MODELS_OPTION_GROUP_ID = 'models';145const PARTNER_AGENTS_OPTION_GROUP_ID = 'partnerAgents';146const REPOSITORIES_OPTION_GROUP_ID = 'repositories';147148const DEFAULT_CUSTOM_AGENT_ID = '___vscode_default___';149const DEFAULT_MODEL_ID = 'auto';150const DEFAULT_PARTNER_AGENT_ID = '___vscode_partner_agent_default___';151const DEFAULT_REPOSITORY_ID = '___vscode_repository_default___';152153const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds154const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore';155const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository';156const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches';157const USER_SELECTED_REPOS_KEY = 'userSelectedRepositories';158const USER_SELECTED_REPOS_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 1 week159160// TTL for caching /enabled responses when CCA is enabled161const CCA_ENABLED_CACHE_TTL_MS = 30 * 60 * 1_000; // 30 minutes162// Shorter TTL for caching /enabled responses when CCA is disabled or undetermined,163// so users aren't stuck but we don't hammer the endpoint on every options query164const CCA_DISABLED_CACHE_TTL_MS = 5 * 60 * 1_000; // 5 minutes165// Status codes that are expected/handled by isCCAEnabled; anything else is unexpected166const CCA_KNOWN_STATUS_CODES = new Set([401, 403, 422]);167// TTL for caching session provider options (custom agents, models, partner agents, etc.)168const OPTIONS_CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes169170interface UserSelectedRepository {171name: string;172timestamp: number;173}174175// TODO: No API from GH yet.176const HARDCODED_PARTNER_AGENTS: { id: string; name: string; at?: string; assignableActorLogin?: string; codiconId?: string }[] = [177{ id: DEFAULT_PARTNER_AGENT_ID, name: 'Copilot', assignableActorLogin: 'copilot-swe-agent', codiconId: 'copilot' },178{ id: '2246796', name: 'Claude', at: 'claude[agent]', assignableActorLogin: 'anthropic-code-agent', codiconId: 'claude' },179{ id: '2248422', name: 'Codex', at: 'codex[agent]', assignableActorLogin: 'openai-code-agent', codiconId: 'openai' }180];181182/**183* Custom renderer for markdown-it that converts markdown to plain text184*/185class PlainTextRenderer {186private md: MarkdownIt;187188constructor() {189this.md = new MarkdownIt();190}191192/**193* Renders markdown text as plain text by extracting text content from all tokens194*/195render(markdown: string): string {196const tokens = this.md.parse(markdown, {});197return this.renderTokens(tokens).trim();198}199200private renderTokens(tokens: MarkdownIt.Token[]): string {201let result = '';202for (const token of tokens) {203// Process child tokens recursively204if (token.children) {205result += this.renderTokens(token.children);206}207208// Handle different token types209switch (token.type) {210case 'text':211case 'code_inline':212// Only add content if no children were processed213if (!token.children) {214result += token.content;215}216break;217218case 'softbreak':219case 'hardbreak':220result += ' '; // Space instead of newline to match original221break;222223case 'paragraph_close':224result += '\n'; // Newline after paragraphs for separation225break;226227case 'heading_close':228result += '\n'; // Newline after headings229break;230231case 'list_item_close':232result += '\n'; // Newline after list items233break;234235case 'fence':236case 'code_block':237case 'hr':238// Skip these entirely239break;240241// Don't add default case - only explicitly handle what we want242}243}244return result;245}246}247248export class CopilotCloudSessionsProvider extends Disposable implements vscode.ChatSessionContentProvider, vscode.ChatSessionItemProvider {249public static readonly TYPE = 'copilot-cloud-agent';250private readonly _onDidChangeChatSessionItems = this._register(new vscode.EventEmitter<void>());251public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event;252private readonly _onDidCommitChatSessionItem = this._register(new vscode.EventEmitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>());253public readonly onDidCommitChatSessionItem = this._onDidCommitChatSessionItem.event;254private readonly _onDidChangeChatSessionProviderOptions = this._register(new vscode.EventEmitter<void>());255public readonly onDidChangeChatSessionProviderOptions = this._onDidChangeChatSessionProviderOptions.event;256private readonly _onDidChangeChatSessionOptions = this._register(new vscode.EventEmitter<vscode.ChatSessionOptionChangeEvent>());257public readonly onDidChangeChatSessionOptions = this._onDidChangeChatSessionOptions.event;258private chatSessions: Map<number, PullRequestSearchItem> = new Map();259private chatSessionItemsPromise: Promise<vscode.ChatSessionItem[]> | undefined;260private readonly sessionCustomAgentMap = new ResourceMap<string>();261private readonly sessionModelMap = new ResourceMap<string>();262private readonly sessionPartnerAgentMap = new ResourceMap<string>();263private readonly sessionRepositoryMap = new ResourceMap<string>();264private readonly sessionReferencesMap = new ResourceMap<readonly vscode.ChatPromptReference[]>();265public chatParticipant = vscode.chat.createChatParticipant(CopilotCloudSessionsProvider.TYPE, async (request, context, stream, token) => {266await this.chatParticipantImpl(request, context, stream, token);267});268private cachedSessionsSize: number = 0;269// Cache for provideChatSessionItems270private cachedSessionItems: (vscode.ChatSessionItem & {271fullDatabaseId: string;272pullRequestDetails: PullRequestSearchItem;273})[] | undefined;274private activeSessionIds: Set<string> = new Set();275private activeSessionPollingInterval: ReturnType<typeof setInterval> | undefined;276private readonly plainTextRenderer = new PlainTextRenderer();277private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService);278279// TTL cache for CCA enabled status per repository (key: "owner/repo")280// enabled=true cached for 30 min; disabled/undetermined cached for 5 min to reduce traffic281private _ccaEnabledCache = new TtlCache<CCAEnabledResult>(CCA_ENABLED_CACHE_TTL_MS);282283// Single-slot TTL cache for the full session provider options result (custom agents, models, partner agents, etc.)284// Caches the most recently computed options regardless of repo/workspace context285private _optionsCache = new SingleSlotTtlCache<vscode.ChatSessionProviderOptions>(OPTIONS_CACHE_TTL_MS);286287// Title288private TITLE = vscode.l10n.t('Delegate to cloud agent');289290// Buttons (used for matching, be careful changing!)291private readonly AUTHORIZE = vscode.l10n.t('Authorize');292private readonly COMMIT = vscode.l10n.t('Commit Changes');293private readonly PUSH_BRANCH = vscode.l10n.t('Push Branch');294private readonly DELEGATE = vscode.l10n.t('Delegate');295private readonly CANCEL = vscode.l10n.t('Cancel');296297// Messages298private 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.');299private readonly AUTHORIZE_MESSAGE = vscode.l10n.t('Cloud agent requires elevated GitHub access to proceed.');300private readonly COMMIT_MESSAGE = vscode.l10n.t('This workspace has uncommitted changes. Should these changes be pushed and included in cloud agent\'s work?');301private 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);302303// Workspace storage keys304private readonly WORKSPACE_CONTEXT_PREFIX = 'copilot.cloudAgent';305306constructor(307@IOctoKitService private readonly _octoKitService: IOctoKitService,308@IGitService private readonly _gitService: IGitService,309@ITelemetryService private readonly telemetry: ITelemetryService,310@ILogService private readonly logService: ILogService,311@IGitExtensionService private readonly _gitExtensionService: IGitExtensionService,312@IPullRequestFileChangesService private readonly _prFileChangesService: IPullRequestFileChangesService,313@IAuthenticationService private readonly _authenticationService: IAuthenticationService,314@IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext,315@IInstantiationService instantiationService: IInstantiationService,316@IGithubRepositoryService private readonly _githubRepositoryService: IGithubRepositoryService,317@IChatDelegationSummaryService private readonly _chatDelegationSummaryService: IChatDelegationSummaryService,318@IExperimentationService private readonly _experimentationService: IExperimentationService,319@IDomainService private readonly _domainService: IDomainService,320@IOTelService private readonly _otelService: IOTelService,321@IFileSystemService private readonly _fileSystemService: IFileSystemService,322) {323super();324this.registerCommands();325326// Refresh when CAPI URL changes (e.g., when GHE Copilot token arrives and updates the base URL)327this._register(this._domainService.onDidChangeDomains(e => {328if (e.capiUrlChanged) {329this.logService.debug('copilotCloudSessionsProvider: CAPI URL changed, refreshing sessions');330this.clearOptionsCaches();331this.refresh();332this._onDidChangeChatSessionProviderOptions.fire();333}334}));335336// Background refresh for Copilot cloud agent sessions based on repository and authentication state337getRepoId(this._gitService).then(async repoIds => {338const telemetryObj: {339intervalMs?: number;340hasHistoricalSessions?: boolean;341error?: string;342isEmptyWindow: boolean;343} = {344isEmptyWindow: !vscode.workspace.workspaceFolders || vscode.workspace.workspaceFolders.length === 0345};346if (repoIds && repoIds.length > 0) {347let intervalMs: number;348let hasHistoricalSessions: boolean;349try {350const sessions = await Promise.all(repoIds.map(repoId => this._octoKitService.getAllSessions(`${repoId.org}/${repoId.repo}`, false, {})));351hasHistoricalSessions = sessions.some(s => s.length > 0);352intervalMs = this.getRefreshIntervalTime(hasHistoricalSessions);353} catch (e) {354this.logService.error(`Error during background refresh setup: ${e instanceof Error ? e.message : String(e)}`);355hasHistoricalSessions = false;356intervalMs = this.getRefreshIntervalTime(hasHistoricalSessions);357telemetryObj.error = e instanceof Error ? e.message : String(e);358}359telemetryObj.intervalMs = intervalMs;360telemetryObj.hasHistoricalSessions = hasHistoricalSessions;361const schedulerCallback = async () => {362let sessions = [];363try {364sessions = await Promise.all(repoIds.map(repoId => this._octoKitService.getAllSessions(`${repoId.org}/${repoId.repo}`, true, {})));365sessions = sessions.flat();366if (this.cachedSessionsSize !== sessions.length) {367this.refresh();368}369} catch (e) {370logService.error(`Error during background refresh: ${e}`);371}372scheduler.schedule();373};374let lastRefreshedAt = 0;375const scheduler = this._register(new RunOnceScheduler(() => {376lastRefreshedAt = Date.now();377schedulerCallback();378}, intervalMs));379scheduler.schedule();380this._register(vscode.window.onDidChangeWindowState((e) => {381if (!e.active) {382scheduler.cancel();383} else if (!scheduler.isScheduled()) {384scheduler.schedule(Math.max(0, intervalMs - (Date.now() - lastRefreshedAt)));385}386}));387388}389const onDebouncedAuthRefresh = Event.debounce(this._authenticationService.onDidAuthenticationChange, () => { }, 500);390this._register(onDebouncedAuthRefresh(() => {391this.clearOptionsCaches();392this.refresh();393}));394this.telemetry.sendTelemetryEvent('copilotCloudSessions.refreshInterval', { microsoft: true, github: false }, telemetryObj);395});396}397398private registerCommands() {399const executePullRequestActionWithExtensionInstall = async (400sessionItemOrResource: vscode.ChatSessionItem | vscode.Uri | number | undefined,401options: {402actionLabel: string;403noRepoErrorMessage: string;404installPromptMessage: string;405executeAction: (repoId: { org: string; repo: string }, pullRequestNumber: number) => Promise<void>;406}407): Promise<void> => {408let pullRequestNumber: number | undefined;409if (typeof sessionItemOrResource === 'number') {410pullRequestNumber = sessionItemOrResource;411} else {412const resource = sessionItemOrResource instanceof vscode.Uri413? sessionItemOrResource414: sessionItemOrResource?.resource;415if (!resource) {416return;417}418pullRequestNumber = SessionIdForPr.parsePullRequestNumber(resource);419}420421422if (!pullRequestNumber) {423return;424}425const repoIds = await getRepoId(this._gitService);426if (!repoIds || repoIds.length === 0) {427vscode.window.showErrorMessage(options.noRepoErrorMessage);428return;429}430431const extensionId = 'github.vscode-pull-request-github';432const isExtensionInstalled = vscode.extensions.getExtension(extensionId) !== undefined;433434if (!isExtensionInstalled) {435const result = await vscode.window.showInformationMessage(436options.installPromptMessage,437{ modal: true },438options.actionLabel439);440441if (result !== options.actionLabel) {442return;443}444445await vscode.commands.executeCommand('workbench.extensions.installExtension', extensionId, { enable: true });446}447448await options.executeAction(repoIds[0], pullRequestNumber);449};450451const checkoutPullRequestReroute = (sessionItemOrResource?: vscode.ChatSessionItem | vscode.Uri) =>452executePullRequestActionWithExtensionInstall(sessionItemOrResource, {453actionLabel: l10n.t('Install and Checkout'),454noRepoErrorMessage: l10n.t('No active repository found to checkout pull request.'),455installPromptMessage: l10n.t('The GitHub Pull Requests extension is required to checkout this PR. Would you like to install and checkout?'),456executeAction: async (repoId, pullRequestNumber) => {457await vscode.commands.executeCommand('pr.checkoutFromDescription', { owner: repoId.org, repo: repoId.repo, number: pullRequestNumber });458},459});460this._register(vscode.commands.registerCommand('github.copilot.chat.checkoutPullRequestReroute', checkoutPullRequestReroute));461462const openPullRequestReroute = (sessionItemOrResource?: vscode.ChatSessionItem | number | vscode.Uri) =>463executePullRequestActionWithExtensionInstall(sessionItemOrResource, {464actionLabel: l10n.t('Install and Open'),465noRepoErrorMessage: l10n.t('No active repository found to open pull request.'),466installPromptMessage: l10n.t('The GitHub Pull Requests extension is required to open this PR. Would you like to install and open?'),467executeAction: async (repoId, pullRequestNumber) => {468await vscode.commands.executeCommand('pr.openDescription', {469pullRequestDetails: {470number: pullRequestNumber,471repository: {472owner: {473login: repoId.org,474},475name: repoId.repo,476},477},478});479},480});481this._register(vscode.commands.registerCommand('github.copilot.chat.openPullRequestReroute', openPullRequestReroute));482483// Command for browsing repositories in the repository picker484const openRepositoryCommand = async (sessionItemResource?: vscode.Uri): Promise<string | undefined> => {485const quickPick = vscode.window.createQuickPick();486const quickPickDisposables = new DisposableStore();487quickPick.placeholder = l10n.t('Search for a repository...');488quickPick.matchOnDescription = true;489quickPick.matchOnDetail = true;490quickPick.busy = true;491quickPick.show();492493// Load initial repositories494try {495const repos = await this.fetchAllRepositoriesFromGitHub();496quickPick.items = repos.map(repo => ({ label: repo.name }));497} catch (error) {498this.logService.error(`Error fetching initial repositories: ${error}`);499} finally {500quickPick.busy = false;501}502503// Handle dynamic search504let searchTimeout: ReturnType<typeof setTimeout> | undefined;505506return new Promise<string | undefined>(resolve => {507let resolved = false;508const doResolve = (value: string | undefined) => {509if (!resolved) {510resolved = true;511resolve(value);512}513};514515quickPickDisposables.add(quickPick.onDidChangeValue(async (value) => {516if (searchTimeout) {517clearTimeout(searchTimeout);518}519searchTimeout = setTimeout(async () => {520quickPick.busy = true;521try {522const searchResults = await this.fetchAllRepositoriesFromGitHub(value);523quickPick.items = searchResults.map(repo => ({ label: repo.name }));524} finally {525quickPick.busy = false;526}527}, 300);528}));529530quickPickDisposables.add(quickPick.onDidAccept(() => {531const selected = quickPick.selectedItems[0];532if (selected && sessionItemResource) {533this.sessionRepositoryMap.set(sessionItemResource, selected.label);534// Save user-selected repo so it appears in the recent repos list535this.saveUserSelectedRepository(selected.label);536this._onDidChangeChatSessionOptions.fire({537resource: sessionItemResource,538updates: [{539optionId: REPOSITORIES_OPTION_GROUP_ID,540value: { id: selected.label, name: selected.label, icon: new vscode.ThemeIcon('repo') }541}]542});543}544doResolve(selected?.label);545quickPick.hide();546}));547548quickPickDisposables.add(quickPick.onDidHide(() => {549if (searchTimeout) {550clearTimeout(searchTimeout);551}552quickPickDisposables.dispose();553quickPick.dispose();554doResolve(undefined);555}));556});557};558this._register(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, openRepositoryCommand));559560this._register(vscode.commands.registerCommand(CLEAR_CACHES_COMMAND_ID, () => {561this.logService.debug('copilotCloudSessionsProvider#clearCaches: clearing all cloud agent caches');562this.clearOptionsCaches();563this.refresh();564this._onDidChangeChatSessionProviderOptions.fire();565}));566}567568private getRefreshIntervalTime(hasHistoricalSessions: boolean): number {569// Check for experiment overrides570const expRefreshInterval = this._experimentationService.getTreatmentVariable<number>('copilotCloudSessions.refreshInterval');571if (expRefreshInterval !== undefined) {572return expRefreshInterval;573}574575// Default intervals576const fiveMinInterval = 5 * 60 * 1000; // 5 minutes577const tenMinInterval = 10 * 60 * 1000; // 10 minutes578if (hasHistoricalSessions) {579return fiveMinInterval;580} else {581return tenMinInterval;582}583}584585public refresh(): void {586this.cachedSessionItems = undefined;587this.chatSessionItemsPromise = undefined;588this.activeSessionIds.clear();589this.stopActiveSessionPolling();590// Note: _ccaEnabledCache and _optionsCache are TTL-based and NOT cleared on refresh.591// Use clearOptionsCaches() to force-clear them (e.g. on auth change).592this._onDidChangeChatSessionItems.fire();593}594595/**596* Force-clears the TTL-based caches for /enabled and session provider options.597* Use for auth changes or explicit user-initiated refresh where stale data is unacceptable.598*/599private clearOptionsCaches(): void {600this._ccaEnabledCache.clear();601this._optionsCache.clear();602}603604/**605* Checks if the Copilot cloud agent is enabled for a repository.606* Results are cached with a TTL: enabled=true results are cached for {@link CCA_ENABLED_CACHE_TTL_MS},607* while disabled/undetermined results are cached for a shorter {@link CCA_DISABLED_CACHE_TTL_MS}608* to balance responsiveness with reducing endpoint traffic.609* @param owner Repository owner610* @param repo Repository name611* @returns CCAEnabledResult with enabled status and optional status code612*/613private async checkCCAEnabled(owner: string, repo: string): Promise<CCAEnabledResult> {614const cacheKey = `${owner}/${repo}`;615616const cached = this._ccaEnabledCache.get(cacheKey);617if (cached !== undefined) {618this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: using cached CCA enabled status for ${owner}/${repo}: ${cached.enabled}`);619return cached;620}621622const result = await this._octoKitService.isCCAEnabled(owner, repo, {});623624// Cache all results: enabled=true uses the default 30 min TTL,625// disabled/undetermined uses a shorter 5 min TTL so users who just626// enabled CCA aren't stuck for too long627if (result.enabled === true) {628this._ccaEnabledCache.set(cacheKey, result);629} else {630this._ccaEnabledCache.set(cacheKey, result, CCA_DISABLED_CACHE_TTL_MS);631}632633this.telemetry.sendTelemetryEvent('copilot.codingAgent.CCAIsEnabledCheck', { microsoft: true, github: false }, {634enabled: String(result.enabled),635statusCode: String(result.statusCode ?? 'none'),636cacheHit: 'false',637});638639// Track unexpected status codes (429 rate-limit, 5xx, etc.) as errors so they surface in dashboards640if (result.statusCode !== undefined && !CCA_KNOWN_STATUS_CODES.has(result.statusCode)) {641/* __GDPR__642"copilot.codingAgent.CCAIsEnabledUnexpectedStatus" : {643"owner": "joshspicer",644"comment": "Fired when the /enabled endpoint returns an unexpected HTTP status code (e.g. 429 rate-limit or 5xx).",645"statusCode": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "The unexpected HTTP status code returned by the /enabled endpoint." },646"isRateLimited": { "classification": "SystemMetaData", "purpose": "PerformanceAndHealth", "comment": "True if the status code is 429 (rate limited)." }647}648*/649this.telemetry.sendTelemetryErrorEvent('copilot.codingAgent.CCAIsEnabledUnexpectedStatus', { microsoft: true, github: false }, {650statusCode: String(result.statusCode),651isRateLimited: String(result.statusCode === 429),652});653}654655this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: fetched CCA enabled status for ${owner}/${repo}: ${result.enabled}`);656return result;657}658659/**660* Gets user-friendly error message for disabled CCA status.661* @param result The CCAEnabledResult to get message for662* @returns User-friendly error message663*/664private getCCADisabledMessage(result: CCAEnabledResult, host: string = 'github.com'): string {665if (result.statusCode === 422) {666return vscode.l10n.t('Cloud agent is unable to create pull requests in this repository. Please verify repository rules allow this operation.');667}668if (result.statusCode === 401) {669return 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.');670}671// Default to 403 'disabled' message672const settingsUrl = `https://${host}/settings/copilot/coding_agent`;673return 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);674}675676private stopActiveSessionPolling(): void {677if (this.activeSessionPollingInterval) {678clearInterval(this.activeSessionPollingInterval);679this.activeSessionPollingInterval = undefined;680}681}682683private startActiveSessionPolling(): void {684// Don't start if already polling685if (this.activeSessionPollingInterval) {686return;687}688689this.activeSessionPollingInterval = setInterval(async () => {690await this.updateActiveSessionsOnly();691}, ACTIVE_SESSION_POLL_INTERVAL_MS);692693// Register for disposal694this._register(toDisposable(() => this.stopActiveSessionPolling()));695}696697private async updateActiveSessionsOnly(): Promise<void> {698if (this.activeSessionIds.size === 0) {699this.stopActiveSessionPolling();700return;701}702703try {704// Fetch only the active sessions using allSettled to handle individual failures705const sessionResults = await Promise.allSettled(706Array.from(this.activeSessionIds).map(sessionId =>707this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS)708)709);710711const stillActiveSessions = new Set<string>();712713for (const result of sessionResults) {714if (result.status === 'rejected') {715this.logService.warn(`Failed to fetch session info: ${result.reason}`);716continue;717}718719const session = result.value;720if (!session) {721continue;722}723this.cachedSessionItems = this.cachedSessionItems?.map(item => {724if (item.fullDatabaseId === session.resource_global_id) {725return {726...item,727status: this.getSessionStatusFromSession(session),728};729}730return item;731});732733if (session.state === 'in_progress' || session.state === 'queued') {734stillActiveSessions.add(session.id);735}736}737738// Update the active sessions set739this.activeSessionIds = stillActiveSessions;740741// If there are changes or no more active sessions, invalidate cache and notify742if (this.activeSessionIds.size === 0) {743this.cachedSessionItems = undefined;744this.stopActiveSessionPolling();745}746this._onDidChangeChatSessionItems.fire();747} catch (error) {748this.logService.error(`Error updating active sessions: ${error}`);749}750}751752/**753* Queries for available partner agents by checking if known CCA logins are assignable in the repository.754* TODO: Remove once given a proper API755*/756private async getAvailablePartnerAgents(owner: string, repo: string): Promise<{ id: string; name: string; at?: string; codiconId?: string }[]> {757try {758// Fetch assignable actors for the repository759const assignableActors = await this._octoKitService.getAssignableActors(owner, repo, {});760761// Check which agents from HARDCODED_PARTNER_AGENTS are assignable762const availableAgents: { id: string; name: string; at?: string; codiconId?: string }[] = [];763764for (const agent of HARDCODED_PARTNER_AGENTS) {765const { assignableActorLogin } = agent;766let isAssignable = false;767768if (assignableActorLogin !== undefined) {769isAssignable = assignableActors.some(actor => actor.login === assignableActorLogin);770}771if (isAssignable) {772availableAgents.push(agent);773}774}775776return availableAgents;777} catch (error) {778this.logService.error(`Error fetching partner agents: ${error}`);779return [];780}781}782783/**784* Scans local .github/agents/ directory and categorizes agent files.785* Returns two groups:786* - matches: local files that correlate with remote agents (name exists in both)787* - localOnly: local files that don't have a corresponding remote agent788*/789private async getLocalCustomAgentFiles(remoteAgents: { name: string }[]): Promise<{790matches: Set<string>;791localOnly: { name: string; path: string }[];792}> {793const matches = new Set<string>();794const localOnly: { name: string; path: string }[] = [];795const remoteAgentNames = new Set(remoteAgents.map(a => a.name.toLowerCase()));796797const workspaceFolders = vscode.workspace.workspaceFolders;798if (!workspaceFolders || workspaceFolders.length === 0) {799return { matches, localOnly };800}801802// Only check the first workspace folder (consistent with how we query GitHub for custom agents)803// TODO: Expand to multi-root workspaces, etc...804const folder = workspaceFolders[0];805try {806// Find all .md files in .github/agents/ using the file system service807const agentsDir = joinPath(folder.uri, '.github/agents');808const entries = await this._fileSystemService.readDirectory(agentsDir);809810for (const [name, type] of entries) {811// Only process .md files812if (!(type & FileType.File) || !name.toLowerCase().endsWith('.md')) {813continue;814}815816// Extract agent name from filename (e.g., "my-agent.md" -> "my-agent" or "myagent.agent.md" -> "myagent")817const agentName = name.replace(/\.agent\.md$/i, '').replace(/\.md$/i, '');818819if (!agentName) {820continue;821}822823const fileUri = joinPath(agentsDir, name);824if (remoteAgentNames.has(agentName.toLowerCase())) {825// This local file matches a remote agent826matches.add(agentName.toLowerCase());827} else {828// This local file has no corresponding remote agent829localOnly.push({830name: agentName,831path: vscode.workspace.asRelativePath(fileUri)832});833}834}835} catch (error) {836if (error instanceof vscode.FileSystemError && error.code === 'FileNotFound') {837return { matches, localOnly };838}839this.logService.warn(`Error scanning for local agents in ${folder.uri.toString()}: ${error}`);840}841842return { matches, localOnly };843}844845async provideChatSessionProviderOptions(token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptions> {846this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions Start');847848const repoIds = await getRepoId(this._gitService);849const repoId = repoIds?.[0];850851const workspaceFolders = vscode.workspace.workspaceFolders;852const isSingleRepoWorkspace = workspaceFolders?.length === 1 && repoIds?.length === 1;853let ccaEnabledResult: { enabled?: boolean; statusCode?: number } | undefined;854let isCcaEnabled = true;855if (isSingleRepoWorkspace && repoId) {856ccaEnabledResult = await this.checkCCAEnabled(repoId.org, repoId.repo);857isCcaEnabled = ccaEnabledResult.enabled !== false;858}859if (!isCcaEnabled && repoId) {860this.logService.trace(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: CCA disabled for ${repoId.org}/${repoId.repo}, statusCode: ${ccaEnabledResult?.statusCode}`);861// Return empty options to disable the feature in the UI862return { optionGroups: [] };863}864865// Check TTL-based options cache866const optionsCacheKey = repoIds && repoIds.length > 0867? repoIds.map(r => `${r.org}/${r.repo}`).sort().join(',')868: '';869const cachedOptions = this._optionsCache.get(optionsCacheKey);870if (cachedOptions) {871this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions: using cached options');872return cachedOptions;873}874875const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];876try {877// Fetch agents (requires repo), models (global), and partner agents in parallel878const [customAgents, models, partnerAgents] = await Promise.allSettled([879repoId && repoIds?.length === 1 ? this._octoKitService.getCustomAgents(repoId.org, repoId.repo, { excludeInvalidConfig: true }, {}) : Promise.resolve([]),880this._octoKitService.getCopilotAgentModels({}),881repoId ? this.getAvailablePartnerAgents(repoId.org, repoId.repo) : Promise.resolve([])882]);883884try {885const items = await this.getRepositoriesOptionItems(repoIds);886if (items.length !== 1) {887optionGroups.push({888id: REPOSITORIES_OPTION_GROUP_ID,889name: vscode.l10n.t('Repository'),890description: vscode.l10n.t('Select repository'),891icon: new vscode.ThemeIcon('repo'),892items,893commands: [{894command: OPEN_REPOSITORY_COMMAND_ID,895title: vscode.l10n.t('Browse repositories...'),896}]897});898}899900} catch (error) {901this.logService.error(`Error fetching repositories: ${error}`);902}903904// Partner agents905// Only show if repo provides a choice of agent (>1)906if (partnerAgents.status === 'fulfilled' && partnerAgents.value.length > 1) {907const partnerAgentItems: vscode.ChatSessionProviderOptionItem[] = partnerAgents.value.map(agent => ({908id: agent.id,909name: agent.name,910...(agent.id === DEFAULT_PARTNER_AGENT_ID && { default: true }),911icon: agent.codiconId ? new vscode.ThemeIcon(agent.codiconId) : undefined912}));913optionGroups.push({914id: PARTNER_AGENTS_OPTION_GROUP_ID,915name: vscode.l10n.t('Partner Agents'),916description: vscode.l10n.t('Select which partner agent to use'),917items: partnerAgentItems,918});919}920921// Find local agent files and categorize them922const { matches, localOnly } = await this.getLocalCustomAgentFiles(923customAgents.status === 'fulfilled' ? customAgents.value : []924);925926if ((customAgents.status === 'fulfilled' && customAgents.value.length > 0) || (repoIds?.length === 1 && localOnly.length > 0)) {927const agentItems: vscode.ChatSessionProviderOptionItem[] = [928{929id: DEFAULT_CUSTOM_AGENT_ID,930default: true,931name: vscode.l10n.t('Agent'),932icon: new vscode.ThemeIcon('agent')933},934...(customAgents.status === 'fulfilled' ? customAgents.value.map(agent => ({935id: agent.name,936name: agent.display_name || agent.name,937...(matches.has(agent.name.toLowerCase()) && { description: `${agent.name}.md` })938})) : []),939// Add local-only agents as disabled items with "push to remote" hint940...localOnly.map(localAgent => ({941id: localAgent.name,942name: localAgent.name,943description: vscode.l10n.t('Missing from {0}', repoId ? `${repoId.org}/${repoId.repo}` : 'remote repository'),944locked: true,945icon: new vscode.ThemeIcon('warning')946}) satisfies vscode.ChatSessionProviderOptionItem)947];948optionGroups.push({949id: CUSTOM_AGENTS_OPTION_GROUP_ID,950name: vscode.l10n.t('Custom Agents'),951description: vscode.l10n.t('Select which custom agent to use'),952items: agentItems,953when: `!chatSessionOption.partnerAgents || chatSessionOption.partnerAgents == ${DEFAULT_PARTNER_AGENT_ID}`954});955}956957if (models.status === 'fulfilled' && models.value.length > 0) {958const modelItems: vscode.ChatSessionProviderOptionItem[] = models.value.map(model => ({959id: model.id,960name: model.name,961...(model.billing?.multiplier !== undefined ? { description: `${model.billing.multiplier}x` } : {}),962}));963if (!models.value.find(m => m.id === DEFAULT_MODEL_ID)) {964modelItems.unshift({ id: DEFAULT_MODEL_ID, name: vscode.l10n.t('Auto'), description: vscode.l10n.t('Automatically select the best model') });965}966optionGroups.push({967id: MODELS_OPTION_GROUP_ID,968name: vscode.l10n.t('Model'),969description: vscode.l10n.t('Select which model to use'),970items: modelItems,971when: `!chatSessionOption.partnerAgents || chatSessionOption.partnerAgents == ${DEFAULT_PARTNER_AGENT_ID}`972});973}974975const result: vscode.ChatSessionProviderOptions = { optionGroups };976977// Cache the full options result with TTL978this._optionsCache.set(optionsCacheKey, result);979980this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: Returning options: ${JSON.stringify(optionGroups, undefined, 2)}`);981return result;982} catch (error) {983this.logService.error(`[copilotCloudSessionsProvider#provideChatSessionProviderOptions] Error fetching options: ${error}`);984return { optionGroups: [] };985}986}987988private async getRepositoriesOptionItems(repoIds?: GithubRepoId[], fetchAll: boolean = false): Promise<vscode.ChatSessionProviderOptionItem[]> {989const items: vscode.ChatSessionProviderOptionItem[] = [];990if (!fetchAll) {991if (repoIds && repoIds.length > 0) {992repoIds.forEach((repoId, index) => {993items.push({994id: `${repoId.org}/${repoId.repo}`,995name: `${repoId.org}/${repoId.repo}`,996default: index === 0,997icon: new vscode.ThemeIcon('repo'),998});999});1000} else {1001// Fetch repos from recent push events (repos user has recently committed to)1002try {1003const recentlyCommittedRepos = await this._octoKitService.getRecentlyCommittedRepositories({});1004for (const repo of recentlyCommittedRepos) {1005const nwo = `${repo.owner}/${repo.name}`;1006items.push({1007id: nwo,1008name: nwo,1009icon: new vscode.ThemeIcon('repo'),1010});1011}1012} catch (error) {1013this.logService.trace(`Failed to fetch recently committed repos: ${error}`);1014}10151016// Add user-selected repos that aren't already in the list1017const userSelectedRepos = this.getUserSelectedRepositories();1018const existingIds = new Set(items.map(item => item.id));1019for (const repo of userSelectedRepos) {1020if (!existingIds.has(repo.name)) {1021items.push({1022id: repo.name,1023name: repo.name,1024icon: new vscode.ThemeIcon('repo'),1025});1026}1027}1028}1029} else {1030const fetchedItems = await this.fetchAllRepositoriesFromGitHub();1031items.push(...fetchedItems);1032}1033return items;1034}10351036private async fetchAllRepositoriesFromGitHub(query?: string): Promise<vscode.ChatSessionProviderOptionItem[]> {1037try {1038// Fetch repos user has access to, optionally filtered by search query1039const repos = await this._octoKitService.getUserRepositories({}, query);10401041// Sort alphabetically and convert to option items1042return repos1043.map(repo => ({ id: `${repo.owner}/${repo.name}`, name: `${repo.owner}/${repo.name}` }))1044.sort((a, b) => a.name.localeCompare(b.name));1045} catch (error) {1046this.logService.error(`Error fetching repositories from GitHub: ${error}`);1047return [];1048}1049}10501051provideHandleOptionsChange(resource: Uri, updates: ReadonlyArray<vscode.ChatSessionOptionUpdate>, token: vscode.CancellationToken): void {1052for (const update of updates) {1053if (update.optionId === CUSTOM_AGENTS_OPTION_GROUP_ID) {1054if (update.value) {1055this.sessionCustomAgentMap.set(resource, update.value);1056this.logService.info(`Custom agent changed for session ${resource}: ${update.value}`);1057} else {1058this.sessionCustomAgentMap.delete(resource);1059this.logService.info(`Custom agent cleared for session ${resource}`);1060}1061} else if (update.optionId === MODELS_OPTION_GROUP_ID) {1062if (update.value) {1063this.sessionModelMap.set(resource, update.value);1064this.logService.info(`Model changed for session ${resource}: ${update.value}`);1065} else {1066this.sessionModelMap.delete(resource);1067this.logService.info(`Model cleared for session ${resource}`);1068}1069} else if (update.optionId === PARTNER_AGENTS_OPTION_GROUP_ID) {1070if (update.value) {1071this.sessionPartnerAgentMap.set(resource, update.value);1072this.logService.info(`Partner agent changed for session ${resource}: ${update.value}`);1073} else {1074this.sessionPartnerAgentMap.delete(resource);1075this.logService.info(`Partner agent cleared for session ${resource}`);1076}1077} else if (update.optionId === REPOSITORIES_OPTION_GROUP_ID) {1078if (update.value) {1079this.sessionRepositoryMap.set(resource, update.value);1080// Refresh timestamp for user-selected repos when selected from the picker1081this.saveUserSelectedRepository(update.value);1082this.logService.info(`Repository changed for session ${resource}: ${update.value}`);1083} else {1084this.sessionRepositoryMap.delete(resource);1085this.logService.info(`Repository cleared for session ${resource}`);1086}1087}1088}1089}10901091async provideChatSessionItems(token: vscode.CancellationToken): Promise<vscode.ChatSessionItem[]> {1092// Return cached items if available1093if (this.cachedSessionItems) {1094return this.cachedSessionItems;1095}10961097if (this.chatSessionItemsPromise) {1098return this.chatSessionItemsPromise;1099}1100this.chatSessionItemsPromise = (async () => {1101const repoIds = await getRepoId(this._gitService);1102this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: repoIds=${JSON.stringify(repoIds?.map(r => ({ org: r.org, repo: r.repo, host: r.host })))}, isAgentSessionsWorkspace=${vscode.workspace.isAgentSessionsWorkspace}`);1103// Make sure if it's not a github repo we don't show any sessions1104// (unless we're in an agent sessions workspace)1105if (!vscode.workspace.isAgentSessionsWorkspace && !this.isGitHubRepoOrEmpty(repoIds)) {1106this.logService.debug('copilotCloudSessionsProvider#provideChatSessionItems: not a GitHub repo, returning empty');1107return [];1108}1109let sessions = [];1110if (vscode.workspace.isAgentSessionsWorkspace || !repoIds || repoIds.length === 0) {1111sessions = await this._octoKitService.getAllSessions(undefined, true, {});1112} else {1113sessions = (await Promise.all(repoIds.map(repo => this._octoKitService.getAllSessions(`${repo.org}/${repo.repo}`, true, {})))).flat();1114}1115this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: fetched ${sessions.length} sessions`);1116this.cachedSessionsSize = sessions.length;11171118// Group sessions by resource_id and keep only the latest per resource_id1119const latestSessionsMap = new Map<number, SessionInfo>();1120for (const session of sessions) {1121const existing = latestSessionsMap.get(session.resource_id);1122if (!existing || this.shouldPushSession(session, existing)) {1123latestSessionsMap.set(session.resource_id, session);1124}1125}11261127// Track active sessions for background polling1128const newActiveSessionIds = new Set<string>();1129for (const session of latestSessionsMap.values()) {1130if (session.state === 'in_progress' || session.state === 'queued') {1131newActiveSessionIds.add(session.id);1132}1133}11341135// Update active sessions and start polling if needed1136this.activeSessionIds = newActiveSessionIds;1137if (this.activeSessionIds.size > 0) {1138this.startActiveSessionPolling();1139} else {1140this.stopActiveSessionPolling();1141}11421143// Fetch PRs for all unique resource_global_ids in parallel1144const uniqueGlobalIds = new Set(Array.from(latestSessionsMap.values()).map(s => s.resource_global_id));1145const prFetches = Array.from(uniqueGlobalIds).map(async globalId => {1146try {1147const pr = await this._octoKitService.getPullRequestFromGlobalId(globalId, {});1148return { globalId, pr };1149} catch (e) {1150this.logService.warn(`Failed to fetch PR for global ID ${globalId}: ${e instanceof Error ? e.message : String(e)}`);1151return { globalId, pr: null };1152}1153});1154const prResults = await Promise.all(prFetches);1155const prMap = new Map(prResults.filter(r => r.pr).map(r => [r.globalId, r.pr!]));1156this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: resolved ${prMap.size}/${uniqueGlobalIds.size} PRs from global IDs`);11571158const validateISOTimestamp = (date: string | undefined): number | undefined => {1159try {1160if (!date) {1161return;1162}1163const time = new Date(date)?.getTime();1164if (time > 0) {1165return time;1166}1167} catch { }1168};11691170// Create session items from latest sessions1171const sessionItems = await Promise.all(Array.from(latestSessionsMap.values()).map(async sessionItem => {1172const pr = prMap.get(sessionItem.resource_global_id);1173if (!pr) {1174return undefined;1175}11761177const multiDiffPart = await this._prFileChangesService.getFileChangesMultiDiffPart(pr);1178const changes = multiDiffPart?.value?.map(change => new vscode.ChatSessionChangedFile(1179change.goToFileUri!,1180change.originalUri,1181change.modifiedUri,1182change.added ?? 0,1183change.removed ?? 0));11841185const metadata = {1186name: pr.repository?.name,1187owner: pr.repository?.owner?.login,1188branch: pr.headRefName,1189baseBranch: pr.baseRefName,1190pullRequestUrl: pr.url,1191pullRequestState: derivePullRequestState(pr),1192} satisfies { readonly [key: string]: unknown };11931194const createdAt = validateISOTimestamp(sessionItem.created_at);1195const session = {1196resource: vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + pr.number }),1197label: pr.title,1198status: this.getSessionStatusFromSession(sessionItem),1199badge: this.getPullRequestBadge(repoIds, pr),1200tooltip: this.createPullRequestTooltip(pr),1201...(createdAt ? {1202timing: {1203created: createdAt,1204startTime: createdAt,1205endTime: validateISOTimestamp(sessionItem.completed_at),1206}1207} : {}),1208changes,1209metadata,1210fullDatabaseId: pr.fullDatabaseId.toString(),1211pullRequestDetails: pr1212} satisfies vscode.ChatSessionItem & {1213fullDatabaseId: string;1214pullRequestDetails: PullRequestSearchItem;1215};1216this.chatSessions.set(pr.number, pr);1217return session;1218}));1219const filteredSessions = sessionItems1220// Remove any undefined sessions1221.filter(item => item !== undefined)1222// Only keep sessions with attached PRs not CLOSED or MERGED1223.filter(item => {1224const pr = item.pullRequestDetails;1225const state = pr.state.toUpperCase();1226return state !== 'CLOSED' && state !== 'MERGED';1227});12281229vscode.commands.executeCommand('setContext', 'github.copilot.chat.cloudSessionsEmpty', filteredSessions.length === 0);1230this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionItems: returning ${filteredSessions.length} sessions (${sessionItems.length - filteredSessions.length} filtered out)`);12311232// Cache the results1233this.cachedSessionItems = filteredSessions;12341235return filteredSessions;1236})().finally(() => {1237this.chatSessionItemsPromise = undefined;1238});1239return this.chatSessionItemsPromise;1240}12411242private isGitHubRepoOrEmpty(repoIds: GithubRepoId[] | undefined) {1243const hasOpenedFolder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders.length > 0;1244if (!hasOpenedFolder) {1245return true;1246}1247const hasGitHubRepo = repoIds && repoIds.length > 0;1248return hasGitHubRepo;1249}12501251private shouldPushSession(sessionItem: SessionInfo, existing: SessionInfo | undefined): boolean {1252if (!existing) {1253return true;1254}1255const existingDate = new Date(existing.last_updated_at);1256const newDate = new Date(sessionItem.last_updated_at);1257return newDate > existingDate;1258}12591260async provideChatSessionContent(resource: Uri, token: vscode.CancellationToken): Promise<vscode.ChatSession> {1261const indexedSessionId = SessionIdForPr.parse(resource);1262let pullRequestNumber: number | undefined;1263if (indexedSessionId) {1264pullRequestNumber = indexedSessionId.prNumber;1265}1266if (typeof pullRequestNumber === 'undefined') {1267pullRequestNumber = SessionIdForPr.parsePullRequestNumber(resource);1268if (isNaN(pullRequestNumber)) {1269this.logService.error(`Invalid pull request number: ${resource}`);1270return this.createEmptySession(resource);1271}1272}12731274const pr = await this.findPR(pullRequestNumber);1275const summaryReference = new DeferredPromise<vscode.ChatPromptReference | undefined>();1276const getProblemStatement = async (repoOwner: string, repoName: string, sessions: SessionInfo[]) => {1277if (sessions.length === 0) {1278summaryReference.complete(undefined);1279return undefined;1280}1281if (!repoOwner || !repoName) {1282summaryReference.complete(undefined);1283return undefined;1284}1285const jobInfo = await this._octoKitService.getJobBySessionId(repoOwner, repoName, sessions[0].id, 'vscode-copilot-chat', CLOUD_SESSIONS_AUTH_OPTIONS);1286let prompt = jobInfo?.problem_statement || 'Initial Implementation';1287// When delegating, we append the summary to the prompt, & that can be very large and doesn't look great.1288// Turn the summary into a reference instead.1289const info = this._chatDelegationSummaryService.extractPrompt(sessions[0].id, prompt);1290if (info) {1291summaryReference.complete(info.reference);1292prompt = info.prompt;1293} else {1294summaryReference.complete(undefined);1295}1296const titleMatch = prompt.match(/TITLE: \s*(.*)/i);1297if (titleMatch && titleMatch[1]) {1298prompt = titleMatch[1].trim();1299} else {1300const split = prompt.split('\n');1301if (split.length > 0) {1302prompt = split[0].trim();1303}1304}1305return prompt.replace(/@copilot\s*/gi, '').trim();1306};1307if (!pr) {1308this.logService.error(`Session not found for ID: ${resource}`);1309return this.createEmptySession(resource);1310}13111312const resolvePartnerAgent = (sessions: SessionInfo[]): { id: string; name: string; at?: string | undefined } | undefined => {1313const getDefault = () => {1314return HARDCODED_PARTNER_AGENTS.find(agent => agent.id === DEFAULT_PARTNER_AGENT_ID) ?? undefined;1315};1316const agentId = sessions.find(s => s.agent_id)?.agent_id;1317if (!agentId) {1318return getDefault();1319}1320// See if this matches any of the known partner agents1321// TODO: Currently hardcoded, no API from GitHub.1322const match = HARDCODED_PARTNER_AGENTS.find(agent => Number(agent.id) === agentId);1323return match ?? getDefault();1324};13251326const sessions = await this._octoKitService.getCopilotSessionsForPR(pr.fullDatabaseId.toString(), CLOUD_SESSIONS_AUTH_OPTIONS);1327const sortedSessions = sessions1328.filter((session, index, array) =>1329array.findIndex(s => s.id === session.id) === index1330)1331.slice().sort((a, b) =>1332new Date(a.created_at).getTime() - new Date(b.created_at).getTime()1333);13341335// Get stored references for this session1336const storedReferences = summaryReference.p.then(summaryRef => {1337return (this.sessionReferencesMap.get(resource) ?? []).concat(summaryRef ? [summaryRef] : []);1338});13391340const sessionContentBuilder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService);1341const 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);13421343// const selectedCustomAgent = undefined; /* TODO: Needs API to support this. */1344// const selectedModel = undefined; /* TODO: Needs API to support this. */13451346const partnerAgent = resolvePartnerAgent(sortedSessions);1347if (partnerAgent) {1348this.sessionPartnerAgentMap.set(resource, partnerAgent.id);1349}13501351return {1352history,1353options: {1354// ...(selectedCustomAgent && { [CUSTOM_AGENTS_OPTION_GROUP_ID]: { id: selectedCustomAgent, locked: true, name: selectedCustomAgent } }),1355// ...(selectedModel && { [MODELS_OPTION_GROUP_ID]: { id: selectedModel, locked: true, name: selectedModel } }),1356...(partnerAgent && { [PARTNER_AGENTS_OPTION_GROUP_ID]: { id: partnerAgent.id, locked: true, name: partnerAgent.name } }),1357},1358activeResponseCallback: this.findActiveResponseCallback(sessions, pr),1359requestHandler: undefined1360};1361}13621363async openSessionInBrowser(chatSessionItem: vscode.ChatSessionItem): Promise<void> {1364const session = SessionIdForPr.parse(chatSessionItem.resource);1365let prNumber = session?.prNumber;1366if (typeof prNumber === 'undefined' || isNaN(prNumber)) {1367prNumber = SessionIdForPr.parsePullRequestNumber(chatSessionItem.resource);1368if (isNaN(prNumber)) {1369vscode.window.showErrorMessage(vscode.l10n.t('Invalid pull request number: {0}', '' + chatSessionItem.resource));1370this.logService.error(`Invalid pull request number: ${chatSessionItem.resource}`);1371return;1372}1373}13741375const pr = await this.findPR(prNumber);1376if (!pr) {1377vscode.window.showErrorMessage(vscode.l10n.t('Could not find pull request #{0}', prNumber));1378this.logService.error(`Could not find pull request #${prNumber}`);1379return;1380}13811382await vscode.env.openExternal(vscode.Uri.parse(pr.url));1383}13841385private findActiveResponseCallback(1386sessions: SessionInfo[],1387pr: PullRequestSearchItem1388): ((stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable<void>) | undefined {1389// Only the latest in-progress session gets activeResponseCallback1390const pendingSession = sessions1391.slice()1392.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())1393.find(session => session.state === 'in_progress' || session.state === 'queued');13941395if (pendingSession) {1396return this.createActiveResponseCallback(pr, pendingSession.id);1397}1398return undefined;1399}14001401private createActiveResponseCallback(pr: PullRequestSearchItem, sessionId: string): (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => Thenable<void> {1402return async (stream: vscode.ChatResponseStream, token: vscode.CancellationToken) => {1403await this.waitForQueuedToInProgress(sessionId, token);1404return this.streamSessionLogs(stream, pr, sessionId, token);1405};1406}14071408private createEmptySession(resource: Uri): vscode.ChatSession {1409const sessionId = resource ? resource.path.slice(1) : undefined;1410return {1411history: [],1412...(sessionId && isUntitledSessionId(sessionId)1413? {1414options: {1415[CUSTOM_AGENTS_OPTION_GROUP_ID]:1416this.sessionCustomAgentMap.get(resource)1417?? (this.sessionCustomAgentMap.set(resource, DEFAULT_CUSTOM_AGENT_ID), DEFAULT_CUSTOM_AGENT_ID),1418[MODELS_OPTION_GROUP_ID]:1419this.sessionModelMap.get(resource)1420?? (this.sessionModelMap.set(resource, DEFAULT_MODEL_ID), DEFAULT_MODEL_ID),1421[PARTNER_AGENTS_OPTION_GROUP_ID]:1422this.sessionPartnerAgentMap.get(resource)1423?? (this.sessionPartnerAgentMap.set(resource, DEFAULT_PARTNER_AGENT_ID), DEFAULT_PARTNER_AGENT_ID),1424[REPOSITORIES_OPTION_GROUP_ID]:1425this.sessionRepositoryMap.get(resource)1426?? (this.sessionRepositoryMap.set(resource, DEFAULT_REPOSITORY_ID), DEFAULT_REPOSITORY_ID)1427}1428}1429: {}),1430requestHandler: undefined1431};1432}14331434private async findPR(prNumber: number, options: { retries?: number; repository?: string } = {}) {1435const { retries = 1, repository } = options;1436let pr = this.chatSessions.get(prNumber);1437if (pr) {1438return pr;1439}1440let repoOwner: string;1441let repoName: string;1442if (repository && repository !== DEFAULT_REPOSITORY_ID) {1443const [owner, name] = repository.split('/');1444repoOwner = owner;1445repoName = name;1446} else {1447const repoIds = await getRepoId(this._gitService);1448const repoId = repoIds?.[0];1449if (!repoId) {1450this.logService.warn('Failed to determine GitHub repo from workspace');1451return undefined;1452}1453repoOwner = repoId.org;1454repoName = repoId.repo;1455}1456try {1457pr = await retry(async () => {1458const pullRequests = await this._octoKitService.getOpenPullRequestsForUser(repoOwner, repoName, CLOUD_SESSIONS_AUTH_OPTIONS);1459const found = pullRequests.find(p => p.number === prNumber);1460if (!found) {1461this.logService.warn(`Pull request ${prNumber} is not visible yet, retrying...`);1462throw new Error(`PR ${prNumber} not yet visible`);1463}1464return found;1465}, 1500, retries);1466if (pr) {1467this.chatSessions.set(pr.number, pr);1468}1469return pr;1470} catch (error) {1471this.logService.warn(`Pull request not found for number: ${prNumber}. ${error instanceof Error ? error.message : String(error)}`);1472return undefined;1473}1474}14751476private getSessionStatusFromSession(session: SessionInfo): vscode.ChatSessionStatus {1477// Map session state to ChatSessionStatus1478switch (session.state) {1479case 'failed':1480return vscode.ChatSessionStatus.Failed;1481case 'in_progress':1482case 'queued':1483return vscode.ChatSessionStatus.InProgress;1484case 'completed':1485return vscode.ChatSessionStatus.Completed;1486default:1487return vscode.ChatSessionStatus.Completed;1488}1489}14901491private getPullRequestBadge(repoIds: GithubRepoId[] | undefined, pr: PullRequestSearchItem): vscode.MarkdownString | undefined {1492if (1493vscode.workspace.workspaceFolders === undefined || // empty window1494vscode.workspace.isAgentSessionsWorkspace || // agent sessions workspace1495(repoIds && repoIds.length > 1) // multiple repositories1496) {1497const badgeLabel = `${pr.repository.owner.login}/${pr.repository.name}`;1498const badge = new vscode.MarkdownString(`$(repo) ${badgeLabel}`, true);1499badge.supportThemeIcons = true;1500return badge;1501}15021503return undefined;1504}15051506private createPullRequestTooltip(pr: PullRequestSearchItem): vscode.MarkdownString {1507const markdown = new vscode.MarkdownString(undefined, true);1508markdown.supportHtml = true;15091510// Repository and date1511const date = new Date(pr.createdAt);1512const ownerName = `${pr.repository.owner.login}/${pr.repository.name}`;1513// Derive repo URL from the PR URL to support both github.com and GHE1514const repoUrl = pr.url.replace(/\/pull\/\d+$/, '');1515markdown.appendMarkdown(1516`[${ownerName}](${repoUrl}) on ${date.toLocaleString('default', {1517day: 'numeric',1518month: 'short',1519year: 'numeric',1520})} \n`1521);15221523// Icon, title, and PR number1524const icon = this.getIconMarkdown(pr);1525// Strip markdown from title for plain text display1526const title = this.plainTextRenderer.render(pr.title);1527markdown.appendMarkdown(1528`${icon} **${title}** [#${pr.number}](${pr.url}) \n`1529);15301531// Body/Description (truncated if too long)1532markdown.appendMarkdown(' \n');1533const maxBodyLength = 200;1534let body = this.plainTextRenderer.render(pr.body || '');1535// Convert plain text newlines to markdown line breaks (two spaces + newline)1536body = body.replace(/\n/g, ' \n');1537body = body.length > maxBodyLength ? body.substring(0, maxBodyLength) + '...' : body;1538markdown.appendMarkdown(body + ' \n');15391540return markdown;1541}15421543private getIconMarkdown(pr: PullRequestSearchItem): string {1544const state = pr.state.toUpperCase();1545return state === 'MERGED' ? '$(git-merge)' : '$(git-pull-request)';1546}15471548private hasHistoryToSummarize(history: readonly (vscode.ChatRequestTurn | vscode.ChatResponseTurn)[]): boolean {1549if (!history || history.length === 0) {1550return false;1551}1552const allResponsesEmpty = history.every(turn => {1553if (turn instanceof vscode.ChatResponseTurn) {1554return turn.response.length === 0;1555}1556return true;1557});1558return !allResponsesEmpty;1559}15601561async delegate(1562request: vscode.ChatRequest,1563stream: vscode.ChatResponseStream,1564context: vscode.ChatContext,1565token: vscode.CancellationToken,1566metadata: ConfirmationMetadata,1567base_ref?: string,1568head_ref?: string1569): Promise<vscode.ChatResponsePullRequestPart> {15701571let history: string | undefined;15721573// TODO: Do this async/optimistically before delegation triggered1574if (this.hasHistoryToSummarize(context.history)) {1575stream.progress(vscode.l10n.t('Analyzing chat history'));1576history = await this._chatDelegationSummaryService.summarize(context, token);1577}15781579// Get the chat resource from context or metadata1580const chatResource = context.chatSessionContext?.chatSessionItem?.resource1581?? metadata.chatContext.chatSessionContext?.chatSessionItem?.resource;15821583let customAgentName: string | undefined;1584let modelName: string | undefined;1585let partnerAgentName: string | undefined;1586let selectedRepository: string | undefined;1587if (chatResource) {1588this.logService.trace(`[delegate] Looking up options for chatResource=${chatResource.toString()}, partnerAgentMap.size=${this.sessionPartnerAgentMap.size}`);1589customAgentName = this.sessionCustomAgentMap.get(chatResource);1590modelName = this.sessionModelMap.get(chatResource);1591partnerAgentName = this.sessionPartnerAgentMap.get(chatResource);1592selectedRepository = this.sessionRepositoryMap.get(chatResource);1593this.logService.trace(`[delegate] Retrieved options for ${chatResource.toString()}: customAgent=${customAgentName}, model=${modelName}, partnerAgent=${partnerAgentName}`);1594} else {1595this.logService.trace(`[delegate] No chatResource available to retrieve session options`);1596}15971598const { result, processedReferences } = await this.extractReferences(metadata.references, !!head_ref);15991600const repoIds = await getRepoId(this._gitService);1601const repoId = repoIds?.[0];1602let repoOwner = repoId?.org;1603let repoName = repoId?.repo;1604const [selectedRepoOwner, selectedRepoName] = (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) ? selectedRepository.split('/') : [];1605if (!base_ref || repoOwner !== selectedRepoOwner || repoName !== selectedRepoName) {1606if (selectedRepoOwner && selectedRepoName) {1607repoOwner = selectedRepoOwner;1608repoName = selectedRepoName;1609} else {1610if (!repoId) {1611throw new Error(vscode.l10n.t('Open a GitHub repository to use the cloud agent.'));1612}1613repoOwner = repoId.org;1614repoName = repoId.repo;1615}1616const { default_branch } = await this._githubRepositoryService.getRepositoryInfo(repoOwner, repoName);1617base_ref = default_branch;1618}16191620const { number, sessionId } = await this.invokeRemoteAgent(1621metadata.prompt,1622[result, history].filter(Boolean).join('\n\n').trim(),1623token,1624stream,1625base_ref,1626head_ref,1627customAgentName,1628modelName,1629partnerAgentName,1630selectedRepository1631);1632if (history) {1633void this._chatDelegationSummaryService.trackSummaryUsage(sessionId, history);1634}1635this.logService.debug(`Delegated to cloud agent for PR #${number} with session ID ${sessionId}`);16361637// Store references for this session1638const sessionUri = vscode.Uri.from({ scheme: CopilotCloudSessionsProvider.TYPE, path: '/' + number });16391640// Cache the processed references for presentation later1641if (processedReferences.length > 0) {1642this.sessionReferencesMap.set(sessionUri, processedReferences);1643}16441645stream.progress(vscode.l10n.t('Fetching pull request details'));1646const pullRequest = await this.findPR(number, { retries: 7, repository: selectedRepository });1647if (!pullRequest) {1648throw new Error(`Failed to find pull request #${number} after delegation.`);1649}1650const uri = await toOpenPullRequestWebviewUri({ owner: pullRequest.repository.owner.login, repo: pullRequest.repository.name, pullRequestNumber: pullRequest.number });16511652if (metadata.chatContext.chatSessionContext?.isUntitled) {1653// Untitled flow1654this._onDidCommitChatSessionItem.fire({1655original: metadata.chatContext.chatSessionContext.chatSessionItem,1656modified: {1657resource: sessionUri,1658label: `Pull Request ${number}`1659}1660});1661} else {1662// Delegated flow1663// NOTE: VS Code will now close the parent/source chat in most cases.1664stream.markdown(vscode.l10n.t('A cloud agent has begun working on your request. Follow its progress in the sessions list and associated pull request.'));1665}16661667// Return this for external callers, eg: CLI1668return {1669uri, // PR uri,1670command: {1671title: vscode.l10n.t('View Pull Request #{0}', pullRequest.number),1672command: 'github.copilot.chat.openPullRequestReroute',1673arguments: [pullRequest.number]1674},1675title: pullRequest.title,1676description: pullRequest.body || '',1677author: getAuthorDisplayName(pullRequest.author),1678linkTag: `#${pullRequest.number}`1679};1680}16811682private async handleConfirmationData(request: vscode.ChatRequest, stream: vscode.ChatResponseStream, context: vscode.ChatContext, token: vscode.CancellationToken) {1683if (!request.prompt || request.prompt.indexOf(':') === -1) {1684this.logService.error('Invalid confirmation prompt format.');1685return {};1686}16871688// Parse out the button selected by the user1689const selection = (request.prompt?.split(':')[0] || '').trim().toUpperCase();1690const metadata: unknown = request.acceptedConfirmationData?.[0]?.metadata || request.rejectedConfirmationData?.[0]?.metadata;1691try {1692validateMetadata(metadata);1693} catch (error) {1694this.logService.error(`Invalid confirmation metadata: ${error}`);1695return {};1696}16971698// -- Process each button press in order of precedence16991700if (!selection || selection === this.CANCEL.toUpperCase() || token.isCancellationRequested) {1701/* __GDPR__1702"copilotcloud.chat.confirmationCancelled" : {1703"owner": "joshspicer",1704"comment": "Event sent when the cloud chat confirmation flow is cancelled.",1705"tokenCancelled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether the cancellation token was already cancelled." }1706}1707*/1708this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.confirmationCancelled', {1709tokenCancelled: String(token.isCancellationRequested)1710});1711stream.markdown(vscode.l10n.t('Cloud agent cancelled'));1712return {};1713}17141715if (selection.includes(this.AUTHORIZE.toUpperCase())) {1716stream.progress(vscode.l10n.t('Authorizing'));1717try {1718await this._authenticationService.getGitHubSession('permissive', { createIfNone: { detail: l10n.t('Sign in to GitHub with additional permissions to use Copilot cloud sessions.') } });1719if (!this._authenticationService.permissiveGitHubSession) {1720throw new Error('Failed to obtain permissive GitHub session');1721}1722} catch (error) {1723this.logService.error(`Authorization failed: ${error}`);1724throw new Error(vscode.l10n.t('Authorization failed. Please sign into GitHub and try again.'));17251726}1727}17281729let head_ref: string | undefined; // If set, this is the branch we pushed pending changes to.17301731if (selection.includes(this.COMMIT.toUpperCase())) {1732try {1733stream.progress(vscode.l10n.t('Committing and pushing local changes'));1734head_ref = await this.gitOperationsManager.commitAndPushChanges();1735stream.markdown(vscode.l10n.t('Local changes pushed to remote branch `{0}`.', head_ref));1736} catch (error) {1737this.logService.error(`Commit and push failed: ${error}`);1738throw 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.'));1739}1740} else if (selection.includes(this.PUSH_BRANCH.toUpperCase())) {1741try {1742stream.progress(vscode.l10n.t('Pushing base branch to remote'));1743const baseBranch = await this.gitOperationsManager.pushBaseRefToRemote();1744stream.markdown(vscode.l10n.t('Base branch `{0}` pushed to remote.', baseBranch));1745} catch (error) {1746this.logService.error(`Push branch failed: ${error}`);1747throw 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.'));1748}1749}17501751// Get the selected repository from the chat context for multiroot workspace support1752const chatResource = metadata.chatContext.chatSessionContext?.chatSessionItem?.resource;1753const selectedRepository = chatResource ? this.sessionRepositoryMap.get(chatResource) : undefined;17541755const base_ref: string = await (async () => {1756const res = await this.checkBaseBranchPresentOnRemote(selectedRepository);1757if (!res) {1758// Unexpected1759throw new Error(vscode.l10n.t('Repo base branch is not detected on remote. Push your branch and try again.'));1760}1761return (res?.missingOnRemote || !res?.baseRef) ? res.repoDefaultBranch : res?.baseRef;1762})();1763stream.progress(vscode.l10n.t('Validating branch base branch exists on remote'));17641765// Now trigger delegation1766try {1767await this.delegate(request, stream, context, token, metadata, base_ref, head_ref);1768} catch (error) {1769this.logService.error(`Failure in delegation: ${error}`);1770throw new Error(vscode.l10n.t('{0}', (error instanceof Error ? error.message : String(error))));1771}1772}17731774private setWorkspaceContext(key: string, value: string) {1775this._extensionContext.workspaceState.update(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`, value);1776}17771778private getWorkspaceContext(key: string): string | undefined {1779return this._extensionContext.workspaceState.get<string>(`${this.WORKSPACE_CONTEXT_PREFIX}.${key}`);1780}17811782resetWorkspaceContext() {1783const keys =1784this._extensionContext.workspaceState.keys()1785.filter(key => key.startsWith(this.WORKSPACE_CONTEXT_PREFIX));1786for (const key of keys) {1787this.logService.debug(`[resetWorkspaceContext] ${key}`);1788this._extensionContext.workspaceState.update(key, undefined);1789}1790}17911792/**1793* Saves a user-selected repository to global state with current timestamp.1794* If the repo already exists, the timestamp is refreshed.1795*/1796private saveUserSelectedRepository(repoName: string): void {1797const repos = this.getUserSelectedRepositories();1798const existingIndex = repos.findIndex(r => r.name === repoName);1799if (existingIndex >= 0) {1800repos[existingIndex].timestamp = Date.now();1801} else {1802repos.push({ name: repoName, timestamp: Date.now() });1803}1804this._extensionContext.globalState.update(USER_SELECTED_REPOS_KEY, repos);1805this._onDidChangeChatSessionProviderOptions.fire();1806}18071808/**1809* Gets user-selected repositories, filtering out expired entries (older than 1 week).1810* Expired entries are automatically cleaned up.1811*/1812private getUserSelectedRepositories(): UserSelectedRepository[] {1813const repos = this._extensionContext.globalState.get<UserSelectedRepository[]>(USER_SELECTED_REPOS_KEY, []);1814const now = Date.now();1815const validRepos = repos.filter(r => (now - r.timestamp) < USER_SELECTED_REPOS_EXPIRY_MS);18161817// Clean up expired repos if any were filtered out1818if (validRepos.length !== repos.length) {1819this._extensionContext.globalState.update(USER_SELECTED_REPOS_KEY, validRepos);1820}18211822return validRepos;1823}18241825private async detectedUncommittedChanges(): Promise<boolean> {1826const currentRepository = this._gitService.activeRepository?.get();1827if (!currentRepository) {1828return false;1829}1830const git = this._gitExtensionService.getExtensionApi();1831const repo = git?.getRepository(currentRepository?.rootUri);1832if (!repo) {1833return false;1834}1835return repo.state.workingTreeChanges.length > 0 || repo.state.indexChanges.length > 0;1836}18371838/**1839* Checks if the current base branch exists on the remote repository.1840* Returns branch information including whether it's missing from remote, the base ref name, and the repository's default branch.1841* @param selectedRepository - Optional repository in `org/repo` format. If provided, uses this specific repository1842* instead of defaulting to the first one. This enables multiroot workspace support.1843*/1844private async checkBaseBranchPresentOnRemote(selectedRepository?: string): Promise<{ missingOnRemote: boolean; baseRef: string; repoDefaultBranch: string } | undefined> {1845try {1846const repoIds = await getRepoId(this._gitService);1847if (!repoIds || repoIds.length === 0) {1848return undefined;1849}18501851// In multiroot workspaces, use the selected repository if provided1852let repoId = repoIds[0];1853if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) {1854const [selectedOrg, selectedRepo] = selectedRepository.split('/');1855const matchingRepoId = repoIds.find(id => id.org === selectedOrg && id.repo === selectedRepo);1856repoId = matchingRepoId ?? new GithubRepoId(selectedOrg, selectedRepo);1857}18581859const { baseRef, repository, remoteName } = await this.gitOperationsManager.repoInfo();1860const remoteRepoInfo = await this._githubRepositoryService.getRepositoryInfo(repoId.org, repoId.repo);1861const remoteHasRef = await this.gitOperationsManager.checkIfRemoteHasRef(repository, remoteName, baseRef);1862if (remoteHasRef) {1863// Remote HAS the base branch, no action needed.1864return { missingOnRemote: false, baseRef, repoDefaultBranch: remoteRepoInfo.default_branch };1865}1866// Remote is MISSING the base branch1867return { missingOnRemote: true, baseRef, repoDefaultBranch: remoteRepoInfo.default_branch };1868} catch (error) {1869this.logService.debug(`Failed to check default branch: ${error}`);1870return undefined;1871}1872}18731874/**1875* Returns either all the data for a confirmation dialog, or undefined if no confirmation is needed.1876* */1877private async buildConfirmation(context: vscode.ChatContext): Promise<{ title: string; message: string; buttons: string[] } | undefined> {1878const title: string = this.TITLE;1879const buttons: string[] = [this.CANCEL];1880let message: string = this.BASE_MESSAGE;18811882// Get the selected repository from the chat context for multiroot workspace support1883const chatResource = context.chatSessionContext?.chatSessionItem?.resource;1884const selectedRepository = chatResource ? this.sessionRepositoryMap.get(chatResource) : undefined;18851886const needsPermissiveAuth = !this._authenticationService.permissiveGitHubSession;1887const hasUncommittedChanges = await this.detectedUncommittedChanges();1888const baseBranchInfo = await this.checkBaseBranchPresentOnRemote(selectedRepository);18891890if (needsPermissiveAuth && hasUncommittedChanges) {1891message += '\n\n' + this.AUTHORIZE_MESSAGE;1892message += '\n\n' + this.COMMIT_MESSAGE;1893buttons.unshift(1894vscode.l10n.t('{0} and {1}', this.AUTHORIZE, this.COMMIT),1895this.AUTHORIZE,1896);1897} else if (needsPermissiveAuth && baseBranchInfo?.missingOnRemote) {1898const { baseRef, repoDefaultBranch } = baseBranchInfo;1899message += '\n\n' + this.AUTHORIZE_MESSAGE;1900message += '\n\n' + this.PUSH_BRANCH_MESSAGE(baseRef, repoDefaultBranch);1901buttons.unshift(1902vscode.l10n.t('{0} and {1}', this.AUTHORIZE, this.PUSH_BRANCH),1903this.AUTHORIZE,1904);1905} else if (needsPermissiveAuth) {1906message += '\n\n' + this.AUTHORIZE_MESSAGE;1907buttons.unshift(1908this.AUTHORIZE,1909);1910} else if (hasUncommittedChanges) {1911message += '\n\n' + this.COMMIT_MESSAGE;1912buttons.unshift(1913vscode.l10n.t('{0} and {1}', this.COMMIT, this.DELEGATE),1914this.DELEGATE,1915);1916} else if (baseBranchInfo?.missingOnRemote) {1917const { baseRef, repoDefaultBranch } = baseBranchInfo;1918message += '\n\n' + this.PUSH_BRANCH_MESSAGE(baseRef, repoDefaultBranch);1919buttons.unshift(1920vscode.l10n.t('{0} and {1}', this.PUSH_BRANCH, this.DELEGATE),1921this.DELEGATE,1922);1923}19241925// Check if the message has been modified from the default1926const messageModified = message !== this.BASE_MESSAGE;19271928// Only skip confirmation if neither buttons were modified nor message was modified1929if (buttons.length === 1 && !messageModified) {1930if (context.chatSessionContext?.isUntitled) {1931return; // Don't show the confirmation1932}1933const seenDelegationPromptBefore = this.getWorkspaceContext(SEEN_DELEGATION_PROMPT_KEY);1934if (seenDelegationPromptBefore) {1935return; // Don't show the confirmation1936}1937}19381939if (buttons.length === 1) {1940// No other affirmative button added, so add generic one1941buttons.unshift(this.DELEGATE);1942}19431944return { title, message, buttons };1945}19461947private async chatParticipantImpl(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {1948if (token.isCancellationRequested) {1949stream.warning(vscode.l10n.t('Cloud session cancelled.'));1950return {};1951}19521953if (request.acceptedConfirmationData || request.rejectedConfirmationData) {1954await this.handleConfirmationData(request, stream, context, token);1955this.setWorkspaceContext(SEEN_DELEGATION_PROMPT_KEY, 'yes');1956return {};1957}19581959// Look up the partner agent and model for telemetry1960const chatResource = context.chatSessionContext?.chatSessionItem?.resource;19611962const initialOptions = context.chatSessionContext?.initialSessionOptions;1963if (chatResource) {1964this.logService.trace(`[chatParticipantImpl] initialSessionOptions for ${chatResource.toString()}: ${describeRuntimeValue(initialOptions)}`);1965}1966if (chatResource) {1967for (const opt of normalizeInitialSessionOptions(initialOptions, this.logService, chatResource)) {1968const value = typeof opt.value === 'string' ? opt.value : opt.value.id;1969if (opt.optionId === CUSTOM_AGENTS_OPTION_GROUP_ID) {1970this.sessionCustomAgentMap.set(chatResource, value);1971} else if (opt.optionId === MODELS_OPTION_GROUP_ID) {1972this.sessionModelMap.set(chatResource, value);1973} else if (opt.optionId === PARTNER_AGENTS_OPTION_GROUP_ID) {1974this.sessionPartnerAgentMap.set(chatResource, value);1975} else if (opt.optionId === REPOSITORIES_OPTION_GROUP_ID) {1976this.sessionRepositoryMap.set(chatResource, value);1977}1978}1979}19801981const partnerAgentId = chatResource ? this.sessionPartnerAgentMap.get(chatResource) : undefined;1982const partnerAgent = HARDCODED_PARTNER_AGENTS.find(agent => agent.id === partnerAgentId);1983const modelId = chatResource ? this.sessionModelMap.get(chatResource) : undefined;19841985/* __GDPR__1986"copilotcloud.chat.invoke" : {1987"owner": "joshspicer",1988"comment": "Event sent when a Copilot Cloud chat request is made.",1989"chatRequestId": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The unique chat request ID." },1990"hasChatSessionItem": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Invoked with a chat session item." },1991"isUntitled": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Indicates if the chat session is untitled." },1992"partnerAgent": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The partner agent name (e.g., Copilot, Claude, Codex)." },1993"model": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The selected model ID." }1994}1995*/1996this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.invoke', {1997chatRequestId: request.id,1998hasChatSessionItem: String(!!context.chatSessionContext?.chatSessionItem),1999isUntitled: String(context.chatSessionContext?.isUntitled),2000partnerAgent: partnerAgent?.name ?? 'unknown',2001model: modelId ?? 'unknown'2002});2003GenAiMetrics.incrementCloudSessionCount(this._otelService, partnerAgent?.name ?? 'unknown');2004emitCloudSessionInvokeEvent(this._otelService, partnerAgent?.name ?? 'unknown', modelId ?? 'unknown', request.id);20052006// Follow up2007if (context.chatSessionContext && !context.chatSessionContext.isUntitled && request.sessionResource.scheme === CopilotCloudSessionsProvider.TYPE) {2008await this.handleFollowUp(request, context, stream, token);2009return {};2010}20112012// New request2013const showConfirmation = await this.buildConfirmation(context);2014if (showConfirmation) {2015const { title, message, buttons } = showConfirmation;2016stream.confirmation(2017title,2018message,2019{2020metadata: {2021prompt: request.prompt,2022references: request.references,2023chatContext: context,2024} satisfies ConfirmationMetadata2025},2026buttons2027);2028} else {2029// No confirmation2030await this.delegate(2031request,2032stream,2033context,2034token,2035{2036prompt: request.prompt,2037references: request.references,2038chatContext: context2039} satisfies ConfirmationMetadata,2040);2041}2042}20432044private async handleFollowUp(request: vscode.ChatRequest, context: vscode.ChatContext, stream: vscode.ChatResponseStream, token: vscode.CancellationToken) {2045if (!context.chatSessionContext || context.chatSessionContext.isUntitled) {2046return {};2047}2048const { prompt } = request;2049if (!prompt || prompt.trim().length === 0) {2050stream.markdown(vscode.l10n.t('Please provide a message for the cloud agent.'));2051return {};2052}20532054stream.progress(vscode.l10n.t('Preparing'));2055const session = SessionIdForPr.parse(context.chatSessionContext.chatSessionItem.resource);2056let prNumber = session?.prNumber;2057if (!prNumber) {2058prNumber = SessionIdForPr.parsePullRequestNumber(context.chatSessionContext.chatSessionItem.resource);2059if (!prNumber) {2060return {};2061}2062}2063const pullRequest = await this.findPR(prNumber);2064if (!pullRequest) {2065stream.warning(vscode.l10n.t('Could not find the associated pull request {0} for this chat session.', '' + context.chatSessionContext.chatSessionItem.resource));2066return {};2067}20682069stream.progress(vscode.l10n.t('Delegating'));20702071const cachedPartnerAgentId = this.sessionPartnerAgentMap.get(context.chatSessionContext.chatSessionItem.resource);2072const partnerAgentAt = HARDCODED_PARTNER_AGENTS.find(agent => agent.id === cachedPartnerAgentId)?.at;20732074const result = await this.addFollowUpToExistingPR(pullRequest.number, prompt, undefined, partnerAgentAt);2075if (!result) {2076stream.markdown(vscode.l10n.t('Failed to add follow-up comment to the pull request.'));2077return {};2078}20792080// Show initial success message2081stream.markdown(result);2082stream.markdown('\n\n');20832084stream.progress(vscode.l10n.t('Attaching to session'));20852086// Wait for new session and stream its progress2087const newSession = await this.waitForNewSession(pullRequest, stream, token, true);2088if (!newSession) {2089return {};2090}20912092// Stream the new session logs2093stream.markdown(vscode.l10n.t('Cloud agent has begun work on your request'));2094stream.markdown('\n\n');20952096await this.streamSessionLogs(stream, pullRequest, newSession.id, token);2097return {};2098}20992100/**2101* Processes *supported* references, returning an LLM-friendly string representation and the filtered list of those references that were processed.2102*/2103private async extractReferences(references: readonly vscode.ChatPromptReference[] | undefined, pushedInProgressBranch: boolean): Promise<{ result: string; processedReferences: readonly vscode.ChatPromptReference[] }> {2104// 'file:///Users/jospicer/dev/joshbot/.github/workflows/build-vsix.yml' -> '.github/workflows/build-vsix.yml'2105const fileRefs: string[] = [];2106const fullFileParts: string[] = [];2107const processedReferences: vscode.ChatPromptReference[] = [];2108const git = this._gitExtensionService.getExtensionApi();2109for (const ref of references || []) {2110if (ref.value instanceof vscode.Uri && ref.value.scheme === 'file') {2111const fileUri = ref.value;2112const repositoryForFile = git?.getRepository(fileUri);2113if (repositoryForFile) {2114const relativePath = pathLib.relative(repositoryForFile.rootUri.fsPath, fileUri.fsPath);2115const isInWorkingTree = repositoryForFile.state.workingTreeChanges.some(change => change.uri.fsPath === fileUri.fsPath);2116const isInIndex = repositoryForFile.state.indexChanges.some(change => change.uri.fsPath === fileUri.fsPath);2117if (!pushedInProgressBranch && (isInWorkingTree || isInIndex)) {2118try {2119// Show only the file diffs for modified files2120let diff: string;2121if (isInIndex) {2122diff = await repositoryForFile.diffIndexWithHEAD(fileUri.fsPath);2123} else {2124diff = await repositoryForFile.diffWithHEAD(fileUri.fsPath);2125}21262127if (diff && diff.trim()) {2128fullFileParts.push(`<file-diff-start>${relativePath}</file-diff-start>`);2129fullFileParts.push(diff);2130fullFileParts.push(`<file-diff-end>${relativePath}</file-diff-end>`);2131} else {2132// If diff is empty, fall back to file reference2133fileRefs.push(` - ${relativePath}`);2134}2135processedReferences.push(ref);2136} catch (error) {2137this.logService.error(`Error reading file diff for reference: ${fileUri.toString()}: ${error}`);2138}2139} else {2140fileRefs.push(` - ${relativePath}`);2141processedReferences.push(ref);2142}2143}2144} else if (ref.value instanceof vscode.Uri && ref.value.scheme === 'github-remote-file') {2145// Virtual filesystem for cloud repos in the sessions window.2146// URI format: github-remote-file://github/{owner}/{repo}/{ref}/{path...}2147const parts = ref.value.path.split('/').filter(Boolean); // ['owner', 'repo', 'ref', ...path]2148if (parts.length >= 4) {2149const relativePath = parts.slice(3).join('/');2150fileRefs.push(` - ${relativePath}`);2151processedReferences.push(ref);2152}2153} else if (ref.value instanceof vscode.Uri && ref.value.scheme === 'untitled') {2154// Get full content of untitled file2155try {2156const document = await vscode.workspace.openTextDocument(ref.value);2157const content = document.getText();2158fullFileParts.push(`<file-start>${ref.value.path}</file-start>`);2159fullFileParts.push(content);2160fullFileParts.push(`<file-end>${ref.value.path}</file-end>`);2161processedReferences.push(ref);2162} catch (error) {2163this.logService.error(`Error reading untitled file content for reference: ${ref.value.toString()}: ${error}`);2164}2165}2166}21672168const parts: string[] = [2169...(fullFileParts.length ? ['The user has attached the following uncommitted or modified files as relevant context:', ...fullFileParts] : []),2170...(fileRefs.length ? ['The user has attached the following file paths as relevant context:', ...fileRefs] : [])2171];21722173this.logService.debug(`Cloud agent knew how to process ${processedReferences.length} of the ${references?.length || 0} provided references.`);2174return { result: parts.join('\n'), processedReferences };2175}21762177private async streamSessionLogs(stream: vscode.ChatResponseStream, pullRequest: PullRequestSearchItem, sessionId: string, token: vscode.CancellationToken): Promise<void> {2178let lastLogLength = 0;2179let lastProcessedLength = 0;2180let hasActiveProgress = false;2181const pollingInterval = 3000; // 3 seconds21822183return new Promise<void>((resolve, reject) => {2184let isCompleted = false;21852186const complete = async () => {2187if (isCompleted) {2188return;2189}2190isCompleted = true;2191this.refresh();2192resolve();2193};21942195const pollForUpdates = async (): Promise<void> => {2196try {2197if (token.isCancellationRequested) {2198complete();2199return;2200}22012202// Get the specific session info2203const sessionInfo = await this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);2204if (!sessionInfo || token.isCancellationRequested) {2205complete();2206return;2207}22082209// Get session logs2210const logs = await this._octoKitService.getSessionLogs(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);22112212// Check if session is still in progress2213if (sessionInfo.state !== 'in_progress') {2214if (logs.length > lastProcessedLength) {2215const newLogContent = logs.slice(lastProcessedLength);2216const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent);2217if (streamResult.hasStreamedContent) {2218hasActiveProgress = false;2219}2220}2221hasActiveProgress = false;2222complete();2223return;2224}22252226if (logs.length > lastLogLength) {2227this.logService.trace(`New logs detected, attempting to stream content`);2228const newLogContent = logs.slice(lastProcessedLength);2229const streamResult = await this.streamNewLogContent(pullRequest, stream, newLogContent);2230lastProcessedLength = logs.length;22312232if (streamResult.hasStreamedContent) {2233this.logService.trace(`Content was streamed, resetting hasActiveProgress to false`);2234hasActiveProgress = false;2235} else if (streamResult.hasSetupStepProgress) {2236this.logService.trace(`Setup step progress detected, keeping progress active`);2237// Keep hasActiveProgress as is, don't reset it2238} else {2239this.logService.trace(`No content was streamed, keeping hasActiveProgress as ${hasActiveProgress}`);2240}2241}22422243lastLogLength = logs.length;22442245if (!token.isCancellationRequested && sessionInfo.state === 'in_progress') {2246if (!hasActiveProgress) {2247this.logService.trace(`Showing progress indicator (hasActiveProgress was false)`);2248hasActiveProgress = true;2249} else {2250this.logService.trace(`NOT showing progress indicator (hasActiveProgress was true)`);2251}2252setTimeout(pollForUpdates, pollingInterval);2253} else {2254complete();2255}2256} catch (error) {2257this.logService.error(`Error polling for session updates: ${error}`);2258if (!token.isCancellationRequested) {2259setTimeout(pollForUpdates, pollingInterval);2260} else {2261reject(error);2262}2263}2264};22652266// Start polling2267setTimeout(pollForUpdates, pollingInterval);2268});2269}22702271private async streamNewLogContent(pullRequest: PullRequestSearchItem, stream: vscode.ChatResponseStream, newLogContent: string): Promise<{ hasStreamedContent: boolean; hasSetupStepProgress: boolean }> {2272try {2273if (!newLogContent.trim()) {2274return { hasStreamedContent: false, hasSetupStepProgress: false };2275}22762277// Parse the new log content2278const contentBuilder = new ChatSessionContentBuilder(CopilotCloudSessionsProvider.TYPE, this._gitService);2279const logChunks = parseSessionLogChunksSafely(newLogContent, this.logService, value => contentBuilder.parseSessionLogs(value));2280let hasStreamedContent = false;2281let hasSetupStepProgress = false;22822283for (const [chunkIndex, chunk] of logChunks.entries()) {2284if (!Array.isArray(chunk.choices)) {2285this.logService.warn(`[streamNewLogContent] Ignoring chunk ${chunkIndex} with non-array choices for PR #${pullRequest.number}.`);2286continue;2287}22882289for (const choice of chunk.choices) {2290if (!choice?.delta) {2291this.logService.warn(`[streamNewLogContent] Ignoring chunk ${chunkIndex} with missing delta for PR #${pullRequest.number}.`);2292continue;2293}22942295const delta = choice.delta;2296const toolCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : undefined;2297if (delta.tool_calls && !toolCalls) {2298this.logService.warn(`[streamNewLogContent] Ignoring non-array tool_calls for PR #${pullRequest.number}.`);2299}23002301if (delta.role === 'assistant') {2302// Handle special case for run_custom_setup_step/run_setup2303if (choice.finish_reason === 'tool_calls' && toolCalls?.length && (toolCalls[0].function.name === 'run_custom_setup_step' || toolCalls[0].function.name === 'run_setup')) {2304const toolCall = toolCalls[0];2305let args: any = {};2306try {2307args = JSON.parse(toolCall.function.arguments);2308} catch {2309// fallback to empty args2310}23112312if (delta.content && delta.content.trim()) {2313// Finished setup step - create/update tool part2314const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, args.name || delta.content);2315if (toolPart) {2316stream.push(toolPart);2317hasStreamedContent = true;2318if (toolPart instanceof vscode.ChatResponseThinkingProgressPart) {2319stream.push(new vscode.ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));2320}2321}2322} else {2323// Running setup step - just track progress2324hasSetupStepProgress = true;2325this.logService.trace(`Setup step in progress: ${args.name || 'Unknown step'}`);2326}2327} else {2328if (delta.content) {2329if (!delta.content.startsWith('<pr_title>')) {2330stream.markdown(delta.content);2331hasStreamedContent = true;2332}2333}23342335if (toolCalls) {2336for (const toolCall of toolCalls) {2337const toolPart = contentBuilder.createToolInvocationPart(pullRequest, toolCall, delta.content || '');2338if (toolPart) {2339stream.push(toolPart);2340hasStreamedContent = true;2341if (toolPart instanceof vscode.ChatResponseThinkingProgressPart) {2342stream.push(new vscode.ChatResponseThinkingProgressPart('', '', { vscodeReasoningDone: true }));2343}2344}2345}2346}2347}2348}23492350// Handle finish reasons2351if (choice.finish_reason && choice.finish_reason !== 'null') {2352this.logService.trace(`Streaming finish_reason: ${choice.finish_reason}`);2353}2354}2355}23562357if (hasStreamedContent) {2358this.logService.trace(`Streamed content (markdown or tool parts), progress should be cleared`);2359} else if (hasSetupStepProgress) {2360this.logService.trace(`Setup step progress detected, keeping progress indicator`);2361} else {2362this.logService.trace(`No actual content streamed, progress may still be showing`);2363}2364return { hasStreamedContent, hasSetupStepProgress };2365} catch (error) {2366this.logService.error(`Error streaming new log content: ${error}`);2367return { hasStreamedContent: false, hasSetupStepProgress: false };2368}2369}23702371private async waitForQueuedToInProgress(2372sessionId: string,2373token?: vscode.CancellationToken2374): Promise<SessionInfo | undefined> {2375let sessionInfo: SessionInfo | undefined;23762377const waitForQueuedMaxRetries = 3;2378const waitForQueuedDelay = 5_000; // 5 seconds23792380// Allow for a short delay before the session is marked as 'queued'2381let waitForQueuedCount = 0;2382do {2383sessionInfo = await this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);2384if (sessionInfo && sessionInfo.state === 'queued') {2385this.logService.trace('Queued session found');2386break;2387}2388if (waitForQueuedCount < waitForQueuedMaxRetries) {2389this.logService.trace('Session not yet queued, waiting...');2390await new Promise(resolve => setTimeout(resolve, waitForQueuedDelay));2391}2392++waitForQueuedCount;2393} while (waitForQueuedCount <= waitForQueuedMaxRetries && (!token || !token.isCancellationRequested));23942395if (!sessionInfo || sessionInfo.state !== 'queued') {2396if (sessionInfo?.state === 'in_progress') {2397this.logService.trace('Session already in progress');2398this.refresh();2399return sessionInfo;2400}2401// Failure2402this.logService.trace('Failed to find queued session');2403return;2404}24052406const maxWaitTime = 2 * 60 * 1_000; // 2 minutes2407const pollInterval = 3_000; // 3 seconds2408const startTime = Date.now();24092410this.logService.trace(`Session ${sessionInfo.id} is queued, waiting for transition to in_progress...`);2411while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) {2412const sessionInfo = await this._octoKitService.getSessionInfo(sessionId, CLOUD_SESSIONS_AUTH_OPTIONS);2413if (sessionInfo?.state === 'in_progress') {2414this.logService.trace(`Session ${sessionInfo.id} now in progress.`);2415this.refresh();2416return sessionInfo;2417}2418await new Promise(resolve => setTimeout(resolve, pollInterval));2419}2420this.logService.error(`Timed out waiting for session ${sessionId} to transition from queued to in_progress.`);2421}24222423private async waitForNewSession(2424pullRequest: PullRequestSearchItem,2425stream: vscode.ChatResponseStream,2426token: vscode.CancellationToken,2427waitForTransitionToInProgress: boolean = false2428): Promise<SessionInfo | undefined> {2429// Get the current number of sessions2430const initialSessions = await this._octoKitService.getCopilotSessionsForPR(pullRequest.fullDatabaseId.toString(), CLOUD_SESSIONS_AUTH_OPTIONS);2431const initialSessionCount = initialSessions.length;24322433// Poll for a new session to start2434const maxWaitTime = 5 * 60 * 1000; // 5 minutes2435const pollInterval = 3000; // 3 seconds2436const startTime = Date.now();24372438while (Date.now() - startTime < maxWaitTime && !token.isCancellationRequested) {2439const currentSessions = await this._octoKitService.getCopilotSessionsForPR(pullRequest.fullDatabaseId.toString(), CLOUD_SESSIONS_AUTH_OPTIONS);24402441// Check if a new session has started2442if (currentSessions.length > initialSessionCount) {2443const newSession = currentSessions2444.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];2445if (!waitForTransitionToInProgress) {2446return newSession;2447}2448const inProgressSession = await this.waitForQueuedToInProgress(newSession.id, token);2449if (!inProgressSession) {2450stream.markdown(vscode.l10n.t('Timed out waiting for cloud agent to begin work. Please try again shortly.'));2451return;2452}2453return inProgressSession;2454}24552456await new Promise(resolve => setTimeout(resolve, pollInterval));2457}24582459stream.markdown(vscode.l10n.t('Timed out waiting for the cloud agent to respond. The agent may still be processing your request.'));2460return;2461}24622463private async addFollowUpToExistingPR(pullRequestNumber: number, userPrompt: string, summary?: string, targetAgent = 'copilot'): Promise<string | undefined> {2464try {2465/* __GDPR__2466"copilotcloud.chat.followupComment" : {2467"owner": "joshspicer",2468"comment": "Event sent when a follow-up comment is delegated to an existing pull request.",2469"targetAgent": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "The target @agent for the follow-up comment." }2470}2471*/2472this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.followupComment', {2473targetAgent,2474});24752476const pr = await this.findPR(pullRequestNumber);2477if (!pr) {2478this.logService.error(`Could not find pull request #${pullRequestNumber}`);2479return;2480}2481const commentBody = `@${targetAgent} ${userPrompt} ${summary ? '\n\n' + summary : ''}`;24822483const commentResult = await this._octoKitService.addPullRequestComment(pr.id, commentBody, CLOUD_SESSIONS_AUTH_OPTIONS);2484if (!commentResult) {2485this.logService.error(`Failed to add comment to PR #${pullRequestNumber}`);2486return;2487}2488// allow-any-unicode-next-line2489return vscode.l10n.t('🚀 Follow-up comment added to [#{0}]({1})', pullRequestNumber, commentResult.url);2490} catch (err) {2491this.logService.error(`Failed to add follow-up comment to PR #${pullRequestNumber}: ${err}`);2492return;2493}2494}24952496// https://github.com/github/sweagentd/blob/main/docs/adr/0001-create-job-api.md2497private validateRemoteAgentJobResponse(response: unknown): response is RemoteAgentJobResponse {2498return typeof response === 'object' && response !== null && 'job_id' in response && 'session_id' in response;2499}25002501private async waitForJobWithPullRequest(2502owner: string,2503repo: string,2504jobId: string,2505token?: vscode.CancellationToken2506): Promise<JobInfo | undefined> {2507const maxWaitTime = 30 * 1000; // 30 seconds2508const pollInterval = 2000; // 2 seconds2509const startTime = Date.now();25102511this.logService.trace(`Waiting for job ${jobId} to have pull request information...`);25122513while (Date.now() - startTime < maxWaitTime && (!token || !token.isCancellationRequested)) {2514const jobInfo = await this._octoKitService.getJobByJobId(owner, repo, jobId, 'vscode-copilot-chat', CLOUD_SESSIONS_AUTH_OPTIONS);2515if (jobInfo && jobInfo.pull_request && jobInfo.pull_request.number) {2516/* __GDPR__2517"copilotcloud.chat.remoteAgentJobPullRequestReady" : {2518"owner": "joshspicer",2519"comment": "Event sent when a remote agent job first returns pull request information."2520}2521*/2522this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.remoteAgentJobPullRequestReady');2523GenAiMetrics.incrementCloudPrReadyCount(this._otelService);2524this.logService.trace(`Job ${jobId} now has pull request #${jobInfo.pull_request.number}`);2525this.refresh();2526return jobInfo;2527}2528await new Promise(resolve => setTimeout(resolve, pollInterval));2529}25302531this.logService.warn(`Timed out waiting for job ${jobId} to have pull request information`);2532return undefined;2533}25342535private 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 }> {2536const title = extractTitle(prompt, problemContext);2537const { problemStatement, isTruncated } = truncatePrompt(this.logService, prompt, problemContext);2538const repoIds = await getRepoId(this._gitService);25392540let repoOwner: string;2541let repoName: string;2542let repoHost: string = 'github.com';2543if (selectedRepository && selectedRepository !== DEFAULT_REPOSITORY_ID) {2544const [owner, repo] = selectedRepository.split('/');2545repoOwner = owner;2546repoName = repo;2547const matchingRepoId = repoIds?.find(id => id.org === owner && id.repo === repo);2548if (matchingRepoId) {2549repoHost = matchingRepoId.host;2550}2551} else {2552const repoId = repoIds?.[0];2553if (!repoId) {2554throw new Error(vscode.l10n.t('Unable to determine repository information. Please ensure you are working within a Git repository.'));2555}2556repoOwner = repoId.org;2557repoName = repoId.repo;2558repoHost = repoId.host;2559}25602561// Check if CCA is enabled before posting job2562const ccaEnabled = await this.checkCCAEnabled(repoOwner, repoName);2563if (ccaEnabled.enabled === false) {2564throw new Error(this.getCCADisabledMessage(ccaEnabled, repoHost));2565}25662567if (isTruncated) {2568stream.progress(vscode.l10n.t('Truncating context'));2569const truncationResult = await vscode.window.showWarningMessage(2570vscode.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);2571const userCancelled = token?.isCancellationRequested || !truncationResult || truncationResult !== CONTINUE_TRUNCATION;2572/* __GDPR__2573"copilot.codingAgent.truncation" : {2574"isCancelled" : { "classification": "SystemMetaData", "purpose": "FeatureInsight" }2575}2576*/2577this.telemetry.sendTelemetryEvent('copilot.codingAgent.truncation', { microsoft: true, github: false }, {2578isCancelled: String(userCancelled),2579});2580if (userCancelled) {2581throw new Error(vscode.l10n.t('User cancelled due to truncation.'));2582}2583}25842585const resolvePartnerAgentName = (partnerAgentName?: string): { agent_id?: number } => {2586this.logService.trace(`Resolving partner agent from: ${partnerAgentName}`);2587if (!partnerAgentName || partnerAgentName === DEFAULT_PARTNER_AGENT_ID) {2588return {};2589}2590// try convert to number2591const partnerAgentIdNum = Number(partnerAgentName);2592if (isNaN(partnerAgentIdNum)) {2593this.logService.warn(`Invalid partner agent name/id provided: ${partnerAgentName}`);2594return {};2595}2596return { agent_id: partnerAgentIdNum };2597};25982599const payload: RemoteAgentJobPayload = {2600problem_statement: problemStatement,2601event_content: prompt,2602event_type: 'visual_studio_code_remote_agent_tool_invoked',2603...(customAgentName && customAgentName !== DEFAULT_CUSTOM_AGENT_ID && { custom_agent: customAgentName }),2604...(modelName && modelName !== DEFAULT_MODEL_ID && { model: modelName }),2605...(resolvePartnerAgentName(partnerAgentName)),2606pull_request: {2607title,2608body_placeholder: formatBodyPlaceholder(title),2609base_ref,2610body_suffix,2611...(head_ref && { head_ref }),2612}2613};26142615/* __GDPR__2616"copilotcloud.chat.remoteAgentJobInvoke" : {2617"owner": "joshspicer",2618"comment": "Event sent when a remote agent job invocation starts.",2619"hasHeadRef": { "classification": "SystemMetaData", "purpose": "FeatureInsight", "comment": "Whether a head ref was provided for delegation." }2620}2621*/2622this.telemetry.sendMSFTTelemetryEvent('copilotcloud.chat.remoteAgentJobInvoke', {2623hasHeadRef: String(!!head_ref)2624});26252626stream?.progress(vscode.l10n.t('Delegating to cloud agent'));2627this.logService.debug(`[postCopilotAgentJob] Invoking cloud agent job with payload: ${JSON.stringify(payload)}`);2628const response = await this._octoKitService.postCopilotAgentJob(repoOwner, repoName, JOBS_API_VERSION, payload, CLOUD_SESSIONS_AUTH_OPTIONS);2629this.logService.debug(`[postCopilotAgentJob] Received response from cloud agent job invocation: ${JSON.stringify(response)}`);2630if (!this.validateRemoteAgentJobResponse(response)) {2631const statusCode = response?.status;2632switch (statusCode) {2633case 401:2634throw 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.'));2635case 403:2636throw 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`));2637case 404:2638throw new Error(vscode.l10n.t('The repository `{0}/{1}` was not found or you do not have access to it.', repoOwner, repoName));2639case 422:2640// NOTE: Although earlier checks should prevent this, ensure that if we end up2641// with a 422 from the API, we give a useful error message2642throw 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));2643case 500:2644throw new Error(vscode.l10n.t('Cloud agent service encountered an internal error. Please try again later.'));2645default:2646throw new Error(vscode.l10n.t('Received invalid response {0} from cloud agent.', statusCode ? statusCode : ''));2647}2648}26492650stream.progress(vscode.l10n.t('Creating pull request'));2651const jobInfo = await this.waitForJobWithPullRequest(repoOwner, repoName, response.job_id, token);26522653if (!jobInfo || !jobInfo.pull_request) {2654throw new Error(vscode.l10n.t('Failed to retrieve pull request information from job'));2655}26562657const { number } = jobInfo.pull_request;2658if (!number || isNaN(number)) {2659throw new Error(vscode.l10n.t('Invalid pull request number received from cloud agent'));2660}2661return {2662number,2663sessionId: response.session_id2664};2665}2666}266726682669