Add TTL caching for cloud agent /enabled and session provider options (#3621)

* Add TTL caching for cloud agent /enabled and session provider options

Further reduce GitHub API requests from the cloud agent sessions provider. (extension of 89771ff43a)

- Add TtlCache and SingleSlotTtlCache utilities for TTL-based caching
- Cache /enabled results for 30 minutes (only enabled=true; disabled results
  always re-fetch so users aren't stuck after enabling CCA)
- Cache the full provideChatSessionProviderOptions result for 15 minutes,
  covering custom agents, models, and partner agents in a single cache entry
- refresh() no longer clears TTL caches; only auth changes and the new
  "Clear Cloud Agent Caches" command force-clear them
- Register "GitHub Copilot: Clear Cloud Agent Caches" command as an escape hatch
- Add unit tests for TtlCache and SingleSlotTtlCache

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Josh Spicer
2026-02-10 14:11:15 -06:00
committed by GitHub
parent 0810844a47
commit 19541d79ea
5 changed files with 350 additions and 30 deletions
+5
View File
@@ -2874,6 +2874,11 @@
"title": "%github.copilot.command.cloudSessions.openRepository.title%",
"icon": "$(repo)",
"category": "GitHub Copilot"
},
{
"command": "github.copilot.chat.cloudSessions.clearCaches",
"title": "%github.copilot.command.cloudSessions.clearCaches.title%",
"category": "GitHub Copilot"
}
],
"configuration": [
+1
View File
@@ -423,6 +423,7 @@
"github.copilot.chat.applyCopilotCLIAgentSessionChanges.apply": "Apply",
"github.copilot.command.checkoutPullRequestReroute.title": "Checkout",
"github.copilot.command.cloudSessions.openRepository.title": "Browse repositories...",
"github.copilot.command.cloudSessions.clearCaches.title": "Clear Cloud Agent Caches",
"github.copilot.command.applyCopilotCLIAgentSessionChanges": "Apply Changes to Workspace",
"github.copilot.config.githubMcpServer.enabled": "Enable built-in support for the GitHub MCP Server.",
"github.copilot.config.githubMcpServer.toolsets": "Specify toolsets to use from the GitHub MCP Server. [Learn more](https://aka.ms/vscode-gh-mcp-toolsets).",
@@ -0,0 +1,169 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { SingleSlotTtlCache, TtlCache } from '../ttlCache';
describe('TtlCache', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns undefined for missing keys', () => {
const cache = new TtlCache<string>(1000);
expect(cache.get('missing')).toBeUndefined();
});
it('stores and retrieves values within TTL', () => {
const cache = new TtlCache<number>(5000);
cache.set('key', 42);
expect(cache.get('key')).toBe(42);
});
it('expires entries after TTL elapses', () => {
const cache = new TtlCache<string>(1000);
cache.set('key', 'value');
vi.advanceTimersByTime(999);
expect(cache.get('key')).toBe('value');
vi.advanceTimersByTime(1);
expect(cache.get('key')).toBeUndefined();
});
it('supports multiple independent keys', () => {
const cache = new TtlCache<string>(2000);
cache.set('a', 'alpha');
vi.advanceTimersByTime(1000);
cache.set('b', 'beta');
vi.advanceTimersByTime(1000);
// 'a' was set 2000ms ago → expired
expect(cache.get('a')).toBeUndefined();
// 'b' was set 1000ms ago → still valid
expect(cache.get('b')).toBe('beta');
});
it('overwrites existing entry and resets TTL', () => {
const cache = new TtlCache<string>(1000);
cache.set('key', 'old');
vi.advanceTimersByTime(800);
cache.set('key', 'new');
vi.advanceTimersByTime(800);
// 800ms since last set → still valid
expect(cache.get('key')).toBe('new');
});
it('delete removes entry', () => {
const cache = new TtlCache<string>(5000);
cache.set('key', 'value');
cache.delete('key');
expect(cache.get('key')).toBeUndefined();
});
it('clear removes all entries', () => {
const cache = new TtlCache<string>(5000);
cache.set('a', '1');
cache.set('b', '2');
cache.clear();
expect(cache.get('a')).toBeUndefined();
expect(cache.get('b')).toBeUndefined();
});
it('has returns true for non-expired entries and false otherwise', () => {
const cache = new TtlCache<string>(1000);
expect(cache.has('key')).toBe(false);
cache.set('key', 'value');
expect(cache.has('key')).toBe(true);
vi.advanceTimersByTime(1000);
expect(cache.has('key')).toBe(false);
});
});
describe('SingleSlotTtlCache', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('returns undefined when empty', () => {
const cache = new SingleSlotTtlCache<string>(1000);
expect(cache.get('any')).toBeUndefined();
});
it('stores and retrieves a value within TTL', () => {
const cache = new SingleSlotTtlCache<number>(5000);
cache.set('key', 42);
expect(cache.get('key')).toBe(42);
});
it('returns undefined when key does not match', () => {
const cache = new SingleSlotTtlCache<string>(5000);
cache.set('key1', 'value');
expect(cache.get('key2')).toBeUndefined();
});
it('expires entry after TTL elapses', () => {
const cache = new SingleSlotTtlCache<string>(1000);
cache.set('key', 'value');
vi.advanceTimersByTime(999);
expect(cache.get('key')).toBe('value');
vi.advanceTimersByTime(1);
expect(cache.get('key')).toBeUndefined();
});
it('replaces previous entry when set with new key', () => {
const cache = new SingleSlotTtlCache<string>(5000);
cache.set('key1', 'a');
cache.set('key2', 'b');
expect(cache.get('key1')).toBeUndefined();
expect(cache.get('key2')).toBe('b');
});
it('replaces and resets TTL on same key', () => {
const cache = new SingleSlotTtlCache<string>(1000);
cache.set('key', 'old');
vi.advanceTimersByTime(800);
cache.set('key', 'new');
vi.advanceTimersByTime(800);
expect(cache.get('key')).toBe('new');
});
it('clear removes entry', () => {
const cache = new SingleSlotTtlCache<string>(5000);
cache.set('key', 'value');
cache.clear();
expect(cache.get('key')).toBeUndefined();
});
it('has returns true for non-expired matching key', () => {
const cache = new SingleSlotTtlCache<string>(1000);
expect(cache.has('key')).toBe(false);
cache.set('key', 'value');
expect(cache.has('key')).toBe(true);
expect(cache.has('other')).toBe(false);
vi.advanceTimersByTime(1000);
expect(cache.has('key')).toBe(false);
});
});
@@ -0,0 +1,108 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
/**
* A simple TTL (time-to-live) cache that stores key-value pairs with expiration.
* Entries are evicted lazily on access when their TTL has elapsed.
*/
export class TtlCache<V> {
private readonly _entries = new Map<string, { value: V; timestamp: number }>();
/**
* @param _ttlMs The time-to-live in milliseconds for cache entries.
*/
constructor(private readonly _ttlMs: number) { }
/**
* Returns the cached value if it exists and has not expired, otherwise `undefined`.
*/
get(key: string): V | undefined {
const entry = this._entries.get(key);
if (!entry) {
return undefined;
}
if (Date.now() - entry.timestamp >= this._ttlMs) {
this._entries.delete(key);
return undefined;
}
return entry.value;
}
/**
* Stores a value in the cache with the current timestamp.
*/
set(key: string, value: V): void {
this._entries.set(key, { value, timestamp: Date.now() });
}
/**
* Removes a single entry from the cache.
*/
delete(key: string): void {
this._entries.delete(key);
}
/**
* Removes all entries from the cache.
*/
clear(): void {
this._entries.clear();
}
/**
* Returns `true` if the cache has a non-expired entry for the given key.
*/
has(key: string): boolean {
return this.get(key) !== undefined;
}
}
/**
* A single-slot TTL cache that stores one value with an associated key and expiration.
* Useful for caching a computed result (e.g. the full options object for a given repo context).
*/
export class SingleSlotTtlCache<V> {
private _entry: { value: V; timestamp: number; key: string } | undefined;
/**
* @param _ttlMs The time-to-live in milliseconds for the cached entry.
*/
constructor(private readonly _ttlMs: number) { }
/**
* Returns the cached value if the key matches and the TTL has not expired, otherwise `undefined`.
*/
get(key: string): V | undefined {
if (!this._entry || this._entry.key !== key) {
return undefined;
}
if (Date.now() - this._entry.timestamp >= this._ttlMs) {
this._entry = undefined;
return undefined;
}
return this._entry.value;
}
/**
* Stores a value in the cache, replacing any previous entry.
*/
set(key: string, value: V): void {
this._entry = { value, timestamp: Date.now(), key };
}
/**
* Removes the cached entry.
*/
clear(): void {
this._entry = undefined;
}
/**
* Returns `true` if the cache has a non-expired entry for the given key.
*/
has(key: string): boolean {
return this.get(key) !== undefined;
}
}
@@ -23,6 +23,7 @@ import { Disposable, toDisposable } from '../../../util/vs/base/common/lifecycle
import { ResourceMap } from '../../../util/vs/base/common/map';
import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation';
import { IChatDelegationSummaryService } from '../../agents/copilotcli/common/delegationSummaryService';
import { SingleSlotTtlCache, TtlCache } from '../common/ttlCache';
import { isUntitledSessionId } from '../common/utils';
import { body_suffix, CONTINUE_TRUNCATION, extractTitle, formatBodyPlaceholder, getAuthorDisplayName, getRepoId, JOBS_API_VERSION, SessionIdForPr, toOpenPullRequestWebviewUri, truncatePrompt } from '../vscode/copilotCodingAgentUtils';
import { CopilotCloudGitOperationsManager } from './copilotCloudGitOperationsManager';
@@ -63,9 +64,15 @@ const DEFAULT_REPOSITORY_ID = '___vscode_repository_default___';
const ACTIVE_SESSION_POLL_INTERVAL_MS = 5 * 1000; // 5 seconds
const SEEN_DELEGATION_PROMPT_KEY = 'seenDelegationPromptBefore';
const OPEN_REPOSITORY_COMMAND_ID = 'github.copilot.chat.cloudSessions.openRepository';
const CLEAR_CACHES_COMMAND_ID = 'github.copilot.chat.cloudSessions.clearCaches';
const USER_SELECTED_REPOS_KEY = 'userSelectedRepositories';
const USER_SELECTED_REPOS_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000; // 1 week
// TTL for caching /enabled responses (only caches enabled=true; disabled results always re-fetch)
const CCA_ENABLED_CACHE_TTL_MS = 30 * 60 * 1_000; // 30 minutes
// TTL for caching session provider options (custom agents, models, partner agents, etc.)
const OPTIONS_CACHE_TTL_MS = 15 * 60 * 1_000; // 15 minutes
interface UserSelectedRepository {
name: string;
timestamp: number;
@@ -175,10 +182,13 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
private readonly plainTextRenderer = new PlainTextRenderer();
private readonly gitOperationsManager = new CopilotCloudGitOperationsManager(this.logService, this._gitService, this._gitExtensionService);
private _partnerAgentsAvailableCache: Map<string, { id: string; name: string; at?: string }[]> | undefined;
// TTL cache for CCA enabled status per repository (key: "owner/repo")
// Only caches enabled=true results; disabled results always re-fetch to avoid stuck states
private _ccaEnabledCache = new TtlCache<CCAEnabledResult>(CCA_ENABLED_CACHE_TTL_MS);
// Cache for CCA enabled status per repository (key: "owner/repo")
private _ccaEnabledCache: Map<string, CCAEnabledResult> | undefined;
// Single-slot TTL cache for the full session provider options result (custom agents, models, partner agents, etc.)
// Caches the most recently computed options regardless of repo/workspace context
private _optionsCache = new SingleSlotTtlCache<vscode.ChatSessionProviderOptions>(OPTIONS_CACHE_TTL_MS);
// Title
private TITLE = vscode.l10n.t('Delegate to cloud agent');
@@ -270,7 +280,10 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
}
const onDebouncedAuthRefresh = Event.debounce(this._authenticationService.onDidAuthenticationChange, () => { }, 500);
this._register(onDebouncedAuthRefresh(() => this.refresh()));
this._register(onDebouncedAuthRefresh(() => {
this.clearOptionsCaches();
this.refresh();
}));
this.telemetry.sendTelemetryEvent('copilotCloudSessions.refreshInterval', { microsoft: true, github: false }, telemetryObj);
});
}
@@ -372,6 +385,13 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
});
};
this._register(vscode.commands.registerCommand(OPEN_REPOSITORY_COMMAND_ID, openRepositoryCommand));
this._register(vscode.commands.registerCommand(CLEAR_CACHES_COMMAND_ID, () => {
this.logService.debug('copilotCloudSessionsProvider#clearCaches: clearing all cloud agent caches');
this.clearOptionsCaches();
this.refresh();
this._onDidChangeChatSessionProviderOptions.fire();
}));
}
private getRefreshIntervalTime(hasHistoricalSessions: boolean): number {
@@ -395,14 +415,25 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
this.cachedSessionItems = undefined;
this.activeSessionIds.clear();
this.stopActiveSessionPolling();
this._partnerAgentsAvailableCache = undefined;
this._ccaEnabledCache = undefined;
// Note: _ccaEnabledCache and _optionsCache are TTL-based and NOT cleared on refresh.
// Use clearOptionsCaches() to force-clear them (e.g. on auth change).
this._onDidChangeChatSessionItems.fire();
}
/**
* Force-clears the TTL-based caches for /enabled and session provider options.
* Use for auth changes or explicit user-initiated refresh where stale data is unacceptable.
*/
private clearOptionsCaches(): void {
this._ccaEnabledCache.clear();
this._optionsCache.clear();
}
/**
* Checks if the Copilot cloud agent is enabled for a repository.
* Results are cached per repository until refresh() is called.
* Results are cached with a TTL: enabled=true results are cached for {@link CCA_ENABLED_CACHE_TTL_MS},
* while enabled=false results are never cached (always re-fetched) so users who just
* enabled CCA are not stuck in a disabled state.
* @param owner Repository owner
* @param repo Repository name
* @returns CCAEnabledResult with enabled status and optional status code
@@ -410,18 +441,21 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
private async checkCCAEnabled(owner: string, repo: string): Promise<CCAEnabledResult> {
const cacheKey = `${owner}/${repo}`;
if (!this._ccaEnabledCache) {
this._ccaEnabledCache = new Map();
}
const cached = this._ccaEnabledCache.get(cacheKey);
if (cached !== undefined) {
if (cached !== undefined && cached.enabled === true) {
this.logService.trace(`copilotCloudSessionsProvider#checkCCAEnabled: using cached CCA enabled status for ${owner}/${repo}: ${cached.enabled}`);
return cached;
}
const result = await this._octoKitService.isCCAEnabled(owner, repo, { createIfNone: false });
this._ccaEnabledCache.set(cacheKey, result);
// Only cache enabled=true results with a TTL; disabled results should always re-fetch
if (result.enabled === true) {
this._ccaEnabledCache.set(cacheKey, result);
} else {
// Remove any stale positive cache entry
this._ccaEnabledCache.delete(cacheKey);
}
this.telemetry.sendTelemetryEvent('copilot.codingAgent.CCAIsEnabledCheck', { microsoft: true, github: false }, {
enabled: String(result.enabled),
@@ -530,13 +564,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
* TODO: Remove once given a proper API
*/
private async getAvailablePartnerAgents(owner: string, repo: string): Promise<{ id: string; name: string; at?: string; codiconId?: string }[]> {
const cacheKey = `${owner}/${repo}`;
// Return cached result if available
if (this._partnerAgentsAvailableCache?.has(cacheKey)) {
return this._partnerAgentsAvailableCache.get(cacheKey)!;
}
try {
// Fetch assignable actors for the repository
const assignableActors = await this._octoKitService.getAssignableActors(owner, repo, { createIfNone: false });
@@ -556,11 +583,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
}
}
if (!this._partnerAgentsAvailableCache) {
this._partnerAgentsAvailableCache = new Map();
}
this._partnerAgentsAvailableCache.set(cacheKey, availableAgents);
return availableAgents;
} catch (error) {
this.logService.error(`Error fetching partner agents: ${error}`);
@@ -625,7 +647,6 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
async provideChatSessionProviderOptions(token: vscode.CancellationToken): Promise<vscode.ChatSessionProviderOptions> {
this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions Start');
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
const repoIds = await getRepoId(this._gitService);
const repoId = repoIds?.[0];
@@ -643,6 +664,17 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
return { optionGroups: [] };
}
// Check TTL-based options cache
const optionsCacheKey = repoIds && repoIds.length > 0
? repoIds.map(r => `${r.org}/${r.repo}`).sort().join(',')
: '';
const cachedOptions = this._optionsCache.get(optionsCacheKey);
if (cachedOptions) {
this.logService.trace('copilotCloudSessionsProvider#provideChatSessionProviderOptions: using cached options');
return cachedOptions;
}
const optionGroups: vscode.ChatSessionProviderOptionGroup[] = [];
try {
// Fetch agents (requires repo), models (global), and partner agents in parallel
const [customAgents, models, partnerAgents] = await Promise.allSettled([
@@ -742,8 +774,13 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
});
}
this.logService.trace(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: Returning options: ${JSON.stringify(optionGroups, undefined, 2)}`);
return { optionGroups };
const result: vscode.ChatSessionProviderOptions = { optionGroups };
// Cache the full options result with TTL
this._optionsCache.set(optionsCacheKey, result);
this.logService.debug(`copilotCloudSessionsProvider#provideChatSessionProviderOptions: Returning options: ${JSON.stringify(optionGroups, undefined, 2)}`);
return result;
} catch (error) {
this.logService.error(`[copilotCloudSessionsProvider#provideChatSessionProviderOptions] Error fetching options: ${error}`);
return { optionGroups: [] };
@@ -2277,9 +2314,9 @@ export class CopilotCloudSessionsProvider extends Disposable implements vscode.C
};
stream?.progress(vscode.l10n.t('Delegating to cloud agent'));
this.logService.trace(`[postCopilotAgentJob] Invoking cloud agent job with payload: ${JSON.stringify(payload)}`);
this.logService.debug(`[postCopilotAgentJob] Invoking cloud agent job with payload: ${JSON.stringify(payload)}`);
const response = await this._octoKitService.postCopilotAgentJob(repoOwner, repoName, JOBS_API_VERSION, payload, { createIfNone: true });
this.logService.trace(`[postCopilotAgentJob] Received response from cloud agent job invocation: ${JSON.stringify(response)}`);
this.logService.debug(`[postCopilotAgentJob] Received response from cloud agent job invocation: ${JSON.stringify(response)}`);
if (!this.validateRemoteAgentJobResponse(response)) {
const statusCode = response?.status;
switch (statusCode) {