From 7b2ec61643bc26ca40155ce29efce543ead17ad3 Mon Sep 17 00:00:00 2001 From: cwebster-99 Date: Wed, 22 Apr 2026 15:46:51 -0500 Subject: [PATCH 01/35] Remove upgrade and plan branching Co-authored-by: Copilot --- .../src/platform/chat/common/commonTypes.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/extensions/copilot/src/platform/chat/common/commonTypes.ts b/extensions/copilot/src/platform/chat/common/commonTypes.ts index f22376f48195c..3929343df9831 100644 --- a/extensions/copilot/src/platform/chat/common/commonTypes.ts +++ b/extensions/copilot/src/platform/chat/common/commonTypes.ts @@ -243,22 +243,6 @@ function getRateLimitMessage(fetchResult: ChatFetchError, copilotPlan: string | if (fetchResult.retryAfter) { const resetDate = new Date(Date.now() + fetchResult.retryAfter * 1000); const resetDateString = resetDate.toLocaleString(undefined, { year: 'numeric', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit' }); - if (copilotPlan === 'free' || copilotPlan === 'individual' || copilotPlan === 'individual_pro') { - if (fetchResult.isAuto) { - return l10n.t({ - message: 'You\'ve reached your weekly rate limit. Please upgrade your plan or wait for your limit to reset on {0}. [Learn More]({1})', - args: [resetDateString, 'https://aka.ms/github-copilot-rate-limit-error'], - comment: [`{Locked=']({'}`] - }); - } - - return l10n.t({ - message: 'You\'ve reached your weekly rate limit. Please upgrade your plan, switch to the Auto model to continue working, or wait for your limit to reset on {0}. [Learn More]({1})', - args: [resetDateString, 'https://aka.ms/github-copilot-rate-limit-error'], - comment: [`{Locked=']({'}`] - }); - } - if (fetchResult.isAuto) { return l10n.t({ message: 'You\'ve reached your weekly rate limit. Please wait for your limit to reset on {0}. [Learn More]({1})', From aca2ea52b7bc199a564a9b903e6cf2fa8daecca3 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:02:42 -0700 Subject: [PATCH 02/35] Try better approach to ensuring webview service worker is up to date This should fix a case where the very first reload after a service worker update used the old service worker --- .../contrib/webview/browser/pre/index.html | 92 +++++-------------- .../webview/browser/pre/service-worker.js | 47 +--------- 2 files changed, 26 insertions(+), 113 deletions(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index da9063a0920fd..fa7a339b6a473 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-IfTPuWcsDJLAGDl2AukNfykiAL7ydLt9X4MnO5hS2xg=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> { - /** - * @param {MessageEvent} event - */ - const versionHandler = async (event) => { - if (event.data.channel !== 'version') { - return; - } - - navigator.serviceWorker.removeEventListener('message', versionHandler); - if (event.data.version === expectedWorkerVersion) { - return resolve(); - } else { - console.log(`Found unexpected service worker version. Found: ${event.data.version}. Expected: ${expectedWorkerVersion}`); - console.log(`Attempting to reload service worker`); - - // If we have the wrong version, try once (and only once) to unregister and re-register - // Note that `.update` doesn't seem to work desktop electron at the moment so we use - // `unregister` and `register` here. - return registration.unregister() - .then(() => navigator.serviceWorker.register(swPath)) - .finally(() => { resolve(); }); - } - }; - navigator.serviceWorker.addEventListener('message', versionHandler); - - const postVersionMessage = (/** @type {ServiceWorker} */ controller) => { - outerIframeMessageChannel = new MessageChannel(); - controller.postMessage({ channel: 'version' }, [outerIframeMessageChannel.port2]); - }; - - // At this point, either the service worker is ready and - // became our controller, or we need to wait for it. - // Note that navigator.serviceWorker.controller could be a - // controller from a previously loaded service worker. - const currentController = navigator.serviceWorker.controller; - if (currentController?.scriptURL.endsWith(swPath)) { - // service worker already loaded & ready to receive messages - postVersionMessage(currentController); - } else { - if (currentController) { - console.log(`Found unexpected service worker controller. Found: ${currentController.scriptURL}. Expected: ${swPath}. Waiting for controllerchange.`); - } else { - console.log(`No service worker controller found. Waiting for controllerchange.`); - } + if (navigator.serviceWorker.controller) { + // A previous SW is already controlling. Force an update + // check so we don't serve stale resources after a VS Code + // update. register() resolves before the update check + // completes, so we must call update() explicitly to wait + // for the download + byte-comparison to finish. + registration = await registration.update(); + } - // Either there's no controlling service worker, or it's an old one. - // Wait for it to change before posting the message - const onControllerChange = () => { - navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); - if (navigator.serviceWorker.controller) { - postVersionMessage(navigator.serviceWorker.controller); - } else { - return reject(new Error('No controller found.')); - } - }; - navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + // If a new worker was found, wait for it to take control via skipWaiting() + clients.claim() in the SW. + if ( + // New worker + registration.installing || registration.waiting + // First ever load + || !navigator.serviceWorker.controller + ) { + await new Promise(r => { + navigator.serviceWorker.addEventListener('controllerchange', r, { once: true }); + }); } + + return resolve(); }).catch(error => { if (!onElectron && error instanceof Error && error.message.includes('user denied permission')) { return reject(new Error(`Could not register service worker. Please make sure third party cookies are enabled: ${error}`)); @@ -1256,16 +1218,6 @@ unloadMonitor.onIframeLoaded(newFrame); } - if (!disableServiceWorker && outerIframeMessageChannel) { - outerIframeMessageChannel.port1.onmessage = event => { - switch (event.data.channel) { - case 'load-resource': - case 'load-localhost': - hostMessaging.postMessage(event.data.channel, event.data); - return; - } - }; - } }); // propagate vscode-context-menu-visible class diff --git a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js index 66aa364435c72..ed965aae60779 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/service-worker.js +++ b/src/vs/workbench/contrib/webview/browser/pre/service-worker.js @@ -18,9 +18,6 @@ const searchParams = new URL(location.toString()).searchParams; const remoteAuthority = searchParams.get('remoteAuthority'); -/** @type {MessagePort|undefined} */ -let outerIframeMessagePort; - /** * Origin used for resources */ @@ -148,20 +145,6 @@ sw.addEventListener('message', async (event) => { /** @type {Client} */ const source = event.source; switch (event.data.channel) { - case 'version': { - perfMark('version/request'); - outerIframeMessagePort = event.ports[0]; - sw.clients.get(source.id).then(client => { - perfMark('version/reply'); - if (client) { - client.postMessage({ - channel: 'version', - version: VERSION - }); - } - }); - return; - } case 'did-load-resource': { /** @type {ResourceResponse} */ const response = event.data.data; @@ -322,17 +305,14 @@ async function processResourceRequest( const webviewId = getWebviewIdForClient(client); // Refs https://github.com/microsoft/vscode/issues/244143 - // With PlzDedicatedWorker, worker subresources and blob wokers + // With PlzDedicatedWorker, worker subresources and blob workers // will use clients different from the window client. - // Since we cannot different a worker main resource from a worker subresource - // we will use message channel to the outer iframe provided at the time - // of service worker controller version initialization. if (!webviewId && client.type !== 'worker' && client.type !== 'sharedworker') { console.error('Could not resolve webview id'); return notFound(); } - const shouldTryCaching = (event.request.method === 'GET'); + const shouldTryCaching = (event.request.method === 'GET' && !event.request.headers.get('range')); /** * @param {RequestStoreResult} result @@ -401,6 +381,7 @@ async function processResourceRequest( // so we just pipe the stream through with a 206 status. if (entry.status === 206 && entry.range) { headers['Content-Range'] = entry.range; + headers['Cache-Control'] = 'no-store'; return new Response(entry.stream, { status: 206, headers }); } @@ -466,17 +447,6 @@ async function processResourceRequest( range, }); } - } else if (client.type === 'worker' || client.type === 'sharedworker') { - outerIframeMessagePort?.postMessage({ - channel: 'load-resource', - id: requestId, - scheme: requestUrlComponents.scheme, - authority: requestUrlComponents.authority, - path: requestUrlComponents.path, - query: requestUrlComponents.query, - ifNoneMatch: cached?.headers.get('ETag'), - range, - }); } return promise.then(entry => resolveResourceEntry(entry, cached)); @@ -499,11 +469,8 @@ async function processLocalhostRequest( } const webviewId = getWebviewIdForClient(client); // Refs https://github.com/microsoft/vscode/issues/244143 - // With PlzDedicatedWorker, worker subresources and blob wokers + // With PlzDedicatedWorker, worker subresources and blob workers // will use clients different from the window client. - // Since we cannot different a worker main resource from a worker subresource - // we will use message channel to the outer iframe provided at the time - // of service worker controller version initialization. if (!webviewId && client.type !== 'worker' && client.type !== 'sharedworker') { console.error('Could not resolve webview id'); return fetch(event.request); @@ -544,12 +511,6 @@ async function processLocalhostRequest( id: requestId, }); } - } else if (client.type === 'worker' || client.type === 'sharedworker') { - outerIframeMessagePort?.postMessage({ - channel: 'load-localhost', - origin: origin, - id: requestId, - }); } return promise.then(resolveRedirect); From e0687d30979a24a847184a1e4bcfd3bed5a3cd86 Mon Sep 17 00:00:00 2001 From: Megan Rogge Date: Wed, 22 Apr 2026 18:25:10 -0400 Subject: [PATCH 03/35] Fix OutputMonitor leak when foreground terminal is reused after `inputNeeded` (#312011) fix #311722 --- .../chatAgentTools/browser/tools/runInTerminalTool.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts index 93b432e4e6d78..af9159a59466f 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/browser/tools/runInTerminalTool.ts @@ -1872,6 +1872,10 @@ export class RunInTerminalTool extends Disposable implements IToolImpl { this._logService.debug(`RunInTerminalTool: Using cached terminal with session resource \`${chatSessionResource}\``); this._terminalToolCreator.refreshShellIntegrationQuality(cachedTerminal); this._terminalChatService.registerTerminalInstanceWithToolSession(terminalToolSessionId, cachedTerminal.instance); + // Dispose any previous background notification (e.g. from an earlier + // `inputNeeded` race that left an OutputMonitor attached) before reusing + // this terminal, so its listeners don't accumulate across invocations. + this._backgroundNotifications.deleteAndDispose(cachedTerminal.instance.instanceId); return cachedTerminal; } } From 407bd5ae78c26182bd7e6c29eae9014a6384b541 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 23 Apr 2026 08:26:02 +1000 Subject: [PATCH 04/35] Enable controller API in insiders (#312014) --- extensions/copilot/package.json | 2 +- .../src/platform/configuration/common/configurationService.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index b51aba656a87f..adda3927ccccb 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4639,7 +4639,7 @@ }, "github.copilot.chat.cli.sessionController.enabled": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%github.copilot.config.cli.sessionController.enabled%", "tags": [ "advanced" diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index d8bdd501f8387..7143be912fa16 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -620,7 +620,7 @@ export namespace ConfigKey { export const CLIBranchSupport = defineSetting('chat.cli.branchSupport.enabled', ConfigType.Simple, false); export const CLIIsolationOption = defineSetting('chat.cli.isolationOption.enabled', ConfigType.Simple, true); export const CLIAutoCommitEnabled = defineSetting('chat.cli.autoCommit.enabled', ConfigType.Simple, true); - export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, false); + export const CLISessionController = defineSetting('chat.cli.sessionController.enabled', ConfigType.Simple, true); export const CLIThinkingEffortEnabled = defineSetting('chat.cli.thinkingEffort.enabled', ConfigType.Simple, true); export const CLIRemoteEnabled = defineSetting('chat.cli.remote.enabled', ConfigType.Simple, false); export const CLISessionControllerForSessionsApp = defineSetting('chat.cli.sessionControllerForSessionsApp.enabled', ConfigType.Simple, false); From 246a5759ef6d95d9352bedc160367fb38a217f72 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 22 Apr 2026 15:33:06 -0700 Subject: [PATCH 05/35] Cache auth tokens client-side to dedupe agent host authenticate RPCs (#312017) * Cache auth tokens client-side to dedupe agent host authenticate RPCs The local and remote agent host contributions were re-firing 'authenticate' RPCs on every rootState change, every default-account change, and every VS Code auth session even when the token had not changed. Thechange server-side string compare absorbed this, producing repeated '[Copilot] Auth token unchanged' log lines for every redundant call. Add an AgentHostAuthTokenCache that tracks the last token sent per protected-resource URI. Skip the RPC when the token is unchanged. Cache lifetime is per-contribution for the local agent host and per-connection for remote agent hosts (so it's dropped on disconnect). Tests: - 5 unit tests for AgentHostAuthTokenCache (first/repeat/rotate/per-URI/clear) - 3 integration tests against AgentHostContribution exercising the real _authenticateWithServer path (dedupe holds, rotation re-fires, no-token is a no-op) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address Copilot review: cache on success, clear on restart/failure - Seed the auth token cache only after a successful authenticate RPC (not before) so a transient RPC failure doesn't suppress future retries - On RPC failure, evict the per-resource cache entry so the next auth pass will retry that resource - Clear the entire cache when the local agent host process (re)starts, preventing the first post-restart authenticate from being skipped as 'token unchanged' - Same fixes applied to the remote agent host contribution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/remoteAgentHost.contribution.ts | 23 ++- .../agentSessions/agentHost/agentHostAuth.ts | 40 ++++++ .../agentHost/agentHostChatContribution.ts | 25 +++- .../agentSessions/agentHostAuth.test.ts | 44 +++++- .../agentHostChatContribution.test.ts | 135 ++++++++++++++++-- 5 files changed, 248 insertions(+), 19 deletions(-) diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index e3222a3232e0f..ea2b851240d2d 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -24,7 +24,7 @@ import { Registry } from '../../../../platform/registry/common/platform.js'; import { IStorageService } from '../../../../platform/storage/common/storage.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../workbench/common/contributions.js'; import { AgentCustomizationSyncProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentCustomizationSyncProvider.js'; -import { resolveTokenForResource } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; +import { resolveTokenForResource, AgentHostAuthTokenCache } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; import { LoggingAgentConnection } from '../../../../workbench/contrib/chat/browser/agentSessions/agentHost/loggingAgentConnection.js'; @@ -48,6 +48,8 @@ class ConnectionState extends Disposable { readonly agents = this._register(new DisposableMap()); readonly modelProviders = new Map(); readonly loggedConnection: LoggingAgentConnection; + /** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ + readonly authTokenCache = new AgentHostAuthTokenCache(); constructor( readonly name: string | undefined, @@ -429,7 +431,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc extensionId: 'vscode.remote-agent-host', extensionDisplayName: 'Remote Agent Host', resolveWorkingDirectory, - resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(loggedConnection, resources), + resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(address, loggedConnection, resources), customizations, })); agentStore.add(this._chatSessionsService.registerChatSessionContentProvider(sessionType, sessionHandler)); @@ -511,6 +513,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc private async _authenticateWithConnection(address: string, loggedConnection: LoggingAgentConnection, agents: readonly AgentInfo[]): Promise { const providerId = `agenthost-${agentHostAuthority(address)}`; const provider = this._sessionsProvidersService.getProvider(providerId); + const authTokenCache = this._connections.get(address)?.authTokenCache; provider?.setAuthenticationPending(true); try { for (const agent of agents) { @@ -518,8 +521,17 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc const resourceUri = URI.parse(resource.resource); const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); if (token) { + if (authTokenCache && !authTokenCache.updateAndIsChanged(resource.resource, token)) { + this._logService.trace(`[RemoteAgentHost] Auth token for ${resource.resource} unchanged; skipping authenticate RPC`); + continue; + } this._logService.info(`[RemoteAgentHost] Authenticating for resource: ${resource.resource}`); - await loggedConnection.authenticate({ resource: resource.resource, token }); + try { + await loggedConnection.authenticate({ resource: resource.resource, token }); + } catch (rpcErr) { + authTokenCache?.clear(resource.resource); + throw rpcErr; + } } else { this._logService.info(`[RemoteAgentHost] No token resolved for resource: ${resource.resource}`); } @@ -545,7 +557,8 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc * Interactively prompt the user to authenticate when the server requires it. * Returns true if authentication succeeded. */ - private async _resolveAuthenticationInteractively(loggedConnection: LoggingAgentConnection, protectedResources: readonly ProtectedResourceMetadata[]): Promise { + private async _resolveAuthenticationInteractively(address: string, loggedConnection: LoggingAgentConnection, protectedResources: readonly ProtectedResourceMetadata[]): Promise { + const authTokenCache = this._connections.get(address)?.authTokenCache; try { for (const resource of protectedResources) { for (const server of resource.authorization_servers ?? []) { @@ -557,6 +570,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc resource: resource.resource, token, }); + authTokenCache?.updateAndIsChanged(resource.resource, token); } else { const providerId = await this._authenticationService.getOrActivateProviderIdForServer(serverUri, resourceUri); if (!providerId) { @@ -573,6 +587,7 @@ export class RemoteAgentHostContribution extends Disposable implements IWorkbenc resource: resource.resource, token: session.accessToken, }); + authTokenCache?.updateAndIsChanged(resource.resource, session.accessToken); } this._logService.info(`[RemoteAgentHost] Interactive authentication succeeded for ${resource.resource}`); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts index 75ad8c68014f7..6e5320b33d909 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.ts @@ -7,6 +7,46 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ILogService } from '../../../../../../platform/log/common/log.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; +/** + * Tracks the last bearer token pushed to a given agent host connection + * for each protected resource, so that redundant `authenticate` RPCs can + * be suppressed when neither the resource nor the token has changed. + * + * One instance per connection. Owned by the contribution that drives + * authentication for that connection so the cache is dropped naturally + * when the connection is disposed. + */ +export class AgentHostAuthTokenCache { + private readonly _lastTokens = new Map(); + + /** + * Record that we just sent `token` for `resource`, and return whether + * this is a change from the last token sent. When `false`, callers + * should skip the `authenticate` RPC. + */ + updateAndIsChanged(resource: string, token: string): boolean { + const previous = this._lastTokens.get(resource); + if (previous === token) { + return false; + } + this._lastTokens.set(resource, token); + return true; + } + + /** + * Clear the cached token for a specific resource, or all resources if + * no argument is given. Call after a failed `authenticate` RPC (per-resource) + * or when the agent host process restarts (all resources). + */ + clear(resource?: string): void { + if (resource !== undefined) { + this._lastTokens.delete(resource); + } else { + this._lastTokens.clear(); + } + } +} + /** * Resolves a bearer token for a protected resource by trying each * authorization server in order. First attempts an exact scope match, diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index 4d0acedf931d3..fa0dc13b603ac 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -28,7 +28,7 @@ import { IAgentPluginService } from '../../../common/plugins/agentPluginService. import { PromptsType } from '../../../common/promptSyntax/promptTypes.js'; import { PromptsStorage } from '../../../common/promptSyntax/service/promptsService.js'; import { AgentCustomizationSyncProvider } from './agentCustomizationSyncProvider.js'; -import { resolveTokenForResource } from './agentHostAuth.js'; +import { resolveTokenForResource, AgentHostAuthTokenCache } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider } from './agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from './agentHostSessionHandler.js'; import { AgentHostSessionListController } from './agentHostSessionListController.js'; @@ -55,6 +55,9 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr /** Model providers keyed by agent provider, for pushing model updates. */ private readonly _modelProviders = new Map(); + /** Dedupes redundant `authenticate` RPCs when the resolved token hasn't changed. */ + private readonly _authTokenCache = new AgentHostAuthTokenCache(); + private readonly _isSessionsWindow: boolean; constructor( @@ -94,6 +97,12 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr this._handleRootStateChange(rootState); })); + // Clear the auth cache whenever the local agent host (re)starts so the + // first post-restart authenticate RPC is never skipped as "unchanged". + this._register(this._agentHostService.onAgentHostStart(() => { + this._authTokenCache.clear(); + })); + // Process initial root state if already available const initialRootState = this._agentHostService.rootState.value; if (initialRootState && !(initialRootState instanceof Error)) { @@ -273,8 +282,18 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr const resourceUri = URI.parse(resource.resource); const token = await this._resolveTokenForResource(resourceUri, resource.authorization_servers ?? [], resource.scopes_supported ?? []); if (token) { + if (!this._authTokenCache.updateAndIsChanged(resource.resource, token)) { + this._logService.trace(`[AgentHost] Auth token for ${resource.resource} unchanged; skipping authenticate RPC`); + continue; + } this._logService.info(`[AgentHost] Authenticating for resource: ${resource.resource}`); - await this._loggedConnection!.authenticate({ resource: resource.resource, token }); + try { + await this._loggedConnection!.authenticate({ resource: resource.resource, token }); + } catch (rpcErr) { + // Clear the cached token so the next auth pass will retry. + this._authTokenCache.clear(resource.resource); + throw rpcErr; + } } else { this._logService.info(`[AgentHost] No token resolved for resource: ${resource.resource}`); } @@ -312,6 +331,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr resource: resource.resource, token: resolved, }); + this._authTokenCache.updateAndIsChanged(resource.resource, resolved); return true; } @@ -333,6 +353,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr resource: resource.resource, token: session.accessToken, }); + this._authTokenCache.updateAndIsChanged(resource.resource, session.accessToken); this._logService.info(`[AgentHost] Interactive authentication succeeded for ${resource.resource}`); return true; } diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts index 464eb0d8095ac..528128888104f 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostAuth.test.ts @@ -8,7 +8,7 @@ import { URI } from '../../../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { NullLogService } from '../../../../../../platform/log/common/log.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; -import { resolveTokenForResource } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; +import { resolveTokenForResource, AgentHostAuthTokenCache } from '../../../browser/agentSessions/agentHost/agentHostAuth.js'; function createMockAuthService(overrides: { getOrActivateProviderIdForServer?: (serverUri: URI, resourceUri: URI) => Promise; @@ -112,3 +112,45 @@ suite('resolveTokenForResource', () => { assert.strictEqual(calls.length, 2); }); }); + +suite('AgentHostAuthTokenCache', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('first token for a resource is reported as changed', () => { + const cache = new AgentHostAuthTokenCache(); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), true); + }); + + test('repeating the same token for the same resource is reported as unchanged', () => { + const cache = new AgentHostAuthTokenCache(); + cache.updateAndIsChanged('https://api.example.com', 'tok1'); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), false); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), false); + }); + + test('a different token for the same resource is reported as changed', () => { + const cache = new AgentHostAuthTokenCache(); + cache.updateAndIsChanged('https://api.example.com', 'tok1'); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok2'), true); + // And the new token is now the cached one. + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok2'), false); + }); + + test('tokens for distinct resources are tracked independently', () => { + const cache = new AgentHostAuthTokenCache(); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), true); + assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', 'tok1'), true); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), false); + assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', 'tok1'), false); + }); + + test('clear forgets every cached token', () => { + const cache = new AgentHostAuthTokenCache(); + cache.updateAndIsChanged('https://api.example.com', 'tok1'); + cache.updateAndIsChanged('https://other.example.com', 'tok2'); + cache.clear(); + assert.strictEqual(cache.updateAndIsChanged('https://api.example.com', 'tok1'), true); + assert.strictEqual(cache.updateAndIsChanged('https://other.example.com', 'tok2'), true); + }); +}); diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts index 368e910ef0811..68e7f362bf8e8 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/agentHostChatContribution.test.ts @@ -19,7 +19,7 @@ import { AgentHostSessionConfigBranchNameHintKey, IAgentCreateSessionConfig, IAg import { isSessionAction, type ActionEnvelope, type INotification, type SessionAction, type TerminalAction, type IToolCallConfirmedAction, type ITurnStartedAction } from '../../../../../../platform/agentHost/common/state/sessionActions.js'; import type { IStateSnapshot } from '../../../../../../platform/agentHost/common/state/sessionProtocol.js'; import type { CustomizationRef } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; +import { SessionLifecycle, SessionStatus, TurnState, ToolCallStatus, ToolCallConfirmationReason, createSessionState, createActiveTurn, ROOT_STATE_URI, PolicyState, ResponsePartKind, StateComponents, buildSubagentSessionUri, ToolResultContentType, type SessionState, type SessionSummary, RootState, type ToolCallState, type AgentInfo } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { sessionReducer } from '../../../../../../platform/agentHost/common/state/sessionReducers.js'; import { IDefaultAccountService } from '../../../../../../platform/defaultAccount/common/defaultAccount.js'; import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; @@ -164,13 +164,32 @@ class MockAgentHostService extends mock() { return this._nextSeq++; } - override readonly rootState: IAgentSubscription = { - value: undefined, - verifiedValue: undefined, - onDidChange: Event.None, - onWillApplyAction: Event.None, - onDidApplyAction: Event.None, - }; + private _rootStateValue: RootState | undefined = undefined; + private readonly _rootStateOnDidChange = new Emitter(); + + override readonly rootState: IAgentSubscription = (() => { + const onDidChangeEmitter = this._rootStateOnDidChange; + const self = this; + return { + get value() { return self._rootStateValue; }, + get verifiedValue() { return self._rootStateValue; }, + onDidChange: onDidChangeEmitter.event, + onWillApplyAction: Event.None, + onDidApplyAction: Event.None, + }; + })(); + + /** Test helper: set rootState value and fire onDidChange. */ + setRootState(state: RootState): void { + this._rootStateValue = state; + this._rootStateOnDidChange.fire(state); + } + + public authenticateCalls: { resource: string; token: string }[] = []; + override async authenticate(params: { resource: string; token: string }): Promise<{ authenticated: boolean }> { + this.authenticateCalls.push({ resource: params.resource, token: params.token }); + return { authenticated: true }; + } override getSubscription(_kind: StateComponents, resource: URI): IReference> { const resourceStr = resource.toString(); const emitter = new Emitter(); @@ -268,6 +287,7 @@ class MockAgentHostService extends mock() { dispose(): void { this._onDidAction.dispose(); this._onDidNotification.dispose(); + this._rootStateOnDidChange.dispose(); } } @@ -286,7 +306,7 @@ class MockChatAgentService extends mock() { // ---- Helpers ---------------------------------------------------------------- -function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined }) { +function createTestServices(disposables: DisposableStore, workingDirectoryResolver?: { resolve(sessionResource: URI): URI | undefined }, authServiceOverride?: Partial) { const instantiationService = disposables.add(new TestInstantiationService()); const agentHostService = new MockAgentHostService(); @@ -306,7 +326,7 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv registerChatSessionContribution: () => toDisposable(() => { }), }); instantiationService.stub(IDefaultAccountService, { onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null }); - instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None }); + instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None, ...authServiceOverride }); instantiationService.stub(ILanguageModelsService, { deltaLanguageModelChatProviderDescriptors: () => { }, registerLanguageModelProvider: () => toDisposable(() => { }), @@ -363,8 +383,8 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv return { instantiationService, agentHostService, chatAgentService }; } -function createContribution(disposables: DisposableStore) { - const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables); +function createContribution(disposables: DisposableStore, opts?: { authServiceOverride?: Partial }) { + const { instantiationService, agentHostService, chatAgentService } = createTestServices(disposables, undefined, opts?.authServiceOverride); const listController = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', agentHostService, undefined, 'local')); const sessionHandler = disposables.add(instantiationService.createInstance(AgentHostSessionHandler, { @@ -2791,4 +2811,95 @@ suite('AgentHostChatContribution', () => { })); }); + + // ---- Auth dedupe ------------------------------------------------------ + + suite('auth dedupe', () => { + + const protectedAgents = (): AgentInfo[] => [{ + provider: 'copilot', + displayName: 'Agent Host - Copilot', + description: 'test', + models: [], + protectedResources: [{ + resource: 'https://api.github.com', + resource_name: 'GitHub', + authorization_servers: ['https://github.com/login/oauth'], + scopes_supported: ['read:user'], + required: true, + }], + }]; + + function tokenAuthService(tokenRef: { current: string }): Partial { + // Always returns whatever token is in tokenRef.current. Returning a session + // for the exact-scope `getSessions` call short-circuits the superset fallback. + return { + onDidChangeSessions: Event.None, + getOrActivateProviderIdForServer: async () => 'github', + getSessions: (async (_providerId: string, scopes?: ReadonlyArray) => { + if (scopes !== undefined) { + return [{ scopes: [...scopes], accessToken: tokenRef.current }]; + } + return []; + }) as unknown as IAuthenticationService['getSessions'], + }; + } + + test('does not re-authenticate when token unchanged across rootState changes', async () => { + const tokenRef = { current: 'tok-1' }; + const { agentHostService } = createContribution(disposables, { authServiceOverride: tokenAuthService(tokenRef) }); + + // First rootState — kicks off the eager auth pass. + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + assert.deepStrictEqual(agentHostService.authenticateCalls, [{ resource: 'https://api.github.com', token: 'tok-1' }]); + + // Repeated rootState changes with the same token must not re-fire authenticate. + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 1 }); + await timeout(0); + assert.deepStrictEqual(agentHostService.authenticateCalls, [{ resource: 'https://api.github.com', token: 'tok-1' }]); + }); + + test('re-authenticates when token rotates, then dedupes again', async () => { + const tokenRef = { current: 'tok-1' }; + const { agentHostService } = createContribution(disposables, { authServiceOverride: tokenAuthService(tokenRef) }); + + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + + // Token rotates externally; next rootState change must push it through. + tokenRef.current = 'tok-2'; + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + + // Subsequent passes with the new token must dedupe again. + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 1 }); + await timeout(0); + + assert.deepStrictEqual(agentHostService.authenticateCalls, [ + { resource: 'https://api.github.com', token: 'tok-1' }, + { resource: 'https://api.github.com', token: 'tok-2' }, + ]); + }); + + test('skips authenticate when no token is resolvable', async () => { + const noTokenService: Partial = { + onDidChangeSessions: Event.None, + getOrActivateProviderIdForServer: async () => undefined, + getSessions: (async () => []) as unknown as IAuthenticationService['getSessions'], + }; + const { agentHostService } = createContribution(disposables, { authServiceOverride: noTokenService }); + + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + agentHostService.setRootState({ agents: protectedAgents(), activeSessions: 0 }); + await timeout(0); + + assert.deepStrictEqual(agentHostService.authenticateCalls, []); + }); + }); }); From e64dce33e6805bec3610cd2d67f3408f54f0048b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 15:40:24 -0700 Subject: [PATCH 06/35] agentHost: add url permission request handling and refine confirmation titles (#312021) * agentHost: add url permission request handling and refine confirmation titles - Add handling for 'url' kind permission requests in getPermissionDisplay, with URL sanitization via the URL constructor for punycode escaping - Add 'url' property to ITypedPermissionRequest interface - Improve confirmation titles to use question format for consistency (e.g. 'Run in terminal?' instead of 'Run in terminal') - Improve custom-tool and default permission display to use markdown invocation messages with the tool name for richer rendering - Refine MCP permission confirmation title to use localized format Fixes https://github.com/microsoft/vscode/issues/311504 (Commit message generated by Copilot) * address copilot review comments --- .../node/copilot/copilotToolDisplay.ts | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index ef91cbe02bfaa..62478a41d85b5 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -425,6 +425,8 @@ export interface ITypedPermissionRequest extends PermissionRequest { toolName?: string; /** Tool arguments — set for `custom-tool` permission requests. */ args?: Record; + /** URL — set for `url` permission requests. */ + url?: string; /** Unified diff of the proposed change — set for `write` permission requests. */ diff?: string; /** New file contents that will be written — set for `write` permission requests. */ @@ -457,7 +459,7 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { switch (request.kind) { case 'shell': return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), + confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), fullCommandText ? { command: fullCommandText } : undefined), toolInput: fullCommandText, permissionKind: 'shell', @@ -471,7 +473,7 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { const sdkToolName = str(request.toolName); if (command && sdkToolName && isShellTool(sdkToolName)) { return { - confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal"), + confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }), toolInput: command, permissionKind: 'shell', @@ -479,8 +481,8 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { }; } return { - confirmationTitle: toolName ?? localize('copilot.permission.default.title', "Permission request"), - invocationMessage: localize('copilot.permission.default.message', "Permission request"), + confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), + invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), toolInput: args ? tryStringify(args) : tryStringify(request), permissionKind: request.kind, permissionPath: path, @@ -488,7 +490,7 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { } case 'write': return { - confirmationTitle: localize('copilot.permission.write.title', "Write file"), + confirmationTitle: localize('copilot.permission.write.title', "Write file?"), invocationMessage: getInvocationMessage(CopilotToolName.Edit, getToolDisplayName(CopilotToolName.Edit), path ? { path } : undefined), toolInput: tryStringify(path ? { path } : request) ?? undefined, permissionKind: 'write', @@ -497,7 +499,9 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { case 'mcp': { const title = toolName ?? localize('copilot.permission.mcp.defaultTool', "MCP Tool"); return { - confirmationTitle: serverName ? `${serverName}: ${title}` : title, + confirmationTitle: serverName + ? localize('copilot.permission.mcp.title', "Allow tool from {0}?", serverName) + : localize('copilot.permission.default.title', "Allow tool call?"), invocationMessage: serverName ? `${serverName}: ${title}` : title, toolInput: tryStringify({ serverName, toolName }) ?? undefined, permissionKind: 'mcp', @@ -506,16 +510,27 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { } case 'read': return { - confirmationTitle: localize('copilot.permission.read.title', "Read file"), + confirmationTitle: localize('copilot.permission.read.title', "Read file?"), invocationMessage: intention ?? getInvocationMessage(CopilotToolName.View, getToolDisplayName(CopilotToolName.View), path ? { path } : undefined), toolInput: tryStringify(path ? { path, intention } : request) ?? undefined, permissionKind: 'read', permissionPath: path, }; + case 'url': { + const url = str(request.url); + // Parse through URL for punycode escaping, but preserve the raw value if parsing fails. + const normalizedUrl = url ? (URL.canParse(url) ? new URL(url).href : url) : undefined; + return { + confirmationTitle: localize('copilot.permission.url.title', "Fetch URL?"), + invocationMessage: md(localize('copilot.permission.url.message', "Allow fetching web content?")), + toolInput: normalizedUrl ? JSON.stringify({ url: normalizedUrl }) : undefined, + permissionKind: 'url', + }; + } default: return { - confirmationTitle: localize('copilot.permission.default.title', "Permission request"), - invocationMessage: localize('copilot.permission.default.message', "Permission request"), + confirmationTitle: localize('copilot.permission.default.title', "Allow tool call?"), + invocationMessage: md(localize('copilot.permission.default.message', "Allow the model to call {0}?", appendEscapedMarkdownInlineCode(toolName ?? request.kind))), toolInput: tryStringify(request) ?? undefined, permissionKind: request.kind, permissionPath: path, From 17f555f7bd18d3d4027ba0e075019e37e8b0a189 Mon Sep 17 00:00:00 2001 From: Michael Lively <12552271+Yoyokrazy@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:44:36 -0700 Subject: [PATCH 07/35] fix(build): use Bearer auth for GitHub API requests in fetch.ts (#312022) The Authorization header was constructed as 'Basic ' + base64(token), which GitHub rejects because Basic auth requires base64(username:password). The malformed header caused all GitHub API requests to fail with HTTP 403, which the fetch wrapper masks with a misleading 'you may be rate limited' message. This was tolerable on GitHub-hosted runners because their IPs aren't aggressively rate limited unauthenticated, but on the new 1ES Azure-hosted runners (shared outbound IPs) the 60 req/hr unauthenticated limit is hit immediately, breaking core-ci on every PR. Switch to the Bearer scheme, which is the correct format for both GITHUB_TOKEN and PATs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/lib/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/lib/fetch.ts b/build/lib/fetch.ts index 0d2c47a7fd804..98a788f78db02 100644 --- a/build/lib/fetch.ts +++ b/build/lib/fetch.ts @@ -109,7 +109,7 @@ const ghApiHeaders: Record = { 'User-Agent': 'VSCode Build', }; if (process.env.GITHUB_TOKEN) { - ghApiHeaders.Authorization = 'Basic ' + Buffer.from(process.env.GITHUB_TOKEN).toString('base64'); + ghApiHeaders.Authorization = 'Bearer ' + process.env.GITHUB_TOKEN; } const ghDownloadHeaders = { ...ghApiHeaders, From d4da5f2a620ea0ef265f44788f89f10af17c852f Mon Sep 17 00:00:00 2001 From: Paul Date: Wed, 22 Apr 2026 15:46:24 -0700 Subject: [PATCH 08/35] Add missing sessionStart hook properties (#311109) --- .../src/extension/intents/node/toolCallingLoop.ts | 11 ++++++++--- .../intents/test/node/toolCallingLoopHooks.spec.ts | 12 ++++++++++-- .../src/platform/chat/common/chatHookService.ts | 8 ++++++++ 3 files changed, 26 insertions(+), 5 deletions(-) diff --git a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts index 743641d6f9a9c..03e30a22219ed 100644 --- a/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts +++ b/extensions/copilot/src/extension/intents/node/toolCallingLoop.ts @@ -196,6 +196,11 @@ export abstract class ToolCallingLoop { - const agentName = (this.options.request as { subAgentName?: string }).subAgentName - ?? (this.options.request as { participant?: string }).participant - ?? 'GitHub Copilot Chat'; + const agentName = this.agentName ?? 'GitHub Copilot Chat'; // Extract custom mode name for debug logging (kept separate from agentName to avoid metric cardinality) const modeInstructions = (this.options.request as { modeInstructions2?: { name?: string; isBuiltin?: boolean } }).modeInstructions2; diff --git a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts index e6e2e61380ca2..b48e339fa6768 100644 --- a/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts +++ b/extensions/copilot/src/extension/intents/test/node/toolCallingLoopHooks.spec.ts @@ -197,7 +197,10 @@ describe('ToolCallingLoop SessionStart hook', () => { describe('SessionStart hook execution conditions', () => { it('should execute SessionStart hook on the first turn of regular sessions', async () => { const conversation = createTestConversation(1); // First turn - const request = createMockChatRequest(); + const request = createMockChatRequest({ + model: { id: 'test-model-id' } as ChatRequest['model'], + participant: 'test-agent', + } as unknown as Partial); const loop = instantiationService.createInstance( TestToolCallingLoop, @@ -216,7 +219,12 @@ describe('ToolCallingLoop SessionStart hook', () => { const sessionStartCalls = mockChatHookService.getCallsForHook('SessionStart'); expect(sessionStartCalls).toHaveLength(1); - expect((sessionStartCalls[0].input as SessionStartHookInput).source).toBe('new'); + const input = sessionStartCalls[0].input as SessionStartHookInput; + expect(input).toMatchObject({ + source: 'new', + model: 'test-model-id', + agent_type: 'test-agent', + }); }); it('should NOT execute SessionStart hook on subsequent turns', async () => { diff --git a/extensions/copilot/src/platform/chat/common/chatHookService.ts b/extensions/copilot/src/platform/chat/common/chatHookService.ts index edd6faa727d0d..db1b2db2432fd 100644 --- a/extensions/copilot/src/platform/chat/common/chatHookService.ts +++ b/extensions/copilot/src/platform/chat/common/chatHookService.ts @@ -172,6 +172,14 @@ export interface SessionStartHookInput { * The source of the session start. Always "new". */ readonly source: 'new'; + /** + * The model identifier (e.g. "claude-sonnet-4-6"). + */ + readonly model: string; + /** + * The agent or mode name, if applicable. + */ + readonly agent_type?: string; } /** From d00e135668b1382637c5de3b70e59f18714f23a3 Mon Sep 17 00:00:00 2001 From: Michael Lively <12552271+Yoyokrazy@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:10:55 -0700 Subject: [PATCH 09/35] Revert "ci: switch PR workflows back to 1ES self-hosted runners with JobId" (#312033) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "ci: switch PR workflows back to 1ES self-hosted runners with JobId (#…" This reverts commit 94c4655a2cbba01eed1a8c1100bc1d17f89beaea. --- .github/workflows/pr-linux-cli-test.yml | 2 +- .github/workflows/pr-node-modules.yml | 6 +++--- .github/workflows/pr-win32-test.yml | 2 +- .github/workflows/pr.yml | 10 +++++----- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/pr-linux-cli-test.yml b/.github/workflows/pr-linux-cli-test.yml index e5c5dcd973e69..78d4c4acdc3a4 100644 --- a/.github/workflows/pr-linux-cli-test.yml +++ b/.github/workflows/pr-linux-cli-test.yml @@ -11,7 +11,7 @@ on: jobs: linux-cli-test: name: ${{ inputs.job_name }} - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-cli-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 env: RUSTUP_TOOLCHAIN: ${{ inputs.rustup_toolchain }} steps: diff --git a/.github/workflows/pr-node-modules.yml b/.github/workflows/pr-node-modules.yml index 731259eb9623d..952938c0df4cf 100644 --- a/.github/workflows/pr-node-modules.yml +++ b/.github/workflows/pr-node-modules.yml @@ -10,7 +10,7 @@ permissions: {} jobs: compile: name: Compile - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -86,7 +86,7 @@ jobs: linux: name: Linux - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=linux-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 env: NPM_ARCH: x64 VSCODE_ARCH: x64 @@ -219,7 +219,7 @@ jobs: windows: name: Windows - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64 ] env: NPM_ARCH: x64 VSCODE_ARCH: x64 diff --git a/.github/workflows/pr-win32-test.yml b/.github/workflows/pr-win32-test.yml index eb3668d88ae63..7a46a9a48bdad 100644 --- a/.github/workflows/pr-win32-test.yml +++ b/.github/workflows/pr-win32-test.yml @@ -17,7 +17,7 @@ on: jobs: windows-test: name: ${{ inputs.job_name }} - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=windows-test-${{ inputs.job_name }}-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: windows-2022 env: ARTIFACT_NAME: ${{ (inputs.electron_tests && 'electron') || (inputs.browser_tests && 'browser') || (inputs.remote_tests && 'remote') || 'unknown' }} NPM_ARCH: x64 diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index e97e099119fd1..70c65ddc42662 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,7 +19,7 @@ env: jobs: compile: name: Compile & Hygiene - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=compile-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 steps: - name: Checkout microsoft/vscode uses: actions/checkout@v6 @@ -159,7 +159,7 @@ jobs: copilot-check-test-cache: name: Copilot - Check Test Cache - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-check-test-cache-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 permissions: contents: read pull-requests: read @@ -205,7 +205,7 @@ jobs: copilot-check-telemetry: name: Copilot - Check Telemetry - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-check-telemetry-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -224,7 +224,7 @@ jobs: copilot-linux-tests: name: Copilot - Test (Linux) - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-ubuntu-22.04-x64, "JobId=copilot-linux-tests-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: ubuntu-22.04 permissions: contents: read steps: @@ -329,7 +329,7 @@ jobs: copilot-windows-tests: name: Copilot - Test (Windows) - runs-on: [ self-hosted, 1ES.Pool=1es-vscode-oss-windows-2022-x64, "JobId=copilot-windows-tests-${{ github.run_id }}-${{ github.run_number }}-${{ github.run_attempt }}" ] + runs-on: windows-2022 permissions: contents: read steps: From 207d9770ce9b8aff895d9fceb0f35e080a8802ff Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 23 Apr 2026 09:13:27 +1000 Subject: [PATCH 10/35] feat(copilotcli): Perf improvement via resolveChatSessionItem API (#312010) --- .../api/browser/mainThreadChatSessions.ts | 102 ++++++++- .../workbench/api/common/extHost.protocol.ts | 4 +- .../api/common/extHostChatSessions.ts | 62 +++++- .../browser/mainThreadChatSessions.test.ts | 194 +++++++++++++++++- .../chatSessions/chatSessions.contribution.ts | 9 + .../chat/common/chatSessionsService.ts | 8 + .../common/chatService/mockChatService.ts | 2 +- .../test/common/mockChatSessionsService.ts | 4 + .../vscode.proposed.chatSessionsProvider.d.ts | 37 ++++ 9 files changed, 411 insertions(+), 11 deletions(-) diff --git a/src/vs/workbench/api/browser/mainThreadChatSessions.ts b/src/vs/workbench/api/browser/mainThreadChatSessions.ts index 2a6a562efad8f..aaffc774fa769 100644 --- a/src/vs/workbench/api/browser/mainThreadChatSessions.ts +++ b/src/vs/workbench/api/browser/mainThreadChatSessions.ts @@ -370,11 +370,14 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes private readonly _proxy: ExtHostChatSessionsShape; private readonly _handle: number; + private _supportsResolve: boolean; private readonly _onDidChangeChatSessionItems = this._register(new Emitter()); public readonly onDidChangeChatSessionItems = this._onDidChangeChatSessionItems.event; private readonly _modelListeners = this._register(new DisposableResourceMap()); + private readonly _resolveCache = new ResourceMap>(); + private readonly _resolving = new ResourceMap(); private _isDisposed = false; @@ -382,6 +385,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes proxy: ExtHostChatSessionsShape, chatSessionType: string, handle: number, + supportsResolve: boolean, @IChatService private readonly _chatService: IChatService, @ILogService private readonly _logService: ILogService, ) { @@ -389,7 +393,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes this._proxy = proxy; this._handle = handle; - + this._supportsResolve = supportsResolve; // Update the chat session item based on on the actual model state // TODO: This should be based on the chat session content provider instead of the chat models directly // or bed moved into the chat session service so that all controllers get the same behavior. @@ -429,6 +433,7 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes override dispose(): void { this._isDisposed = true; + this._resolveCache.clear(); super.dispose(); } @@ -457,9 +462,16 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes async acceptChange(change: { readonly addedOrUpdated: readonly Dto[]; readonly removed: readonly URI[] }): Promise { const addedOrUpdatedItems: MainThreadChatSessionItem[] = []; for (const item of change.addedOrUpdated) { + // Invalidate resolve cache when item is updated — but not if the update + // originated from an in-flight resolve call. + const resource = URI.revive(item.resource); + if (!this._resolving.has(resource)) { + this._resolveCache.delete(resource); + } addedOrUpdatedItems.push(await this.addOrUpdateItem(item)); } for (const uri of change.removed) { + this._resolveCache.delete(uri); this._items.delete(uri); } this._onDidChangeChatSessionItems.fire({ @@ -500,6 +512,81 @@ class MainThreadChatSessionItemController extends Disposable implements IChatSes } return optionGroups; } + + async resolveChatSessionItem(resource: URI, token: CancellationToken): Promise { + if (!this._supportsResolve) { + return undefined; + } + + // Return cached promise if this item was already resolved or is currently resolving. + const cached = this._resolveCache.get(resource); + if (cached) { + return cached; + } + + const promise = this._doResolveItem(resource, token).catch( + err => { + this._resolveCache.delete(resource); + throw err; + } + ); + this._resolveCache.set(resource, promise); + return promise; + } + + private async _doResolveItem(resource: URI, token: CancellationToken): Promise { + const expectedItem = this._items.get(resource); + + // Mark this resource as resolving so that any collection updates triggered + // by the extension inside the resolve handler (e.g. collection.add()) do + // not clear the resolve cache and cause an infinite loop. + this._resolving.set(resource, true); + let dto: Dto | undefined; + try { + dto = await raceCancellationError(this._proxy.$resolveChatSessionItem(this._handle, resource, token), token); + } finally { + this._resolving.delete(resource); + } + if (!dto) { + return undefined; + } + + if (this._items.get(resource) !== expectedItem) { + return this._items.get(resource); + } + + const updated = new MainThreadChatSessionItem( + dto, + this._chatService.getSession(resource), + await this._chatService.getMetadataForSession(resource) + ); + + if (this._items.get(resource) !== expectedItem) { + return this._items.get(resource); + } + + + if (expectedItem?.isEqual(updated)) { + return expectedItem; + } + + this._items.set(resource, updated); + this._onDidChangeChatSessionItems.fire({ + addedOrUpdated: [updated], + }); + return updated; + } + + setSupportsResolve(supportsResolve: boolean): void { + if (this._supportsResolve === supportsResolve) { + return; + } + this._supportsResolve = supportsResolve; + // Drop any cached `undefined` results so a newly-installed handler can be invoked. + if (supportsResolve) { + this._resolveCache.clear(); + } + } } class MainThreadChatSessionItem implements IChatSessionItem { @@ -620,10 +707,10 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat return this._sessionTypeToHandle.get(chatSessionType); } - $registerChatSessionItemController(handle: number, chatSessionType: string): void { + $registerChatSessionItemController(handle: number, chatSessionType: string, supportsResolve: boolean): void { const disposables = new DisposableStore(); - const controller = disposables.add(this._instantiationService.createInstance(MainThreadChatSessionItemController, this._proxy, chatSessionType, handle)); + const controller = disposables.add(this._instantiationService.createInstance(MainThreadChatSessionItemController, this._proxy, chatSessionType, handle, supportsResolve)); disposables.add(this._chatSessionsService.registerChatSessionItemController(chatSessionType, controller)); this._itemControllerRegistrations.set(handle, { @@ -636,6 +723,15 @@ export class MainThreadChatSessions extends Disposable implements MainThreadChat this._refreshControllerInputState(handle, chatSessionType); } + $updateChatSessionItemControllerCapabilities(handle: number, supportsResolve: boolean): void { + const registration = this._itemControllerRegistrations.get(handle); + if (!registration) { + this._logService.warn(`No chat session item controller found for handle ${handle}`); + return; + } + registration.controller.setSupportsResolve(supportsResolve); + } + private _refreshControllerInputState(handle: number, chatSessionType: string): void { this._proxy.$provideChatSessionInputState(handle, undefined, CancellationToken.None).then(optionGroups => { if (optionGroups?.length) { diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 922e84784d0d6..f03878df02b2a 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -3699,7 +3699,8 @@ export interface IChatSessionItemsChange { } export interface MainThreadChatSessionsShape extends IDisposable { - $registerChatSessionItemController(controllerHandle: number, chatSessionType: string): void; + $registerChatSessionItemController(controllerHandle: number, chatSessionType: string, supportsResolve: boolean): void; + $updateChatSessionItemControllerCapabilities(controllerHandle: number, supportsResolve: boolean): void; $unregisterChatSessionItemController(controllerHandle: number): void; $updateChatSessionItems(controllerHandle: number, change: IChatSessionItemsChange): Promise; $addOrUpdateChatSessionItem(controllerHandle: number, item: Dto): Promise; @@ -3728,6 +3729,7 @@ export interface ExtHostChatSessionsShape { $provideChatSessionProviderOptions(providerHandle: number, token: CancellationToken): Promise; $provideHandleOptionsChange(providerHandle: number, sessionResource: UriComponents, updates: Record, token: CancellationToken): Promise; $forkChatSession(providerHandle: number, sessionResource: UriComponents, request: IChatSessionRequestHistoryItemDto | undefined, token: CancellationToken): Promise>; + $resolveChatSessionItem(providerHandle: number, sessionResource: UriComponents, token: CancellationToken): Promise | undefined>; $provideChatSessionInputState(controllerHandle: number, sessionResource: UriComponents | undefined, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/api/common/extHostChatSessions.ts b/src/vs/workbench/api/common/extHostChatSessions.ts index 7653e08d8a461..38d3b8669c654 100644 --- a/src/vs/workbench/api/common/extHostChatSessions.ts +++ b/src/vs/workbench/api/common/extHostChatSessions.ts @@ -437,6 +437,18 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }, onDidChangeChatSessionItemState: onDidChangeChatSessionItemStateEmitter.event, newChatSessionItemHandler: undefined, + // Bridge the deprecated `ChatSessionItemProvider.resolveChatSessionItem` hook through the + // new controller surface so both code paths share the same `$resolveChatSessionItem` impl. + // The legacy provider returns a new item; the bridge adds it to the collection so the + // controller contract (update via collection, return void) is satisfied. + resolveChatSessionItem: provider.resolveChatSessionItem + ? async (item, token) => { + const resolved = await provider.resolveChatSessionItem!(item, token); + if (resolved) { + collection.add(resolved); + } + } + : undefined, dispose: () => { disposables.dispose(); }, @@ -447,7 +459,7 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio }; this._chatSessionItemControllers.set(controllerHandle, { chatSessionType: chatSessionType, controller, extension, disposable: disposables, onDidChangeChatSessionItemStateEmitter, inputStates: new Set() }); - this._proxy.$registerChatSessionItemController(controllerHandle, chatSessionType); + this._proxy.$registerChatSessionItemController(controllerHandle, chatSessionType, !!provider.resolveChatSessionItem); if (provider.onDidChangeChatSessionItems) { disposables.add(provider.onDidChangeChatSessionItems(() => { @@ -486,11 +498,13 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio let isDisposed = false; let newChatSessionItemHandler: vscode.ChatSessionItemController['newChatSessionItemHandler']; let forkHandler: vscode.ChatSessionItemController['forkHandler']; + let resolveChatSessionItemHandler: vscode.ChatSessionItemController['resolveChatSessionItem']; let provideChatSessionInputStateHandler: vscode.ChatSessionItemController['getChatSessionInputState']; const onDidChangeChatSessionItemStateEmitter = disposables.add(new Emitter()); const inputStates = new Set(); const collection = new ChatSessionItemCollectionImpl(controllerHandle, this._proxy); + const proxy = this._proxy; const controller = Object.freeze({ id, @@ -521,6 +535,15 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio set newChatSessionItemHandler(handler: vscode.ChatSessionItemController['newChatSessionItemHandler']) { newChatSessionItemHandler = handler; }, get forkHandler() { return forkHandler; }, set forkHandler(handler: vscode.ChatSessionItemController['forkHandler']) { forkHandler = handler; }, + get resolveChatSessionItem() { return resolveChatSessionItemHandler; }, + set resolveChatSessionItem(handler: vscode.ChatSessionItemController['resolveChatSessionItem']) { + const hadHandler = !!resolveChatSessionItemHandler; + resolveChatSessionItemHandler = handler; + const hasHandler = !!handler; + if (hadHandler !== hasHandler && !isDisposed) { + proxy.$updateChatSessionItemControllerCapabilities(controllerHandle, hasHandler); + } + }, get getChatSessionInputState() { return provideChatSessionInputStateHandler; }, set getChatSessionInputState(handler: vscode.ChatSessionItemController['getChatSessionInputState']) { provideChatSessionInputStateHandler = handler; }, createChatSessionInputState: (groups: vscode.ChatSessionProviderOptionGroup[]) => { @@ -562,8 +585,11 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio this._chatSessionItemControllers.set(controllerHandle, { controller, extension, disposable: disposables, chatSessionType: id, onDidChangeChatSessionItemStateEmitter, inputStates }); - // Register the controller with the main thread - this._proxy.$registerChatSessionItemController(controllerHandle, id); + // Register the controller with the main thread. `resolveChatSessionItem` may be assigned + // later via the setter, which fires `$updateChatSessionItemControllerCapabilities` to + // flip `supportsResolve` on. Start out as `false` so controllers that never set the + // handler don't pay an RPC per render. + this._proxy.$registerChatSessionItemController(controllerHandle, id, !!resolveChatSessionItemHandler); disposables.add(toDisposable(() => { this._chatSessionItemControllers.delete(controllerHandle); @@ -1119,6 +1145,36 @@ export class ExtHostChatSessions extends Disposable implements ExtHostChatSessio controllerData.onDidChangeChatSessionItemStateEmitter.fire(item); } + async $resolveChatSessionItem(handle: number, sessionResourceComponents: UriComponents, token: CancellationToken): Promise | undefined> { + const sessionResource = URI.revive(sessionResourceComponents); + + // Both the new `ChatSessionItemController.resolveChatSessionItem` and the deprecated + // `ChatSessionItemProvider.resolveChatSessionItem` hooks are bridged onto the controller + // surface, so a single code path handles both. + const controllerData = this._chatSessionItemControllers.get(handle); + if (!controllerData?.controller.resolveChatSessionItem) { + return undefined; + } + + const item = controllerData.controller.items.get(sessionResource); + if (!item) { + this._logService.warn(`No item found for session resource ${sessionResource.toString()}`); + return undefined; + } + + // The controller's resolve handler updates the item in the collection + // (via items.add or by mutating properties). We re-read from the + // collection after it completes to pick up the changes. + await controllerData.controller.resolveChatSessionItem(item, token); + + const updatedItem = controllerData.controller.items.get(sessionResource); + if (!updatedItem) { + return undefined; + } + + return typeConvert.ChatSessionItem.from(updatedItem); + } + async $provideChatSessionInputState(controllerHandle: number, sessionResourceComponents: UriComponents | undefined, token: CancellationToken): Promise { const controllerData = this._chatSessionItemControllers.get(controllerHandle); if (!controllerData) { diff --git a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts index 8ff8d4e73c1cf..aa02f90dc6a43 100644 --- a/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts +++ b/src/vs/workbench/api/test/browser/mainThreadChatSessions.test.ts @@ -25,7 +25,7 @@ import { IAgentSessionsModel } from '../../../contrib/chat/browser/agentSessions import { IAgentSessionsService } from '../../../contrib/chat/browser/agentSessions/agentSessionsService.js'; import { ChatSessionsService } from '../../../contrib/chat/browser/chatSessions/chatSessions.contribution.js'; import { IChatProgress, IChatProgressMessage, IChatService } from '../../../contrib/chat/common/chatService/chatService.js'; -import { IChatSessionProviderOptionGroup, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; +import { IChatSessionProviderOptionGroup, IChatSessionItem, IChatSessionRequestHistoryItem, IChatSessionsService } from '../../../contrib/chat/common/chatSessionsService.js'; import { ChatAgentLocation } from '../../../contrib/chat/common/constants.js'; import { LocalChatSessionUri } from '../../../contrib/chat/common/model/chatUri.js'; import { IChatAgentRequest, IChatAgentResult } from '../../../contrib/chat/common/participants/chatAgents.js'; @@ -35,6 +35,7 @@ import { IExtHostContext } from '../../../services/extensions/common/extHostCust import { ExtensionHostKind } from '../../../services/extensions/common/extensionHostKind.js'; import { IExtensionService, nullExtensionDescription } from '../../../services/extensions/common/extensions.js'; import { IViewsService } from '../../../services/views/common/viewsService.js'; +import { Dto } from '../../../services/extensions/common/proxyIdentifier.js'; import { mock, TestExtensionService } from '../../../test/common/workbenchTestServices.js'; import { MainThreadChatSessions, ObservableChatSession } from '../../browser/mainThreadChatSessions.js'; import { ExtHostChatSessionsShape, IChatProgressDto, IChatSessionDto, IChatSessionProviderOptions, IChatSessionRequestHistoryItemDto } from '../../common/extHost.protocol.js'; @@ -73,6 +74,7 @@ suite('ObservableChatSession', function () { $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), $forkChatSession: sinon.stub().resolves(undefined), + $resolveChatSessionItem: sinon.stub().resolves(undefined), $provideChatSessionInputState: sinon.stub().resolves(undefined), }; }); @@ -523,6 +525,7 @@ suite('MainThreadChatSessions', function () { $onDidChangeChatSessionItemState: sinon.stub(), $newChatSessionItem: sinon.stub().resolves(undefined), $forkChatSession: sinon.stub().resolves(undefined), + $resolveChatSessionItem: sinon.stub().resolves(undefined), $provideChatSessionInputState: sinon.stub().resolves(undefined), }; @@ -912,7 +915,7 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; const controllerHandle = 0; - mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme); + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, false); mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resourceA = URI.parse(`${sessionScheme}:/session-a`); @@ -947,7 +950,7 @@ suite('MainThreadChatSessions', function () { const sessionScheme = 'test-session-type'; const controllerHandle = 0; - mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme); + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, false); mainThread.$registerChatSessionContentProvider(1, sessionScheme); const resourceA = URI.parse(`${sessionScheme}:/session-a`); @@ -985,6 +988,174 @@ suite('MainThreadChatSessions', function () { mainThread.$unregisterChatSessionContentProvider(1); mainThread.$unregisterChatSessionItemController(controllerHandle); }); + + test('resolveChatSessionItem invokes proxy and updates item', async function () { + const sessionScheme = 'test-session-type'; + const controllerHandle = 0; + + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, true); + + const resource = URI.parse(`${sessionScheme}:/session-a`); + const initialItem: Dto = { + resource, + label: 'Session A', + timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined }, + }; + + // Add initial item via $addOrUpdateChatSessionItem + await mainThread.$addOrUpdateChatSessionItem(controllerHandle, initialItem); + + const resolvedItem: Dto = { + resource, + label: 'Session A', + timing: { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined }, + badge: 'resolved', + }; + + asSinonMethodStub(proxy.$resolveChatSessionItem).resolves(resolvedItem); + + const result = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + + assert.ok(asSinonMethodStub(proxy.$resolveChatSessionItem).calledOnce); + assert.deepStrictEqual(result?.badge, 'resolved'); + + mainThread.$unregisterChatSessionItemController(controllerHandle); + }); + + test('resolveChatSessionItem returns undefined when supportsResolve is false', async function () { + const sessionScheme = 'test-session-type'; + const controllerHandle = 0; + + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, false); + + const resource = URI.parse(`${sessionScheme}:/session-a`); + + const result = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + + assert.strictEqual(result, undefined); + assert.ok(asSinonMethodStub(proxy.$resolveChatSessionItem).notCalled); + + mainThread.$unregisterChatSessionItemController(controllerHandle); + }); + + test('resolveChatSessionItem cache is invalidated on item update', async function () { + const sessionScheme = 'test-session-type'; + const controllerHandle = 0; + + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, true); + + const resource = URI.parse(`${sessionScheme}:/session-a`); + const timing = { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined }; + const initialItem: Dto = { + resource, + label: 'Session A', + timing, + }; + + await mainThread.$addOrUpdateChatSessionItem(controllerHandle, initialItem); + + const resolvedItem1: Dto = { resource, label: 'Session A', timing, badge: 'first' }; + const resolvedItem2: Dto = { resource, label: 'Session A', timing, badge: 'second' }; + + const resolveStub = asSinonMethodStub(proxy.$resolveChatSessionItem); + resolveStub.onFirstCall().resolves(resolvedItem1); + resolveStub.onSecondCall().resolves(resolvedItem2); + + // First resolve + const result1 = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + assert.deepStrictEqual(result1?.badge, 'first'); + + // Simulate item update (should invalidate cache) + await mainThread.$addOrUpdateChatSessionItem(controllerHandle, { ...initialItem, label: 'Session A Updated' }); + + // Second resolve after cache invalidation should call proxy again + const result2 = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + assert.deepStrictEqual(result2?.badge, 'second'); + + assert.strictEqual(resolveStub.callCount, 2); + + mainThread.$unregisterChatSessionItemController(controllerHandle); + }); + + test('resolveChatSessionItem caches undefined result until item update invalidates it', async function () { + const sessionScheme = 'test-session-type'; + const controllerHandle = 0; + + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, true); + + const resource = URI.parse(`${sessionScheme}:/session-a`); + const timing = { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined }; + const initialItem: Dto = { + resource, + label: 'Session A', + timing, + }; + + const resolveStub = asSinonMethodStub(proxy.$resolveChatSessionItem); + resolveStub.onFirstCall().resolves(undefined); + resolveStub.onSecondCall().resolves({ resource, label: 'Session A', timing, badge: 'resolved' } satisfies Dto); + + // First resolve returns undefined and should be cached. + const result1 = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + assert.strictEqual(result1, undefined); + + // Second resolve should reuse the cached undefined result. + const result2 = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + assert.strictEqual(result2, undefined); + assert.strictEqual(resolveStub.callCount, 1); + + // Updating the item should invalidate the cached undefined result. + await mainThread.$addOrUpdateChatSessionItem(controllerHandle, initialItem); + + const result3 = await chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + assert.deepStrictEqual(result3?.badge, 'resolved'); + + assert.strictEqual(resolveStub.callCount, 2); + + mainThread.$unregisterChatSessionItemController(controllerHandle); + }); + + test('resolveChatSessionItem ignores stale in-flight resolve result after item update', async function () { + const sessionScheme = 'test-session-type'; + const controllerHandle = 0; + + mainThread.$registerChatSessionItemController(controllerHandle, sessionScheme, true); + + const resource = URI.parse(`${sessionScheme}:/session-a`); + const timing = { created: 0, lastRequestStarted: undefined, lastRequestEnded: undefined }; + const initialItem: Dto = { + resource, + label: 'Session A', + timing, + }; + + await mainThread.$addOrUpdateChatSessionItem(controllerHandle, initialItem); + + let resolvePending: ((value: Dto) => void) | undefined; + asSinonMethodStub(proxy.$resolveChatSessionItem).returns(new Promise>(resolve => { + resolvePending = resolve; + })); + + const pendingResolve = chatSessionsService.resolveChatSessionItem(sessionScheme, resource, CancellationToken.None); + + await mainThread.$addOrUpdateChatSessionItem(controllerHandle, { + ...initialItem, + label: 'Session A Updated', + }); + + resolvePending?.({ + resource, + label: 'Session A', + timing, + badge: 'stale', + }); + + const result = await pendingResolve; + assert.strictEqual(result?.label, 'Session A Updated'); + assert.strictEqual(result?.badge, undefined); + + mainThread.$unregisterChatSessionItemController(controllerHandle); + }); }); suite('ExtHostChatSessions', function () { @@ -992,6 +1163,7 @@ suite('ExtHostChatSessions', function () { let extHostChatSessions: ExtHostChatSessions; let mainThreadChatSessionsProxy: { $registerChatSessionItemController: sinon.SinonStub; + $updateChatSessionItemControllerCapabilities: sinon.SinonStub; $unregisterChatSessionItemController: sinon.SinonStub; $updateChatSessionItems: sinon.SinonStub; $addOrUpdateChatSessionItem: sinon.SinonStub; @@ -1007,6 +1179,7 @@ suite('ExtHostChatSessions', function () { disposables = new DisposableStore(); mainThreadChatSessionsProxy = { $registerChatSessionItemController: sinon.stub(), + $updateChatSessionItemControllerCapabilities: sinon.stub(), $unregisterChatSessionItemController: sinon.stub(), $updateChatSessionItems: sinon.stub().resolves(), $addOrUpdateChatSessionItem: sinon.stub().resolves(), @@ -1038,6 +1211,21 @@ suite('ExtHostChatSessions', function () { }; } + test('controller only advertises resolve support after resolve handler is assigned', function () { + const sessionScheme = 'test-session-type'; + const controller = disposables.add(extHostChatSessions.createChatSessionItemController(nullExtensionDescription, sessionScheme, async () => { })); + + assert.ok(mainThreadChatSessionsProxy.$registerChatSessionItemController.calledOnceWithExactly(0, sessionScheme, false)); + assert.ok(mainThreadChatSessionsProxy.$updateChatSessionItemControllerCapabilities.notCalled); + + controller.resolveChatSessionItem = async () => { }; + assert.ok(mainThreadChatSessionsProxy.$updateChatSessionItemControllerCapabilities.calledOnceWithExactly(0, true)); + + controller.resolveChatSessionItem = undefined; + assert.ok(mainThreadChatSessionsProxy.$updateChatSessionItemControllerCapabilities.calledTwice); + assert.ok(mainThreadChatSessionsProxy.$updateChatSessionItemControllerCapabilities.secondCall.calledWithExactly(0, false)); + }); + test('advertises controller fork support when only the controller registers a fork handler', async function () { const sessionScheme = 'test-session-type'; const sessionResource = URI.parse(`${sessionScheme}:/test-session`); diff --git a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts index 8f8fa86be0d95..453cc8c975684 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -402,6 +402,15 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return Array.from(this.inProgressMap.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } + public async resolveChatSessionItem(chatSessionType: string, resource: URI, token: CancellationToken): Promise { + const entry = this._itemControllers.get(chatSessionType); + if (!entry?.controller.resolveChatSessionItem) { + return undefined; + } + + return entry.controller.resolveChatSessionItem(resource, token); + } + private async updateInProgressStatus(chatSessionType: string): Promise { try { const items: IChatSessionItem[] = []; diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 1701c7433f9e8..846dea7e09da4 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -248,6 +248,8 @@ export interface IChatSessionItemController { newChatSessionItem?(request: IChatNewSessionRequest, token: CancellationToken): Promise; getNewChatSessionInputState?(sessionResource: URI, token: CancellationToken): Promise; + + resolveChatSessionItem?(resource: URI, token: CancellationToken): Promise; } export interface IChatSessionOptionsChangeEvent { @@ -383,6 +385,12 @@ export interface IChatSessionsService { /** @deprecated Use `getChatSessionItems` */ getInProgress(): { chatSessionType: string; count: number }[]; + /** + * Lazily resolves a chat session item, filling in expensive details like timing, changes, and badge. + * Returns the resolved item, or undefined if no resolve handler is available. + */ + resolveChatSessionItem(chatSessionType: string, resource: URI, token: CancellationToken): Promise; + // #endregion // #region Content provider support diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts index 062411654238c..2b91c4e39d8ec 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/mockChatService.ts @@ -192,6 +192,6 @@ export class MockChatService implements IChatService { } getMetadataForSession(sessionResource: URI): Promise { - throw new Error('Method not implemented.'); + return Promise.resolve(this.liveSessionItems.find(item => item.sessionResource.toString() === sessionResource.toString())); } } diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 1523845dbf504..a1cde58a3a412 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -131,6 +131,10 @@ export class MockChatSessionsService implements IChatSessionsService { return Array.from(this.inProgress.entries()).map(([chatSessionType, count]) => ({ chatSessionType, count })); } + async resolveChatSessionItem(_chatSessionType: string, _resource: URI, _token: CancellationToken): Promise { + return undefined; + } + registerChatSessionContentProvider(chatSessionType: string, provider: IChatSessionContentProvider): IDisposable { this.contentProviders.set(chatSessionType, provider); this._onDidChangeContentProviderSchemes.fire({ added: [chatSessionType], removed: [] }); diff --git a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts index d3240d451a0af..fc12969f6bac6 100644 --- a/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts +++ b/src/vscode-dts/vscode.proposed.chatSessionsProvider.d.ts @@ -76,6 +76,23 @@ declare module 'vscode' { // TODO: Do we need a flag to try auth if needed? provideChatSessionItems(token: CancellationToken): ProviderResult; + /** + * @deprecated Use {@linkcode ChatSessionItemController.resolveChatSessionItem} instead. + * + * Given a chat session item fill in more data, like {@link ChatSessionItem.timing timing}, + * {@link ChatSessionItem.changes changes}, or {@link ChatSessionItem.badge badge}. + * + * The editor will call this when a chat session item becomes visible in the UI, for example + * when the user scrolls to it or when it is first rendered. + * + * @param item A chat session item currently visible in the UI. Treat this as read-only. + * @param token A cancellation token. + * @returns A new {@link ChatSessionItem} instance (or a thenable that resolves to one) with the + * same `resource` as `item` and any additional properties filled in. When no result is returned, + * the given `item` is left unchanged. + */ + resolveChatSessionItem?: (item: ChatSessionItem, token: CancellationToken) => ProviderResult; + // #region Unstable parts of API /** @@ -194,6 +211,26 @@ declare module 'vscode' { */ getChatSessionInputState?: ChatSessionControllerGetInputState; + /** + * Called to fill in more data on a chat session item, like {@link ChatSessionItem.timing timing}, + * {@link ChatSessionItem.changes changes}, or {@link ChatSessionItem.badge badge}. + * + * The editor will call this when a chat session item becomes visible in the UI, for example + * when the user scrolls to it or when it is first rendered. + * + * The editor will only resolve a chat session item once, unless the item is updated via + * {@link ChatSessionItemCollection.add add} or {@link ChatSessionItemCollection.replace replace}, + * which invalidates the resolve cache. + * + * The handler should update the item in the {@link ChatSessionItemController.items items collection} via + * {@link ChatSessionItemCollection.add add}. The editor picks up the updated item from + * the collection after the returned thenable resolves. + * + * @param item A chat session item currently visible in the UI. + * @param token A cancellation token. + */ + resolveChatSessionItem?: (item: ChatSessionItem, token: CancellationToken) => Thenable; + /** * Create a new managed ChatSessionInputState object. */ From 067270d5bbe077fc31f4fae7f9830ce0732e07c9 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:17:54 -0700 Subject: [PATCH 11/35] Fix nonce --- src/vs/workbench/contrib/webview/browser/pre/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/webview/browser/pre/index.html b/src/vs/workbench/contrib/webview/browser/pre/index.html index fa7a339b6a473..8b9850b307767 100644 --- a/src/vs/workbench/contrib/webview/browser/pre/index.html +++ b/src/vs/workbench/contrib/webview/browser/pre/index.html @@ -5,7 +5,7 @@ + content="default-src 'none'; script-src 'sha256-q+WTr+fBXpLLE3++yWNaxT6BTWQtsKscoeIlynBRk4E=' 'self'; frame-src 'self'; style-src 'unsafe-inline';"> Date: Wed, 22 Apr 2026 16:20:57 -0700 Subject: [PATCH 12/35] Strip redundant `cd &&` prefix from agent host shell tool calls (#312019) * Strip redundant 'cd &&' prefix from agent host shell tool calls The Copilot CLI sometimes emits shell commands prefixed with a redundant `cd <` even though the agent already runs in thatworkingDirectory> && directory. The extension-host CLI strips this for display; this change brings the same cleanup to the agent host so all clients see the simplified command. Three live paths needed patching: - mapSessionEvents.ts (history replay via getMessages()) - copilotAgentSession.ts onToolStart (live tool execution) - copilotToolDisplay.ts getPermissionDisplay (shell permission requests) All three share a new helper, stripRedundantCdPrefix, in src/vs/platform/agentHost/common/commandLineHelpers.ts. Tests: 32 new unit tests across commandLineHelpers, copilotToolDisplay, mapSessionEvents, and copilotAgentSession (109 passing total in the agentHost module). Also added an opt-in real-SDK integration test in toolApprovalRealSdk.integrationTest.ts that exercises the full toolCallReady path with a live model (mock-agent integration tests can't reach this code because the mock IAgent bypasses CopilotAgentSession entirely). (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review: normalize path separators and tighten cd-prefix regex Two Copilot review comments on PR #312019: 1. `commandLineHelpers.ts` `sameDirectory` did string-compare the raw extracted directory against `workingDirectory.fsPath`. On Windows, `URI.file('/repo/project').fsPath` is `\repo\project` while the model often emits `cd / separator mismatch maderepo/` project && the prefix slip through. (This was the root cause of the Windows / Browser + Windows / Electron CI failures: 3 stripRedundantCdPrefix tests asserted on stripping behavior that only worked on POSIX.) Fix: route both sides through `URI.file(...)` (after trimming trailing separators) and compare via `extUriBiasedIgnorePathCase`, which handles separator normalization and case-insensitivity on Windows / macOS. Added two Windows-only test cases (forward-slash extracted vs native backslash wd, and pure-backslash same-direction) to cover the regression. 2. `toolApprovalRealSdk.integrationTest.ts` used a substring check miss quoted variants like `cd "<` or pwsh-stylewd>" && `cd <`. Replaced with an anchored regex that tolerateswd>; optional surrounding quotes and either chain operator. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore 'Run in terminal?' title (lost during merge) The merge of origin/main into the branch accidentally reverted the title change from main ("Run in terminal" -> "Run in terminal?"). Restore both occurrences in getPermissionDisplay's shell + custom-tool branches. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/commandLineHelpers.ts | 121 +++++++++++++++ .../agentHost/node/copilot/copilotAgent.ts | 7 +- .../node/copilot/copilotAgentSession.ts | 17 ++- .../node/copilot/copilotToolDisplay.ts | 20 ++- .../node/copilot/mapSessionEvents.ts | 13 +- .../test/common/commandLineHelpers.test.ts | 142 ++++++++++++++++++ .../test/node/copilotAgentSession.test.ts | 81 ++++++++++ .../test/node/copilotToolDisplay.test.ts | 77 ++++++++++ .../test/node/mapSessionEvents.test.ts | 84 +++++++++++ .../toolApprovalRealSdk.integrationTest.ts | 90 +++++++++++ 10 files changed, 637 insertions(+), 15 deletions(-) create mode 100644 src/vs/platform/agentHost/common/commandLineHelpers.ts create mode 100644 src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts diff --git a/src/vs/platform/agentHost/common/commandLineHelpers.ts b/src/vs/platform/agentHost/common/commandLineHelpers.ts new file mode 100644 index 0000000000000..4783dd2b018dc --- /dev/null +++ b/src/vs/platform/agentHost/common/commandLineHelpers.ts @@ -0,0 +1,121 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { extUriBiasedIgnorePathCase } from '../../../base/common/resources.js'; +import { URI } from '../../../base/common/uri.js'; + +/** + * Result of {@link extractCdPrefix}: the directory the `cd` jumps to and the + * remaining command after the chain operator. + */ +export interface IExtractedCdPrefix { + readonly directory: string; + readonly command: string; +} + +/** + * Extracts a `cd &&` (or PowerShell equivalent) prefix from a command + * line, returning the directory and remaining command. Does not check whether + * the directory matches anything — callers do that comparison themselves. + * + * Recognized forms: + * - bash: `cd && ` + * - powershell: `cd && `, `cd ; ` + * `cd /d && `, `cd /d ; ` + * `Set-Location && `, `Set-Location ; ` + * `Set-Location -Path && `, `Set-Location -Path ; ` + * + * Surrounding double quotes around `` are stripped. + */ +export function extractCdPrefix(commandLine: string, isPowerShell: boolean): IExtractedCdPrefix | undefined { + const cdPrefixMatch = commandLine.match( + isPowerShell + ? /^(?:cd(?: \/d)?|Set-Location(?: -Path)?) (?"[^"]*"|[^\s]+) ?(?:&&|;)\s+(?.+)$/i + : /^cd (?"[^"]*"|[^\s]+) &&\s+(?.+)$/ + ); + const cdDir = cdPrefixMatch?.groups?.dir; + const cdSuffix = cdPrefixMatch?.groups?.suffix; + if (cdDir && cdSuffix) { + let cdDirPath = cdDir; + if (cdDirPath.startsWith('"') && cdDirPath.endsWith('"')) { + cdDirPath = cdDirPath.slice(1, -1); + } + return { directory: cdDirPath, command: cdSuffix }; + } + return undefined; +} + +/** + * If `toolName` is a shell tool (`bash` or `powershell`) and + * `parameters.command` starts with a `cd && …` (or + * PowerShell equivalent) prefix, mutate `parameters.command` to drop the + * prefix and return `true`. Returns `false` otherwise. + * + * Path comparison normalizes trailing slashes and is case-insensitive on + * Windows. + */ +export function stripRedundantCdPrefix( + toolName: string, + parameters: Record | undefined, + workingDirectory: URI | undefined, +): boolean { + if (!workingDirectory || !parameters) { + return false; + } + const isBash = toolName === 'bash'; + const isPowerShell = toolName === 'powershell'; + if (!isBash && !isPowerShell) { + return false; + } + const command = parameters.command; + if (typeof command !== 'string') { + return false; + } + const extracted = extractCdPrefix(command, isPowerShell); + if (!extracted) { + return false; + } + if (!sameDirectory(extracted.directory, workingDirectory)) { + return false; + } + parameters.command = extracted.command; + return true; +} + +/** + * Compares an extracted `cd ` argument (a raw filesystem path string, + * possibly using either `/` or `\` separators) to a working-directory URI. + * Normalizes separators by routing the extracted string through `URI.file`, + * which converts to the platform-native `fsPath` shape, so that e.g. + * `cd C:/repo` matches a working directory of `C:\repo` on Windows. + * + * Path comparison uses {@link extUriBiasedIgnorePathCase}, which is + * case-insensitive on Windows / macOS. + */ +function sameDirectory(extractedDir: string, workingDirectory: URI): boolean { + if (!extractedDir) { + return false; + } + // Strip trailing path separators (either flavor) so e.g. `/repo/project/` + // matches `/repo/project`. Without this, URI.file would preserve the + // trailing slash and the URIs would not compare equal. We do this for + // both sides because the working directory may also end in a separator. + const trim = (p: string) => p.replace(/[\\/]+$/, ''); + const trimmedExtracted = trim(extractedDir); + const trimmedWd = trim(workingDirectory.fsPath); + if (!trimmedExtracted || !trimmedWd) { + return false; + } + let extractedUri: URI; + let wdUri: URI; + try { + extractedUri = URI.file(trimmedExtracted); + wdUri = URI.file(trimmedWd); + } catch { + return false; + } + return extUriBiasedIgnorePathCase.isEqual(extractedUri, wdUri); +} + diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index ae2f11f7527aa..c5e1fe9201f78 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -554,7 +554,7 @@ export class CopilotAgent extends Disposable implements IAgent { let agentSession: CopilotAgentSession; try { - agentSession = this._createAgentSession(factory, sessionId, shellManager, snapshot); + agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, snapshot); await agentSession.initializeSession(); } catch (error) { if (seededActiveClient) { @@ -886,7 +886,7 @@ export class CopilotAgent extends Disposable implements IAgent { * and returns it. The caller must call {@link CopilotAgentSession.initializeSession} * to wire up the SDK session. */ - private _createAgentSession(wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager, snapshot?: IActiveClientSnapshot): CopilotAgentSession { + private _createAgentSession(wrapperFactory: SessionWrapperFactory, sessionId: string, shellManager: ShellManager, workingDirectory: URI | undefined, snapshot?: IActiveClientSnapshot): CopilotAgentSession { const sessionUri = AgentSession.uri(this.id, sessionId); const agentSession = this._instantiationService.createInstance( @@ -897,6 +897,7 @@ export class CopilotAgent extends Disposable implements IAgent { onDidSessionProgress: this._onDidSessionProgress, wrapperFactory, shellManager, + workingDirectory, clientSnapshot: snapshot, }, ); @@ -994,7 +995,7 @@ export class CopilotAgent extends Disposable implements IAgent { } }; - const agentSession = this._createAgentSession(factory, sessionId, shellManager, snapshot); + const agentSession = this._createAgentSession(factory, sessionId, shellManager, workingDirectory, snapshot); await agentSession.initializeSession(); return agentSession; diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts index 1f19cbb44bd2e..08684c105778e 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentSession.ts @@ -18,6 +18,7 @@ import { IFileService } from '../../../files/common/files.js'; import { IInstantiationService } from '../../../instantiation/common/instantiation.js'; import { ILogService } from '../../../log/common/log.js'; import { IAgentAttachment, IAgentMessageEvent, IAgentProgressEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import type { FileEdit, ToolDefinition } from '../../common/state/protocol/state.js'; import { SessionInputAnswerState, SessionInputAnswerValueKind, SessionInputQuestionKind, SessionInputResponseKind, ToolResultContentType, type PendingMessage, type SessionInputAnswer, type SessionInputRequest, type ToolCallResult, type ToolResultContent } from '../../common/state/sessionState.js'; @@ -88,6 +89,8 @@ export interface ICopilotAgentSessionOptions { readonly onDidSessionProgress: Emitter; readonly wrapperFactory: SessionWrapperFactory; readonly shellManager: ShellManager | undefined; + /** Working directory associated with the session, used to strip redundant `cd` prefixes from shell commands. */ + readonly workingDirectory?: URI; /** Snapshot of the active client's tools and plugins at session creation time. */ readonly clientSnapshot?: IActiveClientSnapshot; } @@ -132,6 +135,7 @@ export class CopilotAgentSession extends Disposable { private readonly _onDidSessionProgress: Emitter; private readonly _wrapperFactory: SessionWrapperFactory; private readonly _shellManager: ShellManager | undefined; + private readonly _workingDirectory: URI | undefined; constructor( options: ICopilotAgentSessionOptions, @@ -147,6 +151,7 @@ export class CopilotAgentSession extends Disposable { this._onDidSessionProgress = options.onDidSessionProgress; this._wrapperFactory = options.wrapperFactory; this._shellManager = options.shellManager; + this._workingDirectory = options.workingDirectory; this._appliedSnapshot = options.clientSnapshot ?? { clientId: '', tools: [], plugins: [] }; this._clientToolNames = new Set(this._appliedSnapshot.tools.map(t => t.name)); @@ -333,7 +338,7 @@ export class CopilotAgentSession extends Disposable { } catch { // Database may not exist yet — that's fine } - return mapSessionEvents(this.sessionUri, db, events); + return mapSessionEvents(this.sessionUri, db, events, this._workingDirectory); } async abort(): Promise { @@ -389,7 +394,7 @@ export class CopilotAgentSession extends Disposable { this._pendingPermissions.set(toolCallId, deferred); // Derive display information from the permission request kind - const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request); + const { confirmationTitle, invocationMessage, toolInput, permissionKind, permissionPath } = getPermissionDisplay(request, this._workingDirectory); // For write permission requests, build an FileEdit preview so the // client can show a diff before the user approves or denies. This @@ -691,11 +696,17 @@ export class CopilotAgentSession extends Disposable { return; } this._logService.info(`[Copilot:${sessionId}] Tool started: ${e.data.toolName}`); - const toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined; + let toolArgs = e.data.arguments !== undefined ? tryStringify(e.data.arguments) : undefined; let parameters: Record | undefined; if (toolArgs) { try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } } + // Strip redundant `cd && …` prefixes from shell tool + // commands so clients see the simplified form. Mirrors the logic in + // mapSessionEvents (which handles the history-replay path). + if (stripRedundantCdPrefix(e.data.toolName, parameters, this._workingDirectory)) { + toolArgs = tryStringify(parameters); + } const displayName = getToolDisplayName(e.data.toolName); this._activeToolCalls.set(e.data.toolCallId, { toolName: e.data.toolName, displayName, parameters, content: [] }); const toolKind = getToolKind(e.data.toolName); diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index 62478a41d85b5..aaf96fe302948 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -9,6 +9,7 @@ import { URI } from '../../../../base/common/uri.js'; import { appendEscapedMarkdownInlineCode } from '../../../../base/common/htmlContent.js'; import { localize } from '../../../../nls.js'; import type { IAgentToolReadyEvent } from '../../common/agentService.js'; +import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { StringOrMarkdown } from '../../common/state/protocol/state.js'; import { basename } from '../../../../base/common/resources.js'; @@ -441,7 +442,7 @@ function str(value: unknown): string | undefined { /** * Derives display fields from a permission request for the tool confirmation UI. */ -export function getPermissionDisplay(request: ITypedPermissionRequest): { +export function getPermissionDisplay(request: ITypedPermissionRequest, workingDirectory?: URI): { confirmationTitle: string; invocationMessage: StringOrMarkdown; toolInput?: string; @@ -457,21 +458,28 @@ export function getPermissionDisplay(request: ITypedPermissionRequest): { const toolName = str(request.toolName); switch (request.kind) { - case 'shell': + case 'shell': { + // Strip a redundant `cd && …` prefix so the + // confirmation dialog shows the simplified command. + const shellParams: Record | undefined = fullCommandText ? { command: fullCommandText } : undefined; + stripRedundantCdPrefix(CopilotToolName.Bash, shellParams, workingDirectory); + const cleanedCommand = typeof shellParams?.command === 'string' ? shellParams.command : fullCommandText; return { confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), - invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), fullCommandText ? { command: fullCommandText } : undefined), - toolInput: fullCommandText, + invocationMessage: intention ?? getInvocationMessage(CopilotToolName.Bash, getToolDisplayName(CopilotToolName.Bash), cleanedCommand ? { command: cleanedCommand } : undefined), + toolInput: cleanedCommand, permissionKind: 'shell', permissionPath: path, }; + } case 'custom-tool': { // Custom tool overrides (e.g. our shell tool). Extract the actual // tool args from the SDK's wrapper envelope. const args = typeof request.args === 'object' && request.args !== null ? request.args as Record : undefined; - const command = typeof args?.command === 'string' ? args.command : undefined; const sdkToolName = str(request.toolName); - if (command && sdkToolName && isShellTool(sdkToolName)) { + if (args && sdkToolName && isShellTool(sdkToolName) && typeof args.command === 'string') { + stripRedundantCdPrefix(sdkToolName, args, workingDirectory); + const command = args.command as string; return { confirmationTitle: localize('copilot.permission.shell.title', "Run in terminal?"), invocationMessage: getInvocationMessage(sdkToolName, getToolDisplayName(sdkToolName), { command }), diff --git a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts index 1e8e93f530eb1..551ff04b11346 100644 --- a/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts +++ b/src/vs/platform/agentHost/node/copilot/mapSessionEvents.ts @@ -5,6 +5,7 @@ import { URI } from '../../../../base/common/uri.js'; import { IAgentMessageEvent, IAgentSubagentStartedEvent, IAgentToolCompleteEvent, IAgentToolStartEvent } from '../../common/agentService.js'; +import { stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; import { IFileEditRecord, ISessionDatabase } from '../../common/sessionDataService.js'; import { ToolResultContentType, type ToolResultContent } from '../../common/state/sessionState.js'; import { getInvocationMessage, getPastTenseMessage, getShellLanguage, getSubagentMetadata, getToolDisplayName, getToolInputString, getToolKind, isEditTool, isHiddenTool } from './copilotToolDisplay.js'; @@ -77,6 +78,10 @@ export interface ISessionEventSubagentStarted { * Maps raw SDK session events into agent protocol events, restoring * stored file-edit metadata from the session database when available. * + * If `workingDirectory` is provided, redundant `cd &&` + * (or PowerShell equivalent) prefixes are stripped from shell tool + * commands so clients see the simplified form. + * * Extracted as a standalone function so it can be tested without the * full CopilotAgent or SDK dependencies. */ @@ -84,9 +89,10 @@ export async function mapSessionEvents( session: URI, db: ISessionDatabase | undefined, events: readonly ISessionEvent[], + workingDirectory?: URI, ): Promise<(IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[]> { const result: (IAgentMessageEvent | IAgentToolStartEvent | IAgentToolCompleteEvent | IAgentSubagentStartedEvent)[] = []; - const toolInfoByCallId = new Map | undefined }>(); + const toolInfoByCallId = new Map | undefined; rewrittenArgs?: string }>(); // Collect all tool call IDs for edit tools so we can batch-query the database const editToolCallIds: string[] = []; @@ -103,7 +109,8 @@ export async function mapSessionEvents( if (toolArgs) { try { parameters = JSON.parse(toolArgs) as Record; } catch { /* ignore */ } } - toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters }); + const rewrittenArgs = stripRedundantCdPrefix(d.toolName, parameters, workingDirectory) ? tryStringify(parameters) : undefined; + toolInfoByCallId.set(d.toolCallId, { toolName: d.toolName, parameters, rewrittenArgs }); if (isEditTool(d.toolName)) { editToolCallIds.push(d.toolCallId); } @@ -162,7 +169,7 @@ export async function mapSessionEvents( const info = toolInfoByCallId.get(d.toolCallId); const displayName = getToolDisplayName(d.toolName); const toolKind = getToolKind(d.toolName); - const toolArgs = d.arguments !== undefined ? tryStringify(d.arguments) : undefined; + const toolArgs = info?.rewrittenArgs ?? (d.arguments !== undefined ? tryStringify(d.arguments) : undefined); const subagentMeta = toolKind === 'subagent' ? getSubagentMetadata(info?.parameters) : undefined; result.push({ session, diff --git a/src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts b/src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts new file mode 100644 index 0000000000000..c9033fe079b74 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/commandLineHelpers.test.ts @@ -0,0 +1,142 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { isWindows } from '../../../../base/common/platform.js'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { extractCdPrefix, stripRedundantCdPrefix } from '../../common/commandLineHelpers.js'; + +suite('extractCdPrefix', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const cases: Array<{ + name: string; + commandLine: string; + isPowerShell: boolean; + expected: { directory: string; command: string } | undefined; + }> = [ + // bash matches + { name: 'bash: simple cd', commandLine: 'cd /tmp && ls', isPowerShell: false, expected: { directory: '/tmp', command: 'ls' } }, + { name: 'bash: quoted dir', commandLine: 'cd "/path with spaces" && ls -la', isPowerShell: false, expected: { directory: '/path with spaces', command: 'ls -la' } }, + { name: 'bash: extra spaces after &&', commandLine: 'cd /tmp && echo hi', isPowerShell: false, expected: { directory: '/tmp', command: 'echo hi' } }, + + // bash non-matches + { name: 'bash: no cd prefix', commandLine: 'ls -la', isPowerShell: false, expected: undefined }, + { name: 'bash: cd with semicolon (not allowed in bash variant)', commandLine: 'cd /tmp; ls', isPowerShell: false, expected: undefined }, + { name: 'bash: cd alone', commandLine: 'cd /tmp', isPowerShell: false, expected: undefined }, + { name: 'bash: Set-Location not bash', commandLine: 'Set-Location /tmp && ls', isPowerShell: false, expected: undefined }, + + // powershell matches + { name: 'pwsh: cd && ', commandLine: 'cd C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, + { name: 'pwsh: cd ;', commandLine: 'cd C:\\foo; dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, + { name: 'pwsh: cd /d', commandLine: 'cd /d C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, + { name: 'pwsh: Set-Location', commandLine: 'Set-Location C:\\foo; dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, + { name: 'pwsh: Set-Location -Path', commandLine: 'Set-Location -Path C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, + { name: 'pwsh: quoted dir', commandLine: 'cd "C:\\path with spaces"; dir', isPowerShell: true, expected: { directory: 'C:\\path with spaces', command: 'dir' } }, + { name: 'pwsh: case insensitive', commandLine: 'CD C:\\foo && dir', isPowerShell: true, expected: { directory: 'C:\\foo', command: 'dir' } }, + + // powershell non-matches + { name: 'pwsh: no cd prefix', commandLine: 'dir', isPowerShell: true, expected: undefined }, + { name: 'pwsh: cd alone', commandLine: 'cd C:\\foo', isPowerShell: true, expected: undefined }, + ]; + + for (const tc of cases) { + test(tc.name, () => { + const result = extractCdPrefix(tc.commandLine, tc.isPowerShell); + assert.deepStrictEqual(result, tc.expected); + }); + } +}); + +suite('stripRedundantCdPrefix', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const wd = URI.file('/repo/project'); + + test('rewrites bash command when cd matches working directory', () => { + const params: Record = { command: 'cd /repo/project && npm test' }; + const changed = stripRedundantCdPrefix('bash', params, wd); + assert.strictEqual(changed, true); + assert.strictEqual(params.command, 'npm test'); + }); + + test('rewrites bash command tolerating trailing slash', () => { + const params: Record = { command: 'cd /repo/project/ && ls' }; + const changed = stripRedundantCdPrefix('bash', params, wd); + assert.strictEqual(changed, true); + assert.strictEqual(params.command, 'ls'); + }); + + test('does not rewrite when cd target differs', () => { + const params: Record = { command: 'cd /tmp && ls' }; + const changed = stripRedundantCdPrefix('bash', params, wd); + assert.strictEqual(changed, false); + assert.strictEqual(params.command, 'cd /tmp && ls'); + }); + + test('does not rewrite for non-shell tools', () => { + const params: Record = { command: 'cd /repo/project && ls' }; + const changed = stripRedundantCdPrefix('read_file', params, wd); + assert.strictEqual(changed, false); + assert.strictEqual(params.command, 'cd /repo/project && ls'); + }); + + test('handles missing working directory', () => { + const params: Record = { command: 'cd /repo/project && ls' }; + const changed = stripRedundantCdPrefix('bash', params, undefined); + assert.strictEqual(changed, false); + }); + + test('handles missing parameters', () => { + const changed = stripRedundantCdPrefix('bash', undefined, wd); + assert.strictEqual(changed, false); + }); + + test('handles non-string command', () => { + const params: Record = { command: 42 }; + const changed = stripRedundantCdPrefix('bash', params, wd); + assert.strictEqual(changed, false); + }); + + test('rewrites powershell with semicolon separator', () => { + const params: Record = { command: 'cd /repo/project; dir' }; + const changed = stripRedundantCdPrefix('powershell', params, wd); + assert.strictEqual(changed, true); + assert.strictEqual(params.command, 'dir'); + }); + + test('matches mixed path separators (forward-slash extracted vs native fsPath wd)', () => { + // On Windows, the model may emit `cd C:/repo/project && …` while + // URI.file('C:\\repo\\project').fsPath uses backslashes. The helper + // must normalize separators so the prefix is still recognized. + // On POSIX, `C:\…` is not a meaningful path, so the cross-separator + // test only makes sense on Windows. + if (!isWindows) { + return; + } + const winWd = URI.file('C:\\repo\\project'); + const params: Record = { command: 'cd C:/repo/project && npm test' }; + const changed = stripRedundantCdPrefix('bash', params, winWd); + assert.strictEqual(changed, true); + assert.strictEqual(params.command, 'npm test'); + }); + + test('matches backslash extracted dir against backslash native wd on Windows', () => { + // Inverse direction: the model emits backslashes and the native wd is + // also backslashes. This is the most common Windows case and must + // match without relying on POSIX-shape paths. + if (!isWindows) { + return; + } + const winWd = URI.file('C:\\repo\\project'); + const params: Record = { command: 'cd C:\\repo\\project && ls' }; + const changed = stripRedundantCdPrefix('bash', params, winWd); + assert.strictEqual(changed, true); + assert.strictEqual(params.command, 'ls'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts index 7e3ec2e0f5752..c8aacfc9f9402 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgentSession.test.ts @@ -109,6 +109,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { environmentServiceRegistration?: 'native' | 'none'; logService?: ILogService; captureWrapperCallbacks?: { current?: Parameters[0] }; + workingDirectory?: URI; }): Promise<{ session: CopilotAgentSession; mockSession: MockCopilotSession; @@ -172,6 +173,7 @@ async function createAgentSession(disposables: DisposableStore, options?: { wrapperFactory: factory, shellManager: undefined, clientSnapshot: options?.clientSnapshot, + workingDirectory: options?.workingDirectory, }, )); @@ -508,6 +510,85 @@ suite('CopilotAgentSession', () => { } }); + test('live tool_start strips redundant cd prefix matching workingDirectory', async () => { + const wd = URI.file('/repo/project'); + const { mockSession, progressEvents } = await createAgentSession(disposables, { workingDirectory: wd }); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-cd', + toolName: 'bash', + arguments: { command: 'cd /repo/project && npm test' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + assert.strictEqual(progressEvents.length, 1); + const ev = progressEvents[0]; + assert.strictEqual(ev.type, 'tool_start'); + if (ev.type === 'tool_start') { + assert.strictEqual(ev.toolInput, 'npm test'); + assert.ok(ev.toolArguments && ev.toolArguments.includes('"npm test"'), `toolArguments should contain rewritten command, was: ${ev.toolArguments}`); + assert.ok(!ev.toolArguments?.includes('cd /repo/project'), 'toolArguments should not contain stripped prefix'); + } + }); + + test('live tool_complete past-tense message reflects the rewritten command', async () => { + const wd = URI.file('/repo/project'); + const { mockSession, progressEvents } = await createAgentSession(disposables, { workingDirectory: wd }); + + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-cd-complete', + toolName: 'bash', + arguments: { command: 'cd /repo/project && npm test' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + mockSession.fire('tool.execution_complete', { + toolCallId: 'tc-cd-complete', + success: true, + result: { content: 'all tests passed' }, + } as SessionEventPayload<'tool.execution_complete'>['data']); + + assert.strictEqual(progressEvents.length, 2); + const completeEv = progressEvents[1]; + assert.strictEqual(completeEv.type, 'tool_complete'); + if (completeEv.type === 'tool_complete') { + const past = completeEv.result.pastTenseMessage; + const pastStr = typeof past === 'string' ? past : (past?.markdown ?? ''); + assert.ok(!pastStr.includes('cd /repo/project'), `past-tense message should not contain stripped prefix, got: ${pastStr}`); + assert.ok(pastStr.includes('npm test'), `past-tense message should contain the rewritten command, got: ${pastStr}`); + } + }); + + test('live tool_start does not rewrite when cd target differs from workingDirectory', async () => { + const wd = URI.file('/repo/project'); + const { mockSession, progressEvents } = await createAgentSession(disposables, { workingDirectory: wd }); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-cd-other', + toolName: 'bash', + arguments: { command: 'cd /tmp && ls' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + assert.strictEqual(progressEvents.length, 1); + const ev = progressEvents[0]; + assert.strictEqual(ev.type, 'tool_start'); + if (ev.type === 'tool_start') { + assert.strictEqual(ev.toolInput, 'cd /tmp && ls'); + } + }); + + test('live tool_start without workingDirectory passes command through', async () => { + const { mockSession, progressEvents } = await createAgentSession(disposables); + mockSession.fire('tool.execution_start', { + toolCallId: 'tc-cd-nowd', + toolName: 'bash', + arguments: { command: 'cd /repo/project && npm test' }, + } as SessionEventPayload<'tool.execution_start'>['data']); + + assert.strictEqual(progressEvents.length, 1); + const ev = progressEvents[0]; + assert.strictEqual(ev.type, 'tool_start'); + if (ev.type === 'tool_start') { + assert.strictEqual(ev.toolInput, 'cd /repo/project && npm test'); + } + }); + test('hidden tools are not emitted as tool_start', async () => { const { mockSession, progressEvents } = await createAgentSession(disposables); mockSession.fire('tool.execution_start', { diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts new file mode 100644 index 0000000000000..29981cd61fb7e --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -0,0 +1,77 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { URI } from '../../../../base/common/uri.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { getPermissionDisplay, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; + +suite('getPermissionDisplay — cd-prefix stripping', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + const wd = URI.file('/repo/project'); + + test('strips redundant cd from shell permission request fullCommandText', () => { + const request: ITypedPermissionRequest = { + kind: 'shell', + fullCommandText: 'cd /repo/project && npm test', + } as ITypedPermissionRequest; + const display = getPermissionDisplay(request, wd); + assert.strictEqual(display.toolInput, 'npm test'); + assert.strictEqual(display.permissionKind, 'shell'); + }); + + test('leaves shell command alone when cd target differs from working directory', () => { + const request: ITypedPermissionRequest = { + kind: 'shell', + fullCommandText: 'cd /tmp && ls', + } as ITypedPermissionRequest; + const display = getPermissionDisplay(request, wd); + assert.strictEqual(display.toolInput, 'cd /tmp && ls'); + }); + + test('leaves shell command alone when no working directory provided', () => { + const request: ITypedPermissionRequest = { + kind: 'shell', + fullCommandText: 'cd /repo/project && npm test', + } as ITypedPermissionRequest; + const display = getPermissionDisplay(request, undefined); + assert.strictEqual(display.toolInput, 'cd /repo/project && npm test'); + }); + + test('strips redundant cd from custom-tool shell permission request', () => { + const request: ITypedPermissionRequest = { + kind: 'custom-tool', + toolName: 'bash', + args: { command: 'cd /repo/project && echo hi' }, + } as ITypedPermissionRequest; + const display = getPermissionDisplay(request, wd); + assert.strictEqual(display.toolInput, 'echo hi'); + assert.strictEqual(display.permissionKind, 'shell'); + }); + + test('does not affect non-shell custom-tool requests', () => { + const request: ITypedPermissionRequest = { + kind: 'custom-tool', + toolName: 'some_other_tool', + args: { command: 'cd /repo/project && echo hi' }, + } as ITypedPermissionRequest; + const display = getPermissionDisplay(request, wd); + // Falls through to the generic branch — toolInput is the JSON-stringified args. + assert.ok(display.toolInput?.includes('cd /repo/project'), `expected unrewritten args, got: ${display.toolInput}`); + assert.strictEqual(display.permissionKind, 'custom-tool'); + }); + + test('handles powershell custom-tool with semicolon separator', () => { + const request: ITypedPermissionRequest = { + kind: 'custom-tool', + toolName: 'powershell', + args: { command: 'cd /repo/project; dir' }, + } as ITypedPermissionRequest; + const display = getPermissionDisplay(request, wd); + assert.strictEqual(display.toolInput, 'dir'); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts index 4c358e5916ee6..046fde01db6fc 100644 --- a/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts +++ b/src/vs/platform/agentHost/test/node/mapSessionEvents.test.ts @@ -5,6 +5,7 @@ import assert from 'assert'; import { DisposableStore } from '../../../../base/common/lifecycle.js'; +import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { AgentSession } from '../../common/agentService.js'; import { FileEditKind, ToolResultContentType } from '../../common/state/sessionState.js'; @@ -252,4 +253,87 @@ suite('mapSessionEvents', () => { assert.strictEqual(event.agentDisplayName, 'Code Reviewer'); }); }); + + // ---- cd-prefix rewriting -------------------------------------------- + + suite('cd-prefix rewriting', () => { + + const cwd = URI.file('/workspace/proj'); + + function makeBashEvent(command: string, toolCallId = 'tc-1'): ISessionEvent { + return { + type: 'tool.execution_start', + data: { toolCallId, toolName: 'bash', arguments: { command } }, + }; + } + + function getStart(events: ReturnType extends Promise ? R : never) { + return events[0] as { toolInput: string; toolArguments?: string }; + } + + test('strips redundant bash cd prefix matching workingDirectory', async () => { + const result = await mapSessionEvents(session, undefined, [ + makeBashEvent('cd /workspace/proj && ls -la'), + ], cwd); + const start = getStart(result); + assert.strictEqual(start.toolInput, 'ls -la'); + assert.deepStrictEqual(JSON.parse(start.toolArguments!), { command: 'ls -la' }); + }); + + test('leaves command unchanged when cd dir does not match', async () => { + const result = await mapSessionEvents(session, undefined, [ + makeBashEvent('cd /other && ls'), + ], cwd); + const start = getStart(result); + assert.strictEqual(start.toolInput, 'cd /other && ls'); + }); + + test('leaves command unchanged when no workingDirectory provided', async () => { + const result = await mapSessionEvents(session, undefined, [ + makeBashEvent('cd /workspace/proj && ls'), + ]); + const start = getStart(result); + assert.strictEqual(start.toolInput, 'cd /workspace/proj && ls'); + }); + + test('non-shell tools are not rewritten even with matching command field', async () => { + const result = await mapSessionEvents(session, undefined, [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'edit', arguments: { command: 'cd /workspace/proj && ls' } }, + }, + ], cwd); + const start = getStart(result); + // edit tool's toolInput is derived from filePath, not command — but toolArguments preserves original + assert.deepStrictEqual(JSON.parse(start.toolArguments!), { command: 'cd /workspace/proj && ls' }); + }); + + test('handles trailing slash on workingDirectory', async () => { + const result = await mapSessionEvents(session, undefined, [ + makeBashEvent('cd /workspace/proj && ls'), + ], URI.file('/workspace/proj/')); + const start = getStart(result); + assert.strictEqual(start.toolInput, 'ls'); + }); + + test('handles quoted directory in cd prefix', async () => { + const cwdWithSpaces = URI.file('/workspace/my proj'); + const result = await mapSessionEvents(session, undefined, [ + makeBashEvent('cd "/workspace/my proj" && ls'), + ], cwdWithSpaces); + const start = getStart(result); + assert.strictEqual(start.toolInput, 'ls'); + }); + + test('rewrites powershell commands too', async () => { + const result = await mapSessionEvents(session, undefined, [ + { + type: 'tool.execution_start', + data: { toolCallId: 'tc-1', toolName: 'powershell', arguments: { command: 'cd /workspace/proj; dir' } }, + }, + ], cwd); + const start = getStart(result); + assert.strictEqual(start.toolInput, 'dir'); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts index 3ff7eb9b9e78d..abf7cc2ed1889 100644 --- a/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/protocol/toolApprovalRealSdk.integrationTest.ts @@ -783,4 +783,94 @@ function terminalText(state: TerminalState): string { // has no fixed context window. assert.ok(agent.models.some(m => m.id === 'auto'), `Expected 'auto' model in list, got: ${agent.models.map(m => m.id).join(', ')}`); }); + + // ---- Redundant cd-prefix stripping -------------------------------------- + + test('strips redundant `cd &&` prefix from shell tool calls', async function () { + this.timeout(180_000); + + const tempDir = mkdtempSync(`${tmpdir()}/ahp-cd-strip-test-`); + tempDirs.push(tempDir); + const expectedWorkingDirPath = tempDir; + const sessionUri = await createRealSession(client, 'real-sdk-cd-strip', createdSessions, URI.file(tempDir).toString()); + + // Coax the model into producing a `cd && X` form. The exact text is + // non-deterministic, so the test asserts on rewrite behavior conditional + // on actually receiving a cd-prefixed command. + client.clearReceived(); + dispatchTurn(client, sessionUri, 'turn-cd-strip', + `Run this exact shell command, do not modify it: cd ${expectedWorkingDirPath} && echo strip-me-please`, + 1); + + // Wait for the toolCallReady action that carries the rewritten toolInput. + const toolReadyNotif = await client.waitForNotification(n => { + if (!isActionNotification(n, 'session/toolCallReady')) { + return false; + } + const action = getActionEnvelope(n).action as { toolInput?: string }; + return typeof action.toolInput === 'string' && action.toolInput.includes('echo strip-me-please'); + }, 90_000); + + const toolReadyAction = getActionEnvelope(toolReadyNotif).action as { toolCallId: string; toolInput?: string; confirmed?: string }; + const toolInput = toolReadyAction.toolInput!; + + // The core assertion: regardless of whether the model emitted the cd + // prefix verbatim or already pre-stripped it, the toolInput surfaced to + // the client must NOT contain the redundant `cd &&` prefix. + // Use a regex that anchors to the start of the command and tolerates + // optional surrounding quotes around the directory plus either `&&` + // or `;` as the chain operator (so quoted variants like + // `cd "" && …` and pwsh-style `cd ; …` are both detected). + const escapedWorkingDirPath = expectedWorkingDirPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const redundantWorkingDirCdPrefix = new RegExp( + `^\\s*cd\\s+(?:"${escapedWorkingDirPath}"|'${escapedWorkingDirPath}'|${escapedWorkingDirPath})\\s*(?:&&|;)\\s*`, + ); + assert.ok( + !redundantWorkingDirCdPrefix.test(toolInput), + `toolInput should not contain a redundant cd-prefix targeting the working directory; got: ${JSON.stringify(toolInput)}`, + ); + assert.ok( + toolInput.includes('echo strip-me-please'), + `toolInput should contain the rewritten command body; got: ${JSON.stringify(toolInput)}`, + ); + + // Approve so the turn can complete. If it was already auto-confirmed + // (`confirmed` is set), skip the manual approval. + if (!toolReadyAction.confirmed) { + client.notify('dispatchAction', { + clientSeq: 2, + action: { + type: 'session/toolCallConfirmed', + session: sessionUri, + turnId: 'turn-cd-strip', + toolCallId: toolReadyAction.toolCallId, + approved: true, + }, + }); + } + + // Drive any further confirmations to completion so teardown is clean. + while (true) { + const next = await client.waitForNotification( + n => isActionNotification(n, 'session/toolCallReady') || isActionNotification(n, 'session/turnComplete') || isActionNotification(n, 'session/error'), + 90_000, + ); + if (isActionNotification(next, 'session/turnComplete') || isActionNotification(next, 'session/error')) { + break; + } + const action = getActionEnvelope(next).action as { session: string; turnId: string; toolCallId: string; confirmed?: string }; + if (!action.confirmed) { + client.notify('dispatchAction', { + clientSeq: 3, + action: { + type: 'session/toolCallConfirmed', + session: action.session, + turnId: action.turnId, + toolCallId: action.toolCallId, + approved: true, + }, + }); + } + } + }); }); From ba66c95b7211a8b00f70df8943704eabe543df85 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 22 Apr 2026 16:22:23 -0700 Subject: [PATCH 13/35] agentHost: improve shell tool descriptions for LLM clarity (#312035) Borrows more detailed descriptions for terminal/shell tools from the core implementation to provide LLMs with better guidance on how to use shell commands effectively. This reduces confusion about shell-specific behaviors like command chaining, escaping, and proper syntax. - Updates shell tool descriptions to use detailed model descriptions for bash and PowerShell, providing comprehensive guidance on: * Command execution patterns and chaining * Directory management and context preservation * Program execution and module installation * Output management and filtering * Interactive input handling - Enriches shell command results with shell ID for better tracking and debugging - Includes platform-specific guidance for Windows PowerShell vs modern PowerShell, and sandbox-aware documentation Fixes https://github.com/microsoft/vscode/issues/312009 (Commit message generated by Copilot) --- .../node/copilot/copilotShellTools.ts | 173 +++++++++++++++++- 1 file changed, 168 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts index 1f2ad9b357144..7c45e1a8faa9f 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotShellTools.ts @@ -246,10 +246,13 @@ async function executeCommandInShell( terminalManager: IAgentHostTerminalManager, logService: ILogService, ): Promise { - if (terminalManager.supportsCommandDetection(shell.terminalUri)) { - return executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService); - } - return executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService); + const result = terminalManager.supportsCommandDetection(shell.terminalUri) + ? await executeCommandWithShellIntegration(shell, command, timeoutMs, terminalManager, logService) + : await executeCommandWithSentinel(shell, command, timeoutMs, terminalManager, logService); + return { + ...result, + textResultForLlm: `Shell ID: ${shell.id}\n${result.textResultForLlm}`, + }; } /** @@ -442,7 +445,7 @@ export function createShellTools( const primaryTool: Tool = { name: shellType, - description: `Execute a command in a persistent ${shellType} shell. The shell is reused across calls.`, + description: shellType === 'bash' ? createBashModelDescription(false) : createPowerShellModelDescription(shellType, 'pwsh.exe', false), parameters: { type: 'object', properties: { @@ -559,3 +562,163 @@ export function createShellTools( return [primaryTool, readTool, writeTool, shutdownTool, listTool]; } +interface ITerminalSandboxResolvedNetworkDomains { + allowedDomains: string[]; + deniedDomains: string[]; +} + +function isWindowsPowerShell(envShell: string): boolean { + return envShell.endsWith('System32\\WindowsPowerShell\\v1.0\\powershell.exe'); +} + +function createPowerShellModelDescription(shellType: string, shellPath: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { + const isWinPwsh = isWindowsPowerShell(shellPath); + const parts = [ + `This tool allows you to execute ${isWinPwsh ? 'Windows PowerShell 5.1' : 'PowerShell'} commands in a persistent terminal session, preserving environment variables, working directory, and other context across multiple commands.`, + '', + 'Command Execution:', + // IMPORTANT: PowerShell 5 does not support `&&` so always re-write them to `;`. Note that + // the behavior of `&&` differs a little from `;` but in general it's fine + isWinPwsh ? '- Use semicolons ; to chain commands on one line, NEVER use && even when asked explicitly' : '- Prefer ; when chaining commands on one line', + '- Prefer pipelines | for object-based data flow', + '- Never create a sub-shell (eg. powershell -c "command") unless explicitly asked', + '', + 'Directory Management:', + '- Prefer relative paths when navigating directories, only use absolute when the path is far away or the current cwd is not expected', + '- By default (mode=sync), shell and cwd are reused by subsequent sync commands', + '- Use $PWD or Get-Location for current directory', + '- Use Push-Location/Pop-Location for directory stack', + '', + 'Program Execution:', + '- Supports .NET, Python, Node.js, and other executables', + '- Install modules via Install-Module, Install-Package', + '- Use Get-Command to verify cmdlet/function availability', + '', + 'Async Mode:', + '- For long-running tasks (e.g., servers), use mode=async', + '- Returns a terminal ID for checking status and runtime later', + '- Use Start-Job for background PowerShell jobs', + '', + `Use write_${shellType} to send commands or input to a terminal session.`, + ]; + + if (isSandboxEnabled) { + parts.push(...createSandboxLines(networkDomains)); + } + + parts.push( + '', + 'Output Management:', + '- Output is automatically truncated if longer than 60KB to prevent context overflow', + '- Use Select-Object, Where-Object, Format-Table to filter output', + '- Use -First/-Last parameters to limit results', + '- For pager commands, add | Out-String or | Format-List', + '', + 'Best Practices:', + '- Use proper cmdlet names instead of aliases in scripts', + '- Quote paths with spaces: "C:\\Path With Spaces"', + '- Prefer PowerShell cmdlets over external commands when available', + '- Prefer idiomatic PowerShell like Get-ChildItem instead of dir or ls for file listings', + '- Use Test-Path to check file/directory existence', + '- Be specific with Select-Object properties to avoid excessive output', + '- Avoid printing credentials unless absolutely required', + '', + 'Interactive Input Handling:', + '- When a terminal command is waiting for interactive input, do NOT suggest alternatives or ask the user whether to proceed. Instead, use the ask_user tool to collect the needed values from the user, then send them.', + `- Send exactly one answer per prompt using write_${shellType}. Never send multiple answers in a single send.`, + `- After each send, call read_${shellType} to read the next prompt before sending the next answer.`, + '- Continue one prompt at a time until the command finishes.', + ); + + return parts.join('\n'); +} + +function createSandboxLines(networkDomains?: ITerminalSandboxResolvedNetworkDomains): string[] { + const lines = [ + '', + 'Sandboxing:', + '- ATTENTION: Terminal sandboxing is enabled, commands run in a sandbox by default', + '- When executing commands within the sandboxed environment, all operations requiring a temporary directory must utilize the $TMPDIR environment variable. The /tmp directory is not guaranteed to be accessible or writable and must be avoided', + '- Tools and scripts should respect the TMPDIR environment variable, which is automatically set to an appropriate path within the sandbox', + '- When a command fails due to sandbox restrictions, immediately re-run it with requestUnsandboxedExecution=true. Do NOT ask the user for permission — setting this flag automatically shows a confirmation prompt to the user', + '- Only set requestUnsandboxedExecution=true when there is evidence of failures caused by the sandbox, e.g. \'Operation not permitted\' errors, network failures, or file access errors, etc', + '- Do NOT set requestUnsandboxedExecution=true without first executing the command in sandbox mode. Always try the command in the sandbox first, and only set requestUnsandboxedExecution=true when retrying after that sandboxed execution failed due to sandbox restrictions.', + '- When setting requestUnsandboxedExecution=true, also provide requestUnsandboxedExecutionReason explaining why the command needs unsandboxed access', + ]; + if (networkDomains) { + const deniedSet = new Set(networkDomains.deniedDomains); + const effectiveAllowed = networkDomains.allowedDomains.filter(d => !deniedSet.has(d)); + if (effectiveAllowed.length === 0) { + lines.push('- All network access is blocked in the sandbox'); + } else { + lines.push(`- Only the following domains are accessible in the sandbox (all other network access is blocked): ${effectiveAllowed.join(', ')}`); + } + if (networkDomains.deniedDomains.length > 0) { + lines.push(`- The following domains are explicitly blocked in the sandbox: ${networkDomains.deniedDomains.join(', ')}`); + } + } + return lines; +} + +function createGenericDescription(shellType: string, isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { + const parts = [` +Command Execution: +- Use && to chain simple commands on one line +- Prefer pipelines | over temporary files for data flow +- Never create a sub-shell (eg. bash -c "command") unless explicitly asked + +Directory Management: +- Prefer relative paths when navigating directories, only use absolute when the path is far away or the current cwd is not expected +- By default (mode=sync), shell and cwd are reused by subsequent sync commands +- Use $PWD for current directory references +- Consider using pushd/popd for directory stack management +- Supports directory shortcuts like ~ and - + +Program Execution: +- Supports Python, Node.js, and other executables +- Install packages via package managers (brew, apt, etc.) +- Use which or command -v to verify command availability + +Async Mode: +- For long-running tasks (e.g., servers), use mode=async +- Returns a terminal ID for checking status and runtime later + +Use write_${shellType} to send commands or input to a terminal session.`]; + + if (isSandboxEnabled) { + parts.push(createSandboxLines(networkDomains).join('\n')); + } + + parts.push(` + +Output Management: +- Output is automatically truncated if longer than 60KB to prevent context overflow +- Use head, tail, grep, awk to filter and limit output size +- For pager commands, disable paging: git --no-pager or add | cat +- Use wc -l to count lines before displaying large outputs + +Best Practices: +- Quote variables: "$var" instead of $var to handle spaces +- Use find with -exec or xargs for file operations +- Be specific with commands to avoid excessive output +- Avoid printing credentials unless absolutely required +- NEVER run sleep or similar wait commands in a terminal. You will be automatically notified on your next turn when async terminal commands or timed-out sync commands complete or need input. Use read_${shellType} to check output before then + +Interactive Input Handling: +- When a terminal command is waiting for interactive input, do NOT suggest alternatives or ask the user whether to proceed. Instead, use the ask_user tool to collect the needed values from the user, then send them. +- Send exactly one answer per prompt using write_${shellType}. Never send multiple answers in a single send. +- After each send, call read_${shellType} to read the next prompt before sending the next answer. +- Continue one prompt at a time until the command finishes.`); + + return parts.join(''); +} + +function createBashModelDescription(isSandboxEnabled: boolean, networkDomains?: ITerminalSandboxResolvedNetworkDomains): string { + return [ + 'This tool allows you to execute shell commands in a persistent bash terminal session, preserving environment variables, working directory, and other context across multiple commands.', + createGenericDescription('bash', isSandboxEnabled, networkDomains), + '- Use [[ ]] for conditional tests instead of [ ]', + '- Prefer $() over backticks for command substitution', + '- Use set -e at start of complex commands to exit on errors' + ].join('\n'); +} From cdb3b860733c457e1b98983e9517f557da824201 Mon Sep 17 00:00:00 2001 From: Michael Lively <12552271+Yoyokrazy@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:32:08 -0700 Subject: [PATCH 14/35] Revert "fix(build): use Bearer auth for GitHub API requests in fetch.ts" (#312032) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert "fix(build): use Bearer auth for GitHub API requests in fetch.ts (#312…" This reverts commit 17f555f7bd18d3d4027ba0e075019e37e8b0a189. --- build/lib/fetch.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build/lib/fetch.ts b/build/lib/fetch.ts index 98a788f78db02..0d2c47a7fd804 100644 --- a/build/lib/fetch.ts +++ b/build/lib/fetch.ts @@ -109,7 +109,7 @@ const ghApiHeaders: Record = { 'User-Agent': 'VSCode Build', }; if (process.env.GITHUB_TOKEN) { - ghApiHeaders.Authorization = 'Bearer ' + process.env.GITHUB_TOKEN; + ghApiHeaders.Authorization = 'Basic ' + Buffer.from(process.env.GITHUB_TOKEN).toString('base64'); } const ghDownloadHeaders = { ...ghApiHeaders, From f22464675128c903c4ffd392b29197211c02ef64 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:37:06 -0400 Subject: [PATCH 15/35] sessions: simplify titlebar session actions (#311778) * sessions: simplify titlebar session actions Remove the remaining titlebar separators and extra spacing around the session picker and session action groups. Also drop the command-center Mark as Done button and rename the Add Chat tooltip to New Sub-Session to better match the sub-session workflow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: align titlebar CSS comment with spacing reset Agent-Logs-Url: https://github.com/microsoft/vscode/sessions/c18ac022-43b9-4f2b-bd4c-e6a85a1ac931 Co-authored-by: hawkticehurst <39639992+hawkticehurst@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 5 +++++ .../browser/parts/media/titlebarpart.css | 10 +++------ .../browser/media/changesTitleBarWidget.css | 15 ++----------- .../browser/media/sessionsTitleBarWidget.css | 22 +------------------ .../browser/sessionsTitleBarWidget.ts | 7 +----- .../browser/views/sessionsViewActions.ts | 12 +--------- 6 files changed, 13 insertions(+), 58 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index a6801e423ba39..202985474a686 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -84,6 +84,8 @@ The Agent Sessions titlebar includes a command center with a custom title bar wi The widget: - Extends `BaseActionViewItem` and renders a clickable label showing the active session title - Shows kind icon (provider type icon), session title, repository folder name, and the active git branch/worktree name in parentheses when available, plus the changes summary (+insertions -deletions) +- Uses spacing between titlebar groups instead of vertical separator bars, and shows the session title metadata without the previous dot separator before the folder/worktree label +- Keeps the command center focused on the session picker widget itself, without an adjacent "Mark as Done" action button - Truncates the repository/worktree metadata with ellipsis before truncating the primary AI-generated session title when command center space is constrained - On click, opens the `AgentSessionsPicker` quick pick to switch between sessions - Gets the active session label from `IActiveSessionService.getActiveSession()` and the live model title from `IChatService`, falling back to "New Session" if no active session is found @@ -665,6 +667,9 @@ interface IPartVisibilityState { | 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | | 2026-04-22 | Generalized the auxiliary bar snap-close prevention to trigger whenever the main editor part is visible (any editor type), so the behavior now applies automatically without maintaining an editor-type allowlist. | | 2026-04-22 | Updated the sessions auxiliary bar sizing rules so attached diff editors and integrated browser editors keep the normal 270px auxiliary-bar minimum width while disabling sash snap-to-close in that state, and the titlebar toggle continues to hide/show the secondary sidebar normally. | +| 2026-04-21 | Renamed the command-center "Add Chat" titlebar action to "New Sub-Session" so the plus-button tooltip matches the sub-session workflow. | +| 2026-04-21 | Removed the remaining left-margin spacing after the titlebar's VS Code and session-picker items, and dropped the command-center "Mark as Done" checkmark button next to the active session title. | +| 2026-04-21 | Removed the titlebar's vertical separator bars in favor of spacing-only group separation, and removed the dot separator between the active session title and its folder/worktree metadata. | | 2026-04-21 | Updated the sessions chat composite bar tabs to preserve each chat title's original casing instead of applying per-word capitalization. | | 2026-04-21 | Moved the sessions-only default notification placement to bottom-right and documented the sessions-specific notification center offsets: `15px` from the bottom/right or bottom/left edges, and `top: 40px; right: 15px;` for top-right placement. | | 2026-04-17 | Added a subtle 1px titlebar-token border around the sessions account widget's GitHub profile image, including the inactive-window variant, and documented the avatar chrome in the layout spec. | diff --git a/src/vs/sessions/browser/parts/media/titlebarpart.css b/src/vs/sessions/browser/parts/media/titlebarpart.css index f3e6ee55a0e65..db6da32ee33c2 100644 --- a/src/vs/sessions/browser/parts/media/titlebarpart.css +++ b/src/vs/sessions/browser/parts/media/titlebarpart.css @@ -63,13 +63,9 @@ align-items: center; } -/* Separator before right layout toolbar (only when session actions toolbar also has actions) */ -.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-right-layout-container:not(.has-no-actions)::before { - content: ''; - width: 1px; - height: 16px; - margin: 0 8px; - background-color: var(--vscode-disabledForeground); +/* Add spacing between the session action group and the right layout actions. */ +.monaco-workbench .part.titlebar > .sessions-titlebar-container > .titlebar-right > .titlebar-session-actions-container:not(.has-no-actions) + .titlebar-right-layout-container:not(.has-no-actions) { + margin-left: 8px; } /* Toggled action buttons in session actions toolbar */ diff --git a/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css index c77b454718e1a..1754de1cce6f7 100644 --- a/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css +++ b/src/vs/sessions/contrib/changes/browser/media/changesTitleBarWidget.css @@ -5,19 +5,8 @@ /* ---- Changes titlebar action spacing ---- */ -/* Separator between local-session actions (Run, VS Code) and fixed toggles (Terminal, Changes). +/* Remove leftover spacing between local-session actions (Run, VS Code) and fixed toggles (Terminal, Changes). * Targets the action following the VS Code icon (any Codicon.vscode variant). */ .agent-sessions-workbench .titlebar-session-actions-container .monaco-action-bar .actions-container > .action-item:has(.codicon[class*="codicon-vscode"]) + .action-item { - position: relative; - margin-left: 17px; -} - -.agent-sessions-workbench .titlebar-session-actions-container .monaco-action-bar .actions-container > .action-item:has(.codicon[class*="codicon-vscode"]) + .action-item::before { - content: ''; - position: absolute; - left: -9px; - top: 3px; - width: 1px; - height: 16px; - background-color: var(--vscode-disabledForeground); + margin-left: 0; } diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css index 0dc25732d0fe9..4736ced69aaeb 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsTitleBarWidget.css @@ -10,21 +10,7 @@ } .agent-sessions-workbench .command-center .monaco-action-bar .actions-container > .action-item.agent-sessions-titlebar-container + .action-item { - position: relative; - margin-left: 8px; - padding-left: 12px; -} - -.agent-sessions-workbench .command-center .monaco-action-bar .actions-container > .action-item.agent-sessions-titlebar-container + .action-item::before { - content: ''; - position: absolute; - left: 0; - top: 50%; - width: 1px; - height: 16px; - transform: translateY(-50%); - background-color: var(--vscode-commandCenter-border); - pointer-events: none; + margin-left: 0; } .command-center .agent-sessions-titlebar-container { @@ -121,12 +107,6 @@ opacity: 0.7; } -/* Dot separator */ -.command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-separator { - opacity: 0.5; - flex-shrink: 0; -} - /* Provider label (shown for untitled sessions) */ .command-center .agent-sessions-titlebar-container .agent-sessions-titlebar-provider { display: flex; diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts index bcdb4f70dba4e..beb6c441354eb 100644 --- a/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts +++ b/src/vs/sessions/contrib/sessions/browser/sessionsTitleBarWidget.ts @@ -131,7 +131,7 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { const icon = this._getActiveSessionIcon(); const repoLabel = this._getRepositoryLabel(); const repoDetailLabel = this._getRepositoryDetailLabel(); - const pillLabel = repoLabel ? `${label} \u00B7 ${repoLabel}${repoDetailLabel ? ` (${repoDetailLabel})` : ''}` : label; + const pillLabel = repoLabel ? `${label} ${repoLabel}${repoDetailLabel ? ` (${repoDetailLabel})` : ''}` : label; // Build a render-state key from all displayed data const renderState = `${icon?.id ?? ''}|${label}|${repoLabel ?? ''}|${repoDetailLabel ?? ''}`; @@ -171,11 +171,6 @@ export class SessionsTitleBarWidget extends BaseActionViewItem { if (repoLabel) { const detailsEl = $('span.agent-sessions-titlebar-details'); - const separator1 = $('span.agent-sessions-titlebar-separator'); - separator1.textContent = '\u00B7'; - separator1.setAttribute('aria-hidden', 'true'); - detailsEl.appendChild(separator1); - const repoEl = $('span.agent-sessions-titlebar-repo'); repoEl.textContent = repoDetailLabel ? `${repoLabel} (${repoDetailLabel})` : repoLabel; detailsEl.appendChild(repoEl); diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 8bb4d36db1f2f..5618a6f90a0de 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -701,16 +701,6 @@ registerAction2(class MarkSessionAsDoneAction extends Action2 { icon: Codicon.check, precondition: ChatContextKeys.requestInProgress.negate(), menu: [{ - id: Menus.CommandCenter, - order: 103, - when: ContextKeyExpr.and( - IsAuxiliaryWindowContext.negate(), - SessionsWelcomeVisibleContext.negate(), - IsNewChatSessionContext.negate(), - IsActiveSessionArchivedContext.negate() - ) - }, - { id: MenuId.ChatEditingSessionChangesToolbar, group: 'navigation', order: 1, @@ -750,7 +740,7 @@ registerAction2(class AddChatAction extends Action2 { constructor() { super({ id: 'agentSession.addChat', - title: localize2('addChat', "Add Chat"), + title: localize2('addChat', "New Sub-Session"), icon: Codicon.plus, menu: [{ id: Menus.CommandCenter, From 92c0a0f647141b2aeb7113d9246c1169818da204 Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:37:29 -0400 Subject: [PATCH 16/35] sessions: increase GitHub profile image size in titlebar account widget (#312012) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increases the avatar image in the titlebar account widget from 16×16px to 18×18px. The surrounding 22×22px control footprint and 1px circular border treatment are unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 3 ++- .../accountMenu/browser/media/accountTitleBarWidget.css | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 202985474a686..76aef5cbf750d 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -143,7 +143,7 @@ The account widget is rendered in the **right side of the titlebar** as a custom - Registered in `contrib/accountMenu/browser/account.contribution.ts` - Uses the `Menus.TitleBarRightLayout` menu - Shows the signed-in GitHub profile image when available, and falls back to the existing account codicon when it is not -- Gives the GitHub profile image a subtle 1px circular border using the titlebar command center border tokens so the avatar stays legible against nearby chrome in both active and inactive window states +- Renders the GitHub profile image at `18px × 18px` inside the `22px × 22px` titlebar widget, and gives it a subtle 1px circular border using the titlebar command center border tokens so the avatar stays legible against nearby chrome in both active and inactive window states - Opens a combined account and Copilot status hover panel with sign-in/sign-out, settings, and update actions --- @@ -663,6 +663,7 @@ interface IPartVisibilityState { | Date | Change | |------|--------| +| 2026-04-22 | Increased the sessions titlebar account widget's GitHub profile image from `16px × 16px` to `18px × 18px` while keeping the existing `22px × 22px` control footprint and avatar border treatment. | | 2026-04-22 | Added sessions-only toast offset overrides so notification toasts now use `right: 15px` in the default bottom-right placement and `left: 15px` in the bottom-left placement, matching the notification center spacing. | | 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | | 2026-04-22 | Generalized the auxiliary bar snap-close prevention to trigger whenever the main editor part is visible (any editor type), so the behavior now applies automatically without maintaining an editor-type allowlist. | diff --git a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css index 3dbb70d8a5285..3fc3c8e2e6f7d 100644 --- a/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css +++ b/src/vs/sessions/contrib/accountMenu/browser/media/accountTitleBarWidget.css @@ -49,8 +49,8 @@ .agent-sessions-workbench .sessions-account-titlebar-widget-avatar { display: none; flex: 0 0 auto; - width: 16px; - height: 16px; + width: 18px; + height: 18px; border: 1px solid var(--vscode-commandCenter-border, transparent); border-radius: 50%; box-sizing: border-box; From 5151ae658e4fee711617ae38a3d5317ae16ffb9a Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 22 Apr 2026 16:39:16 -0700 Subject: [PATCH 17/35] Try avoiding extra refreshes of github repos `onDidAuthenticationChange`seems to be getting fired very often in some cases, but that doesn't mean we should always make requests Co-authored-by: Copilot --- .../node/codeSearch/codeSearchChunkSearch.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts index 61a1cda449c9c..c148434c72934 100644 --- a/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts +++ b/extensions/copilot/src/platform/workspaceChunkSearch/node/codeSearch/codeSearchChunkSearch.ts @@ -168,10 +168,21 @@ export class CodeSearchChunkSearch extends Disposable { this.closeRepo(info.repo); })); - // When the authentication state changes, update repos - this._register(this._authenticationService.onDidAuthenticationChange(() => { - this.updateRepoStatuses(undefined, new TelemetryCorrelationId('CodeSearchChunkSearch::onDidAuthenticationChange')); - })); + // When the github authentication state changes, update repos only if the session actually changed + { + let lastAnyGitHubSessionId = this._authenticationService.anyGitHubSession?.id; + let lastPermissiveGitHubSessionId = this._authenticationService.permissiveGitHubSession?.id; + this._register(this._authenticationService.onDidAuthenticationChange(() => { + const anySessionId = this._authenticationService.anyGitHubSession?.id; + const permissiveSessionId = this._authenticationService.permissiveGitHubSession?.id; + if (anySessionId === lastAnyGitHubSessionId && permissiveSessionId === lastPermissiveGitHubSessionId) { + return; + } + lastAnyGitHubSessionId = anySessionId; + lastPermissiveGitHubSessionId = permissiveSessionId; + this.updateRepoStatuses('github', new TelemetryCorrelationId('CodeSearchChunkSearch::onDidAuthenticationChange')); + })); + } this._register(Event.any( this._authenticationService.onDidAdoAuthenticationChange, From 01ae82da72c2a4f6e72ad770f73daa150ef8d4de Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 23 Apr 2026 09:55:23 +1000 Subject: [PATCH 18/35] feat(copilot): enable external sessions in CLI configuration for vscode and disabled in agents app (#310903) --- extensions/copilot/package.json | 2 +- .../src/platform/configuration/common/configurationService.ts | 2 +- .../contrib/configuration/browser/configuration.contribution.ts | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index adda3927ccccb..1768daf037a83 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4574,7 +4574,7 @@ }, "github.copilot.chat.cli.showExternalSessions": { "type": "boolean", - "default": false, + "default": true, "markdownDescription": "%github.copilot.config.cli.showExternalSessions%", "tags": [ "advanced" diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 7143be912fa16..3705e16880738 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -610,7 +610,7 @@ export namespace ConfigKey { export const AgentHistorySummarizationMode = defineAndMigrateSetting('chat.advanced.agentHistorySummarizationMode', 'chat.agentHistorySummarizationMode', undefined); export const UseResponsesApiTruncation = defineAndMigrateSetting('chat.advanced.useResponsesApiTruncation', 'chat.useResponsesApiTruncation', false); export const OmitBaseAgentInstructions = defineAndMigrateSetting('chat.advanced.omitBaseAgentInstructions', 'chat.omitBaseAgentInstructions', false); - export const CLIShowExternalSessions = defineSetting('chat.cli.showExternalSessions', ConfigType.Simple, false); + export const CLIShowExternalSessions = defineSetting('chat.cli.showExternalSessions', ConfigType.Simple, true); export const CLIPlanExitModeEnabled = defineSetting('chat.cli.planExitMode.enabled', ConfigType.Simple, true); export const CLIAutoModelEnabled = defineSetting('chat.cli.autoModel.enabled', ConfigType.Simple, true); export const CLIPlanCommandEnabled = defineSetting('chat.cli.planCommand.enabled', ConfigType.Simple, true); diff --git a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts index c1b06e5c11ea2..6aa6cb99ef33c 100644 --- a/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts +++ b/src/vs/sessions/contrib/configuration/browser/configuration.contribution.ts @@ -75,6 +75,7 @@ Registry.as(Extensions.Configuration).registerDefaultCon 'github.copilot.chat.cli.remote.enabled': false, 'github.copilot.chat.githubMcpServer.enabled': true, 'github.copilot.chat.languageContext.typescript.enabled': true, + 'github.copilot.chat.cli.showExternalSessions': false, 'inlineChat.affordance': 'editor', 'inlineChat.renderMode': 'hover', From 3edb1699c306ae2d9e8c74f8f874338e80be85d7 Mon Sep 17 00:00:00 2001 From: Vijay Upadya <41652029+vijayupadya@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:10:31 -0700 Subject: [PATCH 19/35] Enable testing (#312029) * Enable testing Co-authored-by: Copilot * feedback update Co-authored-by: Copilot --------- Co-authored-by: Copilot --- .../vscode-node/languageModelAccess.ts | 3 ++- .../prompts/node/agent/geminiPrompts.tsx | 4 ++-- .../tools/common/toolSchemaNormalizer.ts | 5 ++--- .../src/extension/tools/node/editFileHealing.tsx | 4 ++-- .../endpoint/common/chatModelCapabilities.ts | 16 ++++++++++++---- 5 files changed, 20 insertions(+), 12 deletions(-) diff --git a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts index 4dc7cc7b99b3e..d110d56dae37d 100644 --- a/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts +++ b/extensions/copilot/src/extension/conversation/vscode-node/languageModelAccess.ts @@ -16,6 +16,7 @@ import { IEndpointProvider } from '../../../platform/endpoint/common/endpointPro import { CustomDataPartMimeTypes } from '../../../platform/endpoint/common/endpointTypes'; import { ModelAliasRegistry } from '../../../platform/endpoint/common/modelAliasRegistry'; import { encodeStatefulMarker } from '../../../platform/endpoint/common/statefulMarkerContainer'; +import { isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; import { AutoChatEndpoint } from '../../../platform/endpoint/node/autoChatEndpoint'; import { IAutomodeService } from '../../../platform/endpoint/node/automodeService'; import { IEnvService, isScenarioAutomation } from '../../../platform/env/common/envService'; @@ -65,7 +66,7 @@ function buildConfigurationSchema(endpoint: IChatEndpoint): { configurationSchem } const family = endpoint.family.toLowerCase(); - if (family.startsWith('gemini')) { + if (isGeminiFamily(endpoint)) { return {}; } diff --git a/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx b/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx index 35d3eef52224b..d3b31ed532227 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/geminiPrompts.tsx @@ -5,7 +5,7 @@ import { PromptElement, PromptSizing } from '@vscode/prompt-tsx'; import { ConfigKey, IConfigurationService } from '../../../../platform/configuration/common/configurationService'; -import { isHiddenModelF } from '../../../../platform/endpoint/common/chatModelCapabilities'; +import { isHiddenModelF, isHiddenModelK } from '../../../../platform/endpoint/common/chatModelCapabilities'; import { IChatEndpoint } from '../../../../platform/networking/common/networking'; import { IExperimentationService } from '../../../../platform/telemetry/common/nullExperimentationService'; import { agenticBrowserTools, ToolName } from '../../../tools/common/toolNames'; @@ -236,7 +236,7 @@ class GeminiPromptResolver implements IAgentPrompt { static readonly familyPrefixes = ['gemini']; static async matchesModel(endpoint: IChatEndpoint): Promise { - return isHiddenModelF(endpoint); + return isHiddenModelF(endpoint) || isHiddenModelK(endpoint); } resolveSystemPrompt(endpoint: IChatEndpoint): SystemPrompt | undefined { diff --git a/extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts b/extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts index f052983126ace..c9cbbcc1702e3 100644 --- a/extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts +++ b/extensions/copilot/src/extension/tools/common/toolSchemaNormalizer.ts @@ -8,6 +8,7 @@ import Ajv from 'ajv'; import { ArrayJsonSchema, JsonSchema, ObjectJsonSchema } from '../../../platform/configuration/common/jsonSchema'; import { jsonSchemaDraft7 } from '../../../platform/configuration/common/jsonSchemaDraft7'; import { OpenAiFunctionDef, OpenAiFunctionTool } from '../../../platform/networking/common/fetch'; +import { isGeminiFamily } from '../../../platform/endpoint/common/chatModelCapabilities'; import { Iterable } from '../../../util/vs/base/common/iterator'; import { Lazy } from '../../../util/vs/base/common/lazy'; import { deepClone } from '../../../util/vs/base/common/objects'; @@ -169,7 +170,7 @@ const jsonSchemaRules: ((family: string, node: JsonSchema, didFix: (message: str }, (family, schema, onFix) => { // Gemini models require nullable types to use OpenAPI 3.0 nullable keyword instead of JSON Schema union types - if (!isGeminiFamily(family)) { + if (!isGeminiFamily(family) && !family.toLowerCase().includes('gemini')) { return; } forEachSchemaNode(schema, n => { @@ -237,8 +238,6 @@ function forEachSchemaNode(input: JsonSchema, fn: (node: JsonSchema) => undef const isGpt4ish = (family: string) => family.startsWith('gpt-4'); // Whether the model is a model known to follow JSON Schema Draft 2020-12, (versus Draft 7). const isDraft2020_12Schema = (family: string) => family.startsWith('gpt-4') || family.startsWith('claude-') || family.startsWith('o4'); -// Whether the model is a Gemini family model. -const isGeminiFamily = (family: string) => family.toLowerCase().includes('gemini'); const gpt4oMaxStringLength = 1024; diff --git a/extensions/copilot/src/extension/tools/node/editFileHealing.tsx b/extensions/copilot/src/extension/tools/node/editFileHealing.tsx index f51f144adb146..c9af3c3b88115 100644 --- a/extensions/copilot/src/extension/tools/node/editFileHealing.tsx +++ b/extensions/copilot/src/extension/tools/node/editFileHealing.tsx @@ -22,7 +22,7 @@ import * as JSONC from 'jsonc-parser'; import type { LanguageModelChat } from 'vscode'; import { ChatFetchResponseType, ChatLocation } from '../../../platform/chat/common/commonTypes.js'; import { ObjectJsonSchema } from '../../../platform/configuration/common/jsonSchema.js'; -import { isHiddenModelF } from '../../../platform/endpoint/common/chatModelCapabilities.js'; +import { isGeminiFamily, isHiddenModelF } from '../../../platform/endpoint/common/chatModelCapabilities.js'; import { IChatEndpoint } from '../../../platform/networking/common/networking.js'; import { extractCodeBlocks } from '../../../util/common/markdown.js'; import { CancellationToken } from '../../../util/vs/base/common/cancellation.js'; @@ -72,7 +72,7 @@ export async function healReplaceStringParams( token: CancellationToken, ): Promise { let finalNewString = originalParams.newString!; - const unescapeStringForGeminiBug = model?.family.toLowerCase().includes('gemini') || (model && isHiddenModelF(model)) ? _unescapeStringForGeminiBug : (s: string) => s; + const unescapeStringForGeminiBug = (model && (isGeminiFamily(model) || isHiddenModelF(model))) ? _unescapeStringForGeminiBug : (s: string) => s; const newStringPotentiallyEscaped = unescapeStringForGeminiBug(originalParams.newString!) !== originalParams.newString; diff --git a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts index ac7db4a7c4600..1ede7b6b6d42b 100644 --- a/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts +++ b/extensions/copilot/src/platform/endpoint/common/chatModelCapabilities.ts @@ -67,6 +67,8 @@ const HIDDEN_MODEL_J_HASHES: string[] = [ '5a81e6aa7556585ba7c569881d1103683adc9e0124ff7952df423afba2f167b5', ]; +const HIDDEN_MODEL_K_HASH = 'a62e299160a1075d9973c28a7aa77f446c21c09887c7aa65c11022918cf83eda'; + const HIDDEN_FAMILY_H_HASHES: string[] = [ '70fcded3f255d368e868cc807d8838a62108bfa5c86ce7d37966f58cda229e33', ]; @@ -106,6 +108,11 @@ export function isHiddenFamilyH(model: LanguageModelChat | IChatEndpoint) { return HIDDEN_FAMILY_H_HASHES.includes(family_hash); } +export function isHiddenModelK(model: LanguageModelChat | IChatEndpoint) { + const h = getCachedSha256Hash(model.family); + return h === HIDDEN_MODEL_K_HASH; +} + export function isGpt54(model: LanguageModelChat | IChatEndpoint | string) { const h = getCachedSha256Hash(typeof model === 'string' ? model : model.family); @@ -230,7 +237,7 @@ export function modelPrefersJsonNotebookRepresentation(model: LanguageModelChat * Model supports replace_string_in_file as an edit tool. */ export function modelSupportsReplaceString(model: LanguageModelChat | IChatEndpoint): boolean { - return model.family.toLowerCase().includes('gemini') || model.family.includes('grok-code') || modelSupportsMultiReplaceString(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); + return isGeminiFamily(model) || model.family.includes('grok-code') || modelSupportsMultiReplaceString(model) || isHiddenModelF(model) || isMinimaxFamily(model) || isHiddenFamilyH(model); } /** @@ -295,7 +302,7 @@ export function modelCanUseApplyPatchExclusively(model: LanguageModelChat | ICha * replace_string. */ export function modelNeedsStrongReplaceStringHint(model: LanguageModelChat | IChatEndpoint): boolean { - return model.family.toLowerCase().includes('gemini') || isHiddenModelF(model); + return isGeminiFamily(model) || isHiddenModelF(model); } /** @@ -309,8 +316,9 @@ export function isAnthropicFamily(model: LanguageModelChat | IChatEndpoint): boo return model.family.startsWith('claude') || model.family.startsWith('Anthropic') || isHiddenModelG(model); } -export function isGeminiFamily(model: LanguageModelChat | IChatEndpoint): boolean { - return model.family.toLowerCase().startsWith('gemini'); +export function isGeminiFamily(model: LanguageModelChat | IChatEndpoint | string): boolean { + const family = typeof model === 'string' ? model : model.family; + return family.toLowerCase().startsWith('gemini') || getCachedSha256Hash(family) === HIDDEN_MODEL_K_HASH; } export function isMinimaxFamily(model: LanguageModelChat | IChatEndpoint): boolean { From 157951669b767219010fc9ababd5922bb638ff42 Mon Sep 17 00:00:00 2001 From: Matt Bierner <12821956+mjbvz@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:13:39 -0700 Subject: [PATCH 20/35] Remove fallback for anchor positioning The stable safari and firefox release should both support this now This lets up clean up a fair amount of manual layout logic Co-authored-by: Copilot --- src/vs/base/browser/overlayLayoutElement.ts | 124 ++++++------------ .../agentPluginEditor/agentPluginEditor.ts | 4 +- .../extensions/browser/extensionEditor.ts | 4 +- .../contrib/mcp/browser/mcpServerEditor.ts | 4 +- .../notebook/browser/notebookEditorWidget.ts | 21 ++- .../contrib/webview/browser/overlayWebview.ts | 28 ++-- .../contrib/webview/browser/webview.ts | 6 +- .../webviewPanel/browser/webviewEditor.ts | 10 +- .../webviewView/browser/webviewViewPane.ts | 43 +----- 9 files changed, 84 insertions(+), 160 deletions(-) diff --git a/src/vs/base/browser/overlayLayoutElement.ts b/src/vs/base/browser/overlayLayoutElement.ts index c6e764dda6d0b..3a477b7f8c543 100644 --- a/src/vs/base/browser/overlayLayoutElement.ts +++ b/src/vs/base/browser/overlayLayoutElement.ts @@ -3,16 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { IDimension, IDomPosition, setParentFlowTo } from './dom.js'; +import { setParentFlowTo } from './dom.js'; import { IDisposable } from '../common/lifecycle.js'; -import { Lazy } from '../common/lazy.js'; import { generateUuid } from '../common/uuid.js'; -/** - * Whether CSS anchor positioning is supported - */ -const supportsAnchorPositioning = new Lazy(() => CSS.supports('(top: anchor(top))')); - /** * If the element already has an `anchor-name` style, return it. * Otherwise generate a fresh `--overlay-anchor-` name, assign it, and return it. @@ -32,7 +26,7 @@ function getOrCreateAnchorName(element: HTMLElement): string { * * This is useful for cases where a dom node cannot be re-parented without losing its state, such as a iframe. * - * Call {@link layoutOverAnchorElement} each time the layout is recalculated. When the + * Call {@link setAnchorElement} each time the layout is recalculated. When the * same anchor element is passed again the call is a no-op (the browser keeps them in sync). */ export class OverlayLayoutElement implements IDisposable { @@ -60,14 +54,12 @@ export class OverlayLayoutElement implements IDisposable { } public reapplyLayoutStyles(): void { - if (supportsAnchorPositioning.value) { - this.content.style.position = 'fixed'; - this.content.style.top = 'anchor(top)'; - this.content.style.left = 'anchor(left)'; - this.content.style.width = 'anchor-size(width)'; - this.content.style.height = 'anchor-size(height)'; - this.content.style.pointerEvents = 'auto'; - } + this.content.style.position = 'fixed'; + this.content.style.top = 'anchor(top)'; + this.content.style.left = 'anchor(left)'; + this.content.style.width = 'anchor-size(width)'; + this.content.style.height = 'anchor-size(height)'; + this.content.style.pointerEvents = 'auto'; this._root.style.position = 'absolute'; this._root.style.pointerEvents = 'none'; @@ -93,88 +85,48 @@ export class OverlayLayoutElement implements IDisposable { /** * Position the content over `anchorElement`. * - * For legacy browser support this should be called each time the layout is recalculated. + * This only needs to be called when the anchor element or the clipping container changes. */ - public layoutOverAnchorElement( + public setAnchorElement( anchorElement: HTMLElement, options?: { readonly clippingContainer?: HTMLElement; - readonly fallbackDimension?: IDimension; - readonly fallbackPosition?: IDomPosition; }, ): void { - if (supportsAnchorPositioning.value) { - if (this._currentAnchor?.element !== anchorElement) { - const name = getOrCreateAnchorName(anchorElement); - this.content.style.setProperty('position-anchor', name); - setParentFlowTo(this.content, anchorElement); - this._currentAnchor = { element: anchorElement, name }; - } - } else { - const cs = this.content.style; - - if (options?.fallbackPosition) { - cs.top = `${options.fallbackPosition.top}px`; - cs.left = `${options.fallbackPosition.left}px`; - } else { - const anchorRect = anchorElement.getBoundingClientRect(); - const parentRect = this.content.parentElement!.getBoundingClientRect(); - const parentBorderTop = (parentRect.height - this.content.parentElement!.clientHeight) / 2.0; - const parentBorderLeft = (parentRect.width - this.content.parentElement!.clientWidth) / 2.0; - cs.top = `${anchorRect.top - parentRect.top - parentBorderTop}px`; - cs.left = `${anchorRect.left - parentRect.left - parentBorderLeft}px`; - } - + if (this._currentAnchor?.element !== anchorElement) { + const name = getOrCreateAnchorName(anchorElement); + this.content.style.setProperty('position-anchor', name); setParentFlowTo(this.content, anchorElement); - - const anchorRect = anchorElement.getBoundingClientRect(); - cs.width = `${options?.fallbackDimension ? options.fallbackDimension.width : anchorRect.width}px`; - cs.height = `${options?.fallbackDimension ? options.fallbackDimension.height : anchorRect.height}px`; + this._currentAnchor = { element: anchorElement, name }; } - this._updateClipping(anchorElement, options?.clippingContainer); + this._updateClipping(options?.clippingContainer); } - private _updateClipping(anchorElement: HTMLElement, clippingContainer: HTMLElement | undefined): void { - if (supportsAnchorPositioning.value) { - if (this._clippingAnchor?.element === clippingContainer) { - return; - } - - this._root.style.removeProperty('position-anchor'); - - const ws = this._root.style; - if (clippingContainer) { - const name = getOrCreateAnchorName(clippingContainer); - ws.clipPath = 'content-box'; - ws.setProperty('position-anchor', name); - ws.setProperty('top', 'anchor(top)'); - ws.setProperty('left', 'anchor(left)'); - ws.setProperty('width', `anchor-size(width)`); - ws.setProperty('height', `anchor-size(height)`); - this._clippingAnchor = { element: clippingContainer, name }; - } else { - ws.clipPath = ''; - ws.setProperty('top', '0'); - ws.setProperty('left', '0'); - ws.setProperty('right', '0'); - ws.setProperty('bottom', '0'); - this._clippingAnchor = undefined; - } + private _updateClipping(clippingContainer: HTMLElement | undefined): void { + if (this._clippingAnchor?.element === clippingContainer) { + return; + } + + this._root.style.removeProperty('position-anchor'); + + const ws = this._root.style; + if (clippingContainer) { + const name = getOrCreateAnchorName(clippingContainer); + ws.clipPath = 'content-box'; + ws.setProperty('position-anchor', name); + ws.setProperty('top', 'anchor(top)'); + ws.setProperty('left', 'anchor(left)'); + ws.setProperty('width', `anchor-size(width)`); + ws.setProperty('height', `anchor-size(height)`); + this._clippingAnchor = { element: clippingContainer, name }; } else { - if (clippingContainer) { - const anchorRect = anchorElement.getBoundingClientRect(); - const clipRect = clippingContainer.getBoundingClientRect(); - const top = Math.max(clipRect.top - anchorRect.top, 0); - const right = Math.max(anchorRect.width - (anchorRect.right - clipRect.right), 0); - const bottom = Math.max(anchorRect.height - (anchorRect.bottom - clipRect.bottom), 0); - const left = Math.max(clipRect.left - anchorRect.left, 0); - this.content.style.clipPath = `polygon(${left}px ${top}px, ${right}px ${top}px, ${right}px ${bottom}px, ${left}px ${bottom}px)`; - this._clippingAnchor = { element: clippingContainer, name: '' }; - } else { - this.content.style.clipPath = ''; - this._clippingAnchor = undefined; - } + ws.clipPath = ''; + ws.setProperty('top', '0'); + ws.setProperty('left', '0'); + ws.setProperty('right', '0'); + ws.setProperty('bottom', '0'); + this._clippingAnchor = undefined; } } } diff --git a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts index 7618325ea959d..660391439d16a 100644 --- a/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts +++ b/src/vs/workbench/contrib/chat/browser/agentPluginEditor/agentPluginEditor.ts @@ -417,7 +417,7 @@ export class AgentPluginEditor extends EditorPane { webview.claim(this, this.window, undefined); setParentFlowTo(webview.container, container); - webview.layoutWebviewOverElement(container); + webview.setAnchorElement(container); webview.setHtml(body); webview.claim(this, this.window, undefined); @@ -428,7 +428,7 @@ export class AgentPluginEditor extends EditorPane { const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout: () => { - webview.layoutWebviewOverElement(container); + webview.setAnchorElement(container); } }); this.contentDisposables.add(toDisposable(removeLayoutParticipant)); diff --git a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts index 9ca8f603041af..72e5285706a19 100644 --- a/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts +++ b/src/vs/workbench/contrib/extensions/browser/extensionEditor.ts @@ -713,7 +713,7 @@ export class ExtensionEditor extends EditorPane { webview.claim(this, this.window, this.scopedContextKeyService); setParentFlowTo(webview.container, container); - webview.layoutWebviewOverElement(container); + webview.setAnchorElement(container); webview.setHtml(body); webview.claim(this, this.window, undefined); @@ -724,7 +724,7 @@ export class ExtensionEditor extends EditorPane { const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout: () => { - webview.layoutWebviewOverElement(container); + webview.setAnchorElement(container); } }); this.contentDisposables.add(toDisposable(removeLayoutParticipant)); diff --git a/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts b/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts index 1f0f633be661a..8c85b988cb007 100644 --- a/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts +++ b/src/vs/workbench/contrib/mcp/browser/mcpServerEditor.ts @@ -498,7 +498,7 @@ export class McpServerEditor extends EditorPane { webview.claim(this, this.window, this.scopedContextKeyService); setParentFlowTo(webview.container, container); - webview.layoutWebviewOverElement(container); + webview.setAnchorElement(container); webview.setHtml(body); webview.claim(this, this.window, undefined); @@ -509,7 +509,7 @@ export class McpServerEditor extends EditorPane { const removeLayoutParticipant = arrays.insert(this.layoutParticipants, { layout: () => { - webview.layoutWebviewOverElement(container); + webview.setAnchorElement(container); } }); this.contentDisposables.add(toDisposable(removeLayoutParticipant)); diff --git a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts index 7c8cc185ff60e..223811f281b91 100644 --- a/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts +++ b/src/vs/workbench/contrib/notebook/browser/notebookEditorWidget.ts @@ -216,7 +216,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD private _localCellStateListeners: DisposableStore[] = []; private _fontInfo: FontInfo | undefined; private _dimension?: DOM.Dimension; - private _position?: DOM.IDomPosition; private _shadowElement?: HTMLElement; private _cellLayoutManager: NotebookCellLayoutManager | undefined; @@ -422,7 +421,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD return; } - this.layoutContainerOverShadowElement(this._shadowElement, this._dimension, this._position); + this.layoutContainerOverShadowElement(this._shadowElement); })); this.notebookEditorService.addNotebookEditor(this); @@ -1882,7 +1881,6 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD layout(dimension: DOM.Dimension, shadowElement?: HTMLElement, position?: DOM.IDomPosition): void { if (!shadowElement && !this._shadowElement) { this._dimension = dimension; - this._position = position; return; } @@ -1896,20 +1894,19 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD // In floating windows, we need to ensure that the // container is ready for us to compute certain // layout related properties. - whenContainerStylesLoaded.then(() => this.layoutNotebook(dimension, shadowElement, position)); + whenContainerStylesLoaded.then(() => this.layoutNotebook(dimension, shadowElement)); } else { - this.layoutNotebook(dimension, shadowElement, position); + this.layoutNotebook(dimension, shadowElement); } } - private layoutNotebook(dimension: DOM.Dimension, shadowElement?: HTMLElement, position?: DOM.IDomPosition) { + private layoutNotebook(dimension: DOM.Dimension, shadowElement?: HTMLElement) { if (shadowElement) { this._shadowElement = shadowElement; } this._dimension = dimension; - this._position = position; const newBodyHeight = this.getBodyHeight(dimension.height) - this.getLayoutInfo().stickyHeight; DOM.size(this._body, dimension.width, newBodyHeight); @@ -1926,7 +1923,7 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._overlayContainer.inert = false; - this.layoutContainerOverShadowElement(shadowElement ?? this._shadowElement, dimension, position); + this.layoutContainerOverShadowElement(shadowElement ?? this._shadowElement); if (this._webviewTransparentCover) { this._webviewTransparentCover.style.height = `${dimension.height}px`; @@ -1939,21 +1936,21 @@ export class NotebookEditorWidget extends Disposable implements INotebookEditorD this._viewContext?.eventDispatcher.emit([new NotebookLayoutChangedEvent({ width: true, fontInfo: true }, this.getLayoutInfo())]); } - private layoutContainerOverShadowElement(shadowElement: HTMLElement | undefined, dimension?: DOM.Dimension, position?: DOM.IDomPosition): void { - if (!shadowElement) { + private layoutContainerOverShadowElement(anchorElement: HTMLElement | undefined): void { + if (!anchorElement) { return; } const modalEditorContainer = this.editorGroupsService.activeModalEditorPart?.modalElement; let clippingContainer: HTMLElement | undefined; - if (DOM.isHTMLElement(modalEditorContainer) && modalEditorContainer.contains(shadowElement)) { + if (DOM.isHTMLElement(modalEditorContainer) && modalEditorContainer.contains(anchorElement)) { clippingContainer = modalEditorContainer; } else { clippingContainer = this.layoutService.getContainer(DOM.getWindow(this.getDomNode()), Parts.EDITOR_PART); } this._overlayContainer.style.visibility = 'visible'; - this._overlayLayout.layoutOverAnchorElement(shadowElement, { clippingContainer, fallbackDimension: dimension, fallbackPosition: position }); + this._overlayLayout.setAnchorElement(anchorElement, { clippingContainer }); this._overlayLayout.reapplyLayoutStyles(); } diff --git a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts index 683604c1cc727..b45c2e4fe0835 100644 --- a/src/vs/workbench/contrib/webview/browser/overlayWebview.ts +++ b/src/vs/workbench/contrib/webview/browser/overlayWebview.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Dimension, getWindowById } from '../../../../base/browser/dom.js'; +import { getWindowById } from '../../../../base/browser/dom.js'; import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { OverlayLayoutElement } from '../../../../base/browser/overlayLayoutElement.js'; import { CodeWindow } from '../../../../base/browser/window.js'; @@ -57,6 +57,8 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { private _overlayLayout: OverlayLayoutElement | undefined; + private _anchorState: { readonly anchorElement: HTMLElement; readonly clippingContainer?: HTMLElement } | undefined; + public constructor( initInfo: WebviewInitInfo, @IWorkbenchLayoutService private readonly _layoutService: IWorkbenchLayoutService, @@ -104,17 +106,21 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { throw new Error(`OverlayWebview has been disposed`); } + return this.overlayLayout.content; + } + + private get overlayLayout() { if (!this._overlayLayout) { this._overlayLayout = new OverlayLayoutElement(); this._overlayLayout.content.style.visibility = 'hidden'; - // // Webviews cannot be reparented in the dom as it will destroy their contents. - // // Mount them to a high level node to avoid this depending on the active container. + // Webviews cannot be reparented in the dom as it will destroy their contents. + // Mount them to a high level node to avoid this depending on the active container. const root = this._layoutService.getContainer(this.window); root.appendChild(this._overlayLayout.root); } - return this._overlayLayout.content; + return this._overlayLayout; } public claim(owner: unknown, targetWindow: CodeWindow, scopedContextKeyService: IContextKeyService | undefined) { @@ -138,6 +144,10 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { this._windowId = targetWindow.vscodeWindowId; this._show(targetWindow); + if (this._anchorState) { + this.overlayLayout.setAnchorElement(this._anchorState.anchorElement, { clippingContainer: this._anchorState.clippingContainer }); + } + if (oldOwner !== owner) { const contextKeyService = (scopedContextKeyService || this._baseContextKeyService); @@ -182,12 +192,10 @@ export class OverlayWebview extends Disposable implements IOverlayWebview { } } - public layoutWebviewOverElement(anchorElement: HTMLElement, dimension?: Dimension, clippingContainer?: HTMLElement) { - if (!this._overlayLayout || !this._overlayLayout.content.parentElement) { - return; - } - - this._overlayLayout?.layoutOverAnchorElement(anchorElement, { clippingContainer, fallbackDimension: dimension }); + public setAnchorElement(anchorElement: HTMLElement, clippingContainer?: HTMLElement) { + this._anchorState = { anchorElement, clippingContainer }; + // Force the overlay layout to be created if it doesn't exist + this.overlayLayout.setAnchorElement(anchorElement, { clippingContainer }); } private _show(targetWindow: CodeWindow) { diff --git a/src/vs/workbench/contrib/webview/browser/webview.ts b/src/vs/workbench/contrib/webview/browser/webview.ts index 936b635aa6cf1..9c990d657df99 100644 --- a/src/vs/workbench/contrib/webview/browser/webview.ts +++ b/src/vs/workbench/contrib/webview/browser/webview.ts @@ -3,7 +3,6 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Dimension } from '../../../../base/browser/dom.js'; import { IMouseWheelEvent } from '../../../../base/browser/mouseEvent.js'; import { CodeWindow } from '../../../../base/browser/window.js'; import { equals } from '../../../../base/common/arrays.js'; @@ -331,14 +330,13 @@ export interface IOverlayWebview extends IWebview { release(claimant: any): void; /** - * Absolutely position the webview on top of another element in the DOM. + * Sets the webview to be absolutely positioned on top of another element in the DOM. * * @param element Element to position the webview on top of. This element should * be an placeholder for the webview since the webview will entirely cover it. - * @param dimension Optional explicit dimensions to use for sizing the webview. * @param clippingContainer Optional container to clip the webview to. This should generally be a parent of `element`. */ - layoutWebviewOverElement(element: HTMLElement, dimension?: Dimension, clippingContainer?: HTMLElement): void; + setAnchorElement(element: HTMLElement, clippingContainer?: HTMLElement): void; } /** diff --git a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts index f06d57edf11c8..7e954cca0c556 100644 --- a/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts +++ b/src/vs/workbench/contrib/webviewPanel/browser/webviewEditor.ts @@ -69,7 +69,7 @@ export class WebviewEditor extends EditorPane { const part = _editorGroupsService.getPart(group); this._register(Event.any(part.onDidScroll, part.onDidAddGroup, part.onDidRemoveGroup, part.onDidMoveGroup)(() => { if (this.webview && this._visible) { - this.synchronizeWebviewContainerDimensions(this.webview); + this.setWebviewAnchorElement(this.webview); } })); @@ -105,7 +105,7 @@ export class WebviewEditor extends EditorPane { public override layout(dimension: DOM.Dimension): void { this._dimension = dimension; if (this.webview && this._visible) { - this.synchronizeWebviewContainerDimensions(this.webview, dimension); + this.setWebviewAnchorElement(this.webview); } } @@ -197,16 +197,16 @@ export class WebviewEditor extends EditorPane { this._webviewVisibleDisposables.add(new WebviewWindowDragMonitor(this.window, () => this.webview)); - this.synchronizeWebviewContainerDimensions(input.webview); + this.setWebviewAnchorElement(input.webview); this._webviewVisibleDisposables.add(this.trackFocus(input.webview)); } - private synchronizeWebviewContainerDimensions(webview: IOverlayWebview, dimension?: DOM.Dimension) { + private setWebviewAnchorElement(webview: IOverlayWebview) { if (!this._element?.isConnected) { return; } - webview.layoutWebviewOverElement(this._element.parentElement!, dimension, this._clippingContainer); + webview.setAnchorElement(this._element.parentElement!, this._clippingContainer); } private trackFocus(webview: IOverlayWebview): IDisposable { diff --git a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts index efa5a1b2b7494..f640b299e48d2 100644 --- a/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts +++ b/src/vs/workbench/contrib/webviewView/browser/webviewViewPane.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { addDisposableListener, Dimension, EventType, findParentWithClass, getWindow } from '../../../../base/browser/dom.js'; +import { addDisposableListener, EventType, findParentWithClass, getWindow } from '../../../../base/browser/dom.js'; import { CancellationTokenSource } from '../../../../base/common/cancellation.js'; import { Emitter } from '../../../../base/common/event.js'; import { DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; @@ -53,7 +53,6 @@ export class WebviewViewPane extends ViewPane { private _container?: HTMLElement; private _rootContainer?: HTMLElement; - private _resizeObserver?: ResizeObserver; private readonly defaultTitle: string; private setTitle: string | undefined; @@ -65,8 +64,6 @@ export class WebviewViewPane extends ViewPane { private readonly viewState: WebviewViewState; private readonly extensionId?: ExtensionIdentifier; - private _repositionTimeout?: Timeout; - constructor( options: IViewletViewOptions, @IConfigurationService configurationService: IConfigurationService, @@ -114,8 +111,6 @@ export class WebviewViewPane extends ViewPane { override dispose() { this._onDispose.fire(); - clearTimeout(this._repositionTimeout); - super.dispose(); } @@ -130,18 +125,7 @@ export class WebviewViewPane extends ViewPane { this._container = container; this._rootContainer = undefined; - if (!this._resizeObserver) { - this._resizeObserver = new ResizeObserver(() => { - setTimeout(() => { - this.layoutWebview(); - }, 0); - }); - - this._register(toDisposable(() => { - this._resizeObserver?.disconnect(); - })); - this._resizeObserver.observe(container); - } + this.layoutWebview(); } public override saveState() { @@ -155,8 +139,7 @@ export class WebviewViewPane extends ViewPane { protected override layoutBody(height: number, width: number): void { super.layoutBody(height, width); - - this.layoutWebview(new Dimension(width, height)); + this.layoutWebview(); } private updateTreeVisibility() { @@ -187,9 +170,7 @@ export class WebviewViewPane extends ViewPane { webview.state = this.viewState[storageKeys.webviewState]; this._webview.value = webview; - if (this._container) { - this.layoutWebview(); - } + this.layoutWebview(); this._webviewDisposables.add(toDisposable(() => { this._webview.value?.release(this); @@ -272,11 +253,7 @@ export class WebviewViewPane extends ViewPane { return this.progressService.withProgress({ location: this.id, delay: 500 }, task); } - override onDidScrollRoot() { - this.layoutWebview(); - } - - private doLayoutWebview(dimension?: Dimension) { + private layoutWebview() { const webviewEntry = this._webview.value; if (!this._container || !webviewEntry) { return; @@ -286,15 +263,7 @@ export class WebviewViewPane extends ViewPane { this._rootContainer = this.findRootContainer(this._container); } - webviewEntry.layoutWebviewOverElement(this._container, dimension, this._rootContainer); - } - - private layoutWebview(dimension?: Dimension) { - this.doLayoutWebview(dimension); - // Temporary fix for https://github.com/microsoft/vscode/issues/110450 - // There is an animation that lasts about 200ms, update the webview positioning once this animation is complete. - clearTimeout(this._repositionTimeout); - this._repositionTimeout = setTimeout(() => this.doLayoutWebview(dimension), 200); + webviewEntry.setAnchorElement(this._container, this._rootContainer); } private findRootContainer(container: HTMLElement): HTMLElement | undefined { From 8c4616c5d43e2735e991374e0ff23c02892b25c7 Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 23 Apr 2026 10:16:21 +1000 Subject: [PATCH 21/35] feat(copilotcli): Perf impromvent by lazy loading chat session items (#311817) * feat(copilotcli): add lazy loading for chat session items Co-authored-by: Copilot * Update extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/copilot/package.nls.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Updates Co-authored-by: Copilot --------- Co-authored-by: Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 8 ++ extensions/copilot/package.nls.json | 1 + .../common/chatSessionWorktreeService.ts | 2 + .../chatSessionWorktreeServiceImpl.ts | 8 ++ .../vscode-node/copilotCLIChatSessions.ts | 89 ++++++++++--- .../copilotCLIChatSessionsContribution.ts | 119 ++++++++++++------ .../common/configurationService.ts | 1 + extensions/copilot/test/e2e/cli.stest.ts | 1 + 8 files changed, 178 insertions(+), 51 deletions(-) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 1768daf037a83..3647fe870ad83 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4604,6 +4604,14 @@ "advanced" ] }, + "github.copilot.chat.cli.lazyLoadSessionItem.enabled": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.cli.lazyLoadSessionItem.enabled%", + "tags": [ + "advanced" + ] + }, "github.copilot.chat.cli.aiGenerateBranchNames.enabled": { "type": "boolean", "default": true, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index c20b73d95bd43..fe77f97c4a27a 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -411,6 +411,7 @@ "github.copilot.config.cli.planExitMode.enabled": "Enable Plan Mode exit handling in Copilot CLI.", "github.copilot.config.cli.autoModel.enabled": "Enable the Auto model option in Copilot CLI, which automatically selects the best model for each request. Requires VS Code reload.", "github.copilot.config.cli.planCommand.enabled": "Enable the /plan command in Copilot CLI to create implementation plans before coding.", + "github.copilot.config.cli.lazyLoadSessionItem.enabled": "Enable lazy loading of session items in Copilot CLI. Requires VS Code reload.", "github.copilot.config.cli.aiGenerateBranchNames.enabled": "Enable AI-generated branch names in Copilot CLI.", "github.copilot.config.cli.isolationOption.enabled": "Enable the isolation mode option for Copilot CLI. When enabled, users can choose between Worktree and Workspace modes.", "github.copilot.config.cli.autoCommit.enabled": "Enable automatic commit for Copilot CLI. When enabled, changes made by Copilot CLI will be automatically committed to the repository at the end of each turn.", diff --git a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts index 4a76bf2288cac..6b442ce04ec73 100644 --- a/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts +++ b/extensions/copilot/src/extension/chatSessions/common/chatSessionWorktreeService.ts @@ -75,6 +75,8 @@ export interface IChatSessionWorktreeService { getWorktreeChanges(sessionId: string): Promise; + hasWorktreeChanges(sessionId: string): Promise; + handleRequestCompleted(sessionId: string): Promise; /** Get worktree properties for all additional workspaces in a session. */ diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts index 09a2aee2f371b..6ff3e2b232c62 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessionWorktreeServiceImpl.ts @@ -318,6 +318,14 @@ export class ChatSessionWorktreeService extends Disposable implements IChatSessi } } + async hasWorktreeChanges(sessionId: string): Promise { + const worktreeProperties = await this.getWorktreeProperties(sessionId); + if (!worktreeProperties || typeof worktreeProperties === 'string') { + return false; + } + return !!worktreeProperties.changes; + } + async getWorktreeChanges(sessionId: string): Promise { const worktreeProperties = await this.getWorktreeProperties(sessionId); if (!worktreeProperties || typeof worktreeProperties === 'string') { diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts index 27a48a7ab4a12..1f37a2722b820 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessions.ts @@ -16,7 +16,7 @@ import { ILogService } from '../../../platform/log/common/logService'; import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { isUri } from '../../../util/common/types'; -import { DeferredPromise, IntervalTimer } from '../../../util/vs/base/common/async'; +import { DeferredPromise, IntervalTimer, raceCancellation } from '../../../util/vs/base/common/async'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter, Event } from '../../../util/vs/base/common/event'; @@ -131,6 +131,8 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements private readonly controller: vscode.ChatSessionItemController; private readonly newSessions = new ResourceMap(); + private readonly previouslyCachedChanges = new Map(); + constructor( @ICopilotCLISessionService private readonly sessionService: ICopilotCLISessionService, @IChatSessionWorktreeService private readonly copilotCLIWorktreeManagerService: IChatSessionWorktreeService, @@ -206,11 +208,24 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements if (!item) { throw new Error(`Failed to get session item for forked session ${forkedSessionId}`); } - return this.toChatSessionItem(item); + return this.toChatSessionItem(item, undefined, token); + }; + } + // Defers the slow `buildChanges` (git diff) call to when the editor renders the item. + if (this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { + controller.resolveChatSessionItem = async (item, token) => { + const sessionId = SessionIdForCLI.parse(item.resource); + const session = await this.sessionService.getSessionItem(sessionId, token); + if (!session || token.isCancellationRequested || Array.isArray(item.changes)) { + return; + } + const updatedItem = await this.toChatSessionItem(session, { includeChanges: true }, token); + controller.items.add(updatedItem); }; } this._register(this.sessionService.onDidDeleteSession(async (e) => { controller.items.delete(SessionIdForCLI.getResource(e)); + this.previouslyCachedChanges.delete(e); })); this._register(this.sessionService.onDidChangeSession(async (e) => { const item = await this.toChatSessionItem(e); @@ -300,6 +315,7 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements if (refreshOptions.reason === 'delete') { const uri = SessionIdForCLI.getResource(refreshOptions.sessionId); this.controller.items.delete(uri); + this.previouslyCachedChanges.delete(refreshOptions.sessionId); } else if (refreshOptions.reason === 'update' && hasKey(refreshOptions, { 'sessionIds': true })) { await Promise.allSettled(refreshOptions.sessionIds.map(async sessionId => { const item = await this.sessionService.getSessionItem(sessionId, CancellationToken.None); @@ -317,30 +333,54 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements } } - public async toChatSessionItem(session: ICopilotCLISessionItem): Promise { + public async toChatSessionItem(session: ICopilotCLISessionItem, options?: { readonly includeChanges?: boolean }, token?: vscode.CancellationToken): Promise { + token = token ?? CancellationToken.None; const resource = SessionIdForCLI.getResource(session.id); const item = this.controller.createChatSessionItem(resource, session.label); - let worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id); + let worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; + if (token.isCancellationRequested) { + return item; + + } item.timing = session.timing; item.status = session.status ?? vscode.ChatSessionStatus.Completed; + // This way, when user refreshes everything, they get the cached changes immediately. + item.changes = this.previouslyCachedChanges.get(session.id); + + // `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the + // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items. + // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass. + if (options?.includeChanges || ((await this.canBuildChangesFast(session.id, worktreeProperties)))) { + const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token); + if (token.isCancellationRequested) { + return item; + } - const changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory); + // We need to get an updated version of worktree properties here because when the + // changes are being computed, the worktree properties are also updated with the + // repository state which we are passing along through the metadata + worktreeProperties = await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id), token); + if (token.isCancellationRequested) { + return item; + } - // We need to get an updated version of worktree properties here because when the - // changes are being computed, the worktree properties are also updated with the - // repository state which we are passing along through the metadata - worktreeProperties = await this.copilotCLIWorktreeManagerService.getWorktreeProperties(session.id); + item.changes = changes; + this.previouslyCachedChanges.set(session.id, changes); + } + + if (token.isCancellationRequested) { + return item; + } const [badge, metadata] = await Promise.all([ this.buildBadge(worktreeProperties, workingDirectory), this.buildMetadata(session.id, worktreeProperties, workingDirectory), ]); item.badge = badge; - item.changes = changes; item.metadata = metadata; return item; } @@ -370,20 +410,41 @@ export class CopilotCLIChatSessionContentProvider extends Disposable implements return badge; } + private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited>): Promise { + if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { + return true; + } + if (!worktreeProperties?.repositoryPath) { + return false; + } + const [trusted, available] = await Promise.all([ + vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)), + this.copilotCLIWorktreeManagerService.hasWorktreeChanges(sessionId) + ]); + return trusted && available; + } + private async buildChanges( sessionId: string, worktreeProperties: Awaited>, workingDirectory: vscode.Uri | undefined, + token: CancellationToken = CancellationToken.None ): Promise { const changes: vscode.ChatSessionChangedFile[] = []; if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) { - changes.push(...(await this.copilotCLIWorktreeManagerService.getWorktreeChanges(sessionId) ?? [])); + if (token.isCancellationRequested) { + return []; + } + changes.push(...(await raceCancellation(this.copilotCLIWorktreeManagerService.getWorktreeChanges(sessionId), token) ?? [])); } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { - const workspaceChanges = await this._workspaceFolderService.getWorkspaceChanges(sessionId) ?? []; - const repositoryProperties = await this._metadataStore.getRepositoryProperties(sessionId); + if (token.isCancellationRequested) { + return []; + } + const workspaceChanges = await raceCancellation(this._workspaceFolderService.getWorkspaceChanges(sessionId), token) ?? []; + const repositoryProperties = await raceCancellation(this._metadataStore.getRepositoryProperties(sessionId), token); changes.push(...workspaceChanges.map(change => { - const originalRef = repositoryProperties?.mergeBaseCommit ?? repositoryProperties?.baseCommit ?? 'HEAD'; + const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD'; return new vscode.ChatSessionChangedFile( vscode.Uri.file(change.filePath), diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts index 7f381a538e635..a92b66b894411 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/copilotCLIChatSessionsContribution.ts @@ -22,7 +22,7 @@ import { IPromptsService, ParsedPromptFile } from '../../../platform/promptFiles import { ITelemetryService } from '../../../platform/telemetry/common/telemetry'; import { IWorkspaceService } from '../../../platform/workspace/common/workspaceService'; import { isUri } from '../../../util/common/types'; -import { DeferredPromise, disposableTimeout, IntervalTimer, SequencerByKey } from '../../../util/vs/base/common/async'; +import { DeferredPromise, disposableTimeout, IntervalTimer, raceCancellation, SequencerByKey } from '../../../util/vs/base/common/async'; import { CancellationToken } from '../../../util/vs/base/common/cancellation'; import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Emitter, Event } from '../../../util/vs/base/common/event'; @@ -170,8 +170,11 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc private readonly _onDidCommitChatSessionItem = this._register(new Emitter<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }>()); public readonly onDidCommitChatSessionItem: Event<{ original: vscode.ChatSessionItem; modified: vscode.ChatSessionItem }> = this._onDidCommitChatSessionItem.event; + private readonly previouslyCachedChanges = new Map(); + public resolveChatSessionItem?: (item: vscode.ChatSessionItem, token: vscode.CancellationToken) => Promise; + constructor( @ICopilotCLISessionService private readonly copilotcliSessionService: ICopilotCLISessionService, @ICopilotCLISessionTracker private readonly sessionTracker: ICopilotCLISessionTracker, @@ -184,7 +187,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc @IGitService private readonly gitService: IGitService, @IOctoKitService private readonly octoKitService: IOctoKitService, @ILogService private readonly logService: ILogService, - @IConfigurationService configurationService: IConfigurationService, + @IConfigurationService private readonly configurationService: IConfigurationService, ) { super(); this._register(this.terminalIntegration); @@ -194,6 +197,17 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } })); + if (configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { + this.resolveChatSessionItem = async (item: vscode.ChatSessionItem, token: vscode.CancellationToken): Promise => { + const sessionId = SessionIdForCLI.parse(item.resource); + const session = await this.copilotcliSessionService.getSessionItem(sessionId, token); + if (!session || token.isCancellationRequested || Array.isArray(item.changes)) { + return undefined; + } + return this.toChatSessionItem(session, { includeChanges: true }, token); + }; + } + // Resolve session dirs for terminal links. See resolveSessionDirsForTerminal. this.terminalIntegration.setSessionDirResolver(terminal => resolveSessionDirsForTerminal(this.sessionTracker, terminal) @@ -248,9 +262,9 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc repositories.length > 1; // multiple repositories } - public async toChatSessionItem(session: ICopilotCLISessionItem): Promise { + public async toChatSessionItem(session: ICopilotCLISessionItem, options?: { readonly includeChanges?: boolean }, token: vscode.CancellationToken = CancellationToken.None): Promise { const resource = this.sdkToUntitledUriMapping.get(session.id) ?? SessionIdForCLI.getResource(this.untitledSessionIdMapping.get(session.id) ?? session.id); - let worktreeProperties = await this.worktreeManager.getWorktreeProperties(session.id); + let worktreeProperties = await raceCancellation(this.worktreeManager.getWorktreeProperties(session.id), token); const workingDirectory = worktreeProperties?.worktreePath ? vscode.Uri.file(worktreeProperties.worktreePath) : session.workingDirectory; @@ -258,7 +272,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc // Badge let badge: vscode.MarkdownString | undefined; - if (this.shouldShowBadge()) { + if (this.shouldShowBadge() && !token.isCancellationRequested) { if (worktreeProperties?.repositoryPath) { // Worktree const repositoryPathUri = vscode.Uri.file(worktreeProperties.repositoryPath); @@ -277,30 +291,21 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } } - // Statistics (only returned for trusted workspace/worktree folders) - const changes: vscode.ChatSessionChangedFile[] = []; - if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) { - // Worktree - changes.push(...(await this.worktreeManager.getWorktreeChanges(session.id) ?? [])); - } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { - // Workspace - const workspaceChanges = await this.workspaceFolderService.getWorkspaceChanges(session.id) ?? []; - const repositoryProperties = await this.chatSessionMetadataStore.getRepositoryProperties(session.id); + // Statistics (only returned for trusted workspace/worktree folders). + // `getWorktreeChanges`/`getWorkspaceChanges` shell out to `git diff` and dominate the cost + // of building an item — defer to `resolveChatSessionItem` for visible items. + // `buildChanges` runs `git diff` and is the slow leg of populating an item. Skip it on the + // eager pass and let `resolveChatSessionItem` fill it in lazily for visible items. + // But if computing changes is easy (cached or the like), then include them right away to avoid a second update pass. + let changes: vscode.ChatSessionChangedFile[] | undefined = this.previouslyCachedChanges.get(session.id); + if (!token.isCancellationRequested && (options?.includeChanges || (await this.canBuildChangesFast(session.id, worktreeProperties)))) { + changes = await this.buildChanges(session.id, worktreeProperties, workingDirectory, token); + this.previouslyCachedChanges.set(session.id, changes); - changes.push(...workspaceChanges.map(change => { - const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD'; - - return new vscode.ChatSessionChangedFile( - vscode.Uri.file(change.filePath), - change.originalFilePath - ? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef) - : undefined, - change.modifiedFilePath - ? vscode.Uri.file(change.modifiedFilePath) - : undefined, - change.statistics.additions, - change.statistics.deletions); - })); + // We need to get an updated version of worktree properties here because when the + // changes are being computed, the worktree properties are also updated with the + // repository state which we are passing along through the metadata + worktreeProperties = await raceCancellation(this.worktreeManager.getWorktreeProperties(session.id), token); } // Status @@ -308,13 +313,7 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc // Metadata let metadata: { readonly [key: string]: unknown }; - - // We need to get an updated version of worktree properties here because when the - // changes are being computed, the worktree properties are also updated with the - // repository state which we are passing along through the metadata - worktreeProperties = await this.worktreeManager.getWorktreeProperties(session.id); - - const sessionParentId = await this.chatSessionMetadataStore.getSessionParentId(session.id); + const sessionParentId = await raceCancellation(this.chatSessionMetadataStore.getSessionParentId(session.id), token); if (worktreeProperties) { // Worktree @@ -365,8 +364,8 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } satisfies { readonly [key: string]: unknown }; } else { // Workspace - const sessionRequestDetails = await this.chatSessionMetadataStore.getRequestDetails(session.id); - const repositoryProperties = await this.chatSessionMetadataStore.getRepositoryProperties(session.id); + const sessionRequestDetails = await raceCancellation(this.chatSessionMetadataStore.getRequestDetails(session.id), token) ?? []; + const repositoryProperties = await raceCancellation(this.chatSessionMetadataStore.getRepositoryProperties(session.id), token); let lastCheckpointRef: string | undefined; for (let i = sessionRequestDetails.length - 1; i >= 0; i--) { @@ -409,6 +408,52 @@ export class CopilotCLIChatSessionItemProvider extends Disposable implements vsc } satisfies vscode.ChatSessionItem; } + private async canBuildChangesFast(sessionId: string, worktreeProperties: Awaited>): Promise { + if (!this.configurationService.getConfig(ConfigKey.Advanced.CLIChatLazyLoadSessionItem)) { + return true; + } + if (!worktreeProperties?.repositoryPath) { + return false; + } + const [trusted, available] = await Promise.all([ + vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath)), + this.worktreeManager.hasWorktreeChanges(sessionId) + ]); + return trusted && available; + } + + + private async buildChanges( + sessionId: string, + worktreeProperties: Awaited>, + workingDirectory: vscode.Uri | undefined, + token: vscode.CancellationToken + ): Promise { + const changes: vscode.ChatSessionChangedFile[] = []; + if (worktreeProperties?.repositoryPath && await vscode.workspace.isResourceTrusted(vscode.Uri.file(worktreeProperties.repositoryPath))) { + changes.push(...(await raceCancellation(this.worktreeManager.getWorktreeChanges(sessionId), token) ?? [])); + } else if (workingDirectory && await vscode.workspace.isResourceTrusted(workingDirectory)) { + const workspaceChanges = await raceCancellation(this.workspaceFolderService.getWorkspaceChanges(sessionId), token) ?? []; + const repositoryProperties = await raceCancellation(this.chatSessionMetadataStore.getRepositoryProperties(sessionId), token); + + changes.push(...workspaceChanges.map(change => { + const originalRef = repositoryProperties?.mergeBaseCommit ?? 'HEAD'; + + return new vscode.ChatSessionChangedFile( + vscode.Uri.file(change.filePath), + change.originalFilePath + ? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef) + : undefined, + change.modifiedFilePath + ? vscode.Uri.file(change.modifiedFilePath) + : undefined, + change.statistics.additions, + change.statistics.deletions); + })); + } + return changes; + } + /** * Detects a pull request for a session when the user opens it. * If a PR is found, persists the URL and notifies the UI. diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 3705e16880738..3f105b8109018 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -614,6 +614,7 @@ export namespace ConfigKey { export const CLIPlanExitModeEnabled = defineSetting('chat.cli.planExitMode.enabled', ConfigType.Simple, true); export const CLIAutoModelEnabled = defineSetting('chat.cli.autoModel.enabled', ConfigType.Simple, true); export const CLIPlanCommandEnabled = defineSetting('chat.cli.planCommand.enabled', ConfigType.Simple, true); + export const CLIChatLazyLoadSessionItem = defineSetting('chat.cli.lazyLoadSessionItem.enabled', ConfigType.Simple, false); export const CLIAIGenerateBranchNames = defineSetting('chat.cli.aiGenerateBranchNames.enabled', ConfigType.Simple, true); export const CLIForkSessionsEnabled = defineSetting('chat.cli.forkSessions.enabled', ConfigType.Simple, true); export const CLIMCPServerEnabled = defineAndMigrateSetting('chat.advanced.cli.mcp.enabled', 'chat.cli.mcp.enabled', true); diff --git a/extensions/copilot/test/e2e/cli.stest.ts b/extensions/copilot/test/e2e/cli.stest.ts index 4b19c97d5b4ae..d0550535c7b6d 100644 --- a/extensions/copilot/test/e2e/cli.stest.ts +++ b/extensions/copilot/test/e2e/cli.stest.ts @@ -298,6 +298,7 @@ async function registerChatServices(testingServiceCollection: TestingServiceColl async handleRequestCompletedForWorktree() { }, async cleanupWorktreeOnArchive() { return { cleaned: false }; }, async recreateWorktreeOnUnarchive() { return { recreated: false }; }, + async hasWorktreeChanges() { return false; }, } as IChatSessionWorktreeService); testingServiceCollection.define(IPromptVariablesService, new SyncDescriptor(NullPromptVariablesService)); testingServiceCollection.define(IChatDebugFileLoggerService, new NullChatDebugFileLoggerService()); From 1bad83125f613a3b340517162802f3a476e2c2bf Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:18:07 -0700 Subject: [PATCH 22/35] Fix reasoning effort handling in Claude (#312041) The reasoning effort wasn't really getting passed down properly. This ensures that it does and if a model only has 1 reasoning value then we use that. Co-authored-by: Copilot --- .../common/claudeSessionStateService.ts | 8 +- .../claude/node/claudeCodeAgent.ts | 13 +- .../claude/node/claudeCodeModels.ts | 41 ++++- .../claude/node/claudeLanguageModelServer.ts | 5 +- .../claude/node/claudeSessionStateService.ts | 6 +- .../claude/node/test/claudeCodeAgent.spec.ts | 73 +++++++++ .../claude/node/test/claudeCodeModels.spec.ts | 153 +++++++++++++++++- .../claudeChatSessionContentProvider.ts | 7 +- 8 files changed, 286 insertions(+), 20 deletions(-) diff --git a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts index 4055fdf98b014..5b93a75029cf4 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/common/claudeSessionStateService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; +import { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import type * as vscode from 'vscode'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { createServiceIdentifier } from '../../../../util/common/services'; @@ -22,7 +22,7 @@ export interface SessionState { capturingToken: CapturingToken | undefined; folderInfo: ClaudeFolderInfo | undefined; usageHandler: UsageHandler | undefined; - reasoningEffort: string | undefined; + reasoningEffort: EffortLevel | undefined; } /** @@ -96,12 +96,12 @@ export interface IClaudeSessionStateService { /** * Gets the reasoning effort for a session (user's per-request selection from the model picker). */ - getReasoningEffortForSession(sessionId: string): string | undefined; + getReasoningEffortForSession(sessionId: string): EffortLevel | undefined; /** * Sets the reasoning effort for a session. */ - setReasoningEffortForSession(sessionId: string, effort: string | undefined): void; + setReasoningEffortForSession(sessionId: string, effort: EffortLevel | undefined): void; } export const IClaudeSessionStateService = createServiceIdentifier('IClaudeSessionStateService'); diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts index d0377f10157e8..c2da7cb0a8255 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeAgent.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { McpServerConfig, Options, PermissionMode, Query, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; +import { EffortLevel, McpServerConfig, Options, PermissionMode, Query, SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; import Anthropic from '@anthropic-ai/sdk'; import * as l10n from '@vscode/l10n'; import type * as vscode from 'vscode'; @@ -161,6 +161,7 @@ export class ClaudeCodeSession extends Disposable { private _settingsChangeTracker: ClaudeSettingsChangeTracker; private _currentModelId: ParsedClaudeModelId; private _currentPermissionMode: PermissionMode; + private _currentEffort: EffortLevel | undefined; private _isResumed: boolean; private _yieldInProgress = false; private _sessionStarting: Promise | undefined; @@ -337,7 +338,15 @@ export class ClaudeCodeSession extends Disposable { // Do this BEFORE starting a session so the Options are correct from the start const modelId = this.sessionStateService.getModelIdForSession(this.sessionId); const permissionMode = this.sessionStateService.getPermissionModeForSession(this.sessionId); + const effortLevel = this.sessionStateService.getReasoningEffortForSession(this.sessionId); + if (effortLevel !== this._currentEffort) { + this._currentEffort = effortLevel; + // Effort doesn't have a direct setter on the query generator, so we need to restart the session + if (this._queryGenerator) { + this._restartSession(); + } + } // Update model and permission mode on active session if they changed if (modelId) { await this._setModel(modelId); @@ -427,6 +436,7 @@ export class ClaudeCodeSession extends Disposable { // the permission mode ourselves in the options allowDangerouslySkipPermissions: true, abortController: this._abortController, + effort: this._currentEffort, executable: process.execPath as 'node', // get it to fork the EH node process // TODO: CAPI does not yet support the WebSearch tool // Once it does, we can re-enable it. @@ -662,6 +672,7 @@ export class ClaudeCodeSession extends Disposable { this._queryGenerator = undefined; this._abortController = new AbortController(); this._currentRequest = undefined; + this._currentEffort = undefined; } /** diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts index 2b7ca58ce97ed..1ea22addebfc8 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeCodeModels.ts @@ -13,6 +13,7 @@ import { Emitter } from '../../../../util/vs/base/common/event'; import { Disposable } from '../../../../util/vs/base/common/lifecycle'; import type { ParsedClaudeModelId } from '../common/claudeModelId'; import { tryParseClaudeModelId } from './claudeModelId'; +import { EffortLevel } from '@anthropic-ai/claude-agent-sdk'; export const CLAUDE_REASONING_EFFORT_PROPERTY = 'reasoningEffort'; @@ -24,7 +25,13 @@ export interface IClaudeCodeModels { * then to the newest Sonnet, newest Haiku, or any Claude endpoint. * Returns `undefined` if no Claude endpoint can be found. */ - resolveEndpoint(requestedModel: string | undefined, fallbackModelId: ParsedClaudeModelId | undefined): Promise; + resolveEndpoint(requestedModel: ParsedClaudeModelId | string | undefined, fallbackModelId: ParsedClaudeModelId | undefined): Promise; + + /** + * Resolves the reasoning effort level for the given requested model ID and requested reasoning effort. + */ + resolveReasoningEffort(requestedModel: ParsedClaudeModelId | string | undefined, requestedReasoningEffort: string | undefined): Promise; + /** * Registers a LanguageModelChatProvider so that Claude models appear in * VS Code's built-in model picker for the claude-code session type. @@ -101,12 +108,32 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { }); } - public async resolveEndpoint(requestedModel: string | undefined, fallbackModelId: ParsedClaudeModelId | undefined): Promise { + public async resolveReasoningEffort(requestedModel: ParsedClaudeModelId | string | undefined, requestedReasoningEffort: string | undefined): Promise { + const endpoint = await this.resolveEndpoint(requestedModel, undefined); + if (!endpoint || !endpoint.supportsReasoningEffort || endpoint.supportsReasoningEffort.length === 0) { + return undefined; + } + if (requestedReasoningEffort && isEffortLevel(requestedReasoningEffort) && endpoint.supportsReasoningEffort.includes(requestedReasoningEffort)) { + return requestedReasoningEffort; + } + if (endpoint.supportsReasoningEffort.length === 1 && isEffortLevel(endpoint.supportsReasoningEffort[0])) { + return endpoint.supportsReasoningEffort[0]; + } + return undefined; + } + + public async resolveEndpoint(requestedModel: ParsedClaudeModelId | string | undefined, fallbackModelId: ParsedClaudeModelId | undefined): Promise { const endpoints = await this._getEndpoints(); // 1. Exact match for the requested model if (requestedModel) { - const mappedModel = tryParseClaudeModelId(requestedModel)?.toEndpointModelId() ?? requestedModel; + let parsedModel: ParsedClaudeModelId | undefined; + if (typeof requestedModel === 'string') { + parsedModel = tryParseClaudeModelId(requestedModel); + } else { + parsedModel = requestedModel; + } + const mappedModel = parsedModel?.toEndpointModelId() ?? requestedModel; const exact = endpoints.find(e => e.family === mappedModel || e.model === mappedModel); if (exact) { return exact; @@ -163,14 +190,18 @@ export class ClaudeCodeModels extends Disposable implements IClaudeCodeModels { } } -const SUPPORTED_EFFORT_LEVELS = ['low', 'medium', 'high'] as const; +const SUPPORTED_EFFORT_LEVELS: EffortLevel[] = ['low', 'medium', 'high']; + +export function isEffortLevel(value: string): value is EffortLevel { + return SUPPORTED_EFFORT_LEVELS.includes(value as EffortLevel); +} function buildConfigurationSchema(endpoint: IChatEndpoint): vscode.LanguageModelConfigurationSchema | undefined { const effortLevels = endpoint.supportsReasoningEffort?.filter( (level): level is typeof SUPPORTED_EFFORT_LEVELS[number] => (SUPPORTED_EFFORT_LEVELS as readonly string[]).includes(level) ); - if (!effortLevels || effortLevels.length <= 1) { + if (!effortLevels) { return; } diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts index 0ec3e8b30c163..27c4e851f9010 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeLanguageModelServer.ts @@ -215,7 +215,10 @@ export class ClaudeLanguageModelServer extends Disposable { } const capturingToken = sessionId ? this.sessionStateService.getCapturingTokenForSession(sessionId) : undefined; - const reasoningEffort = sessionId ? this.sessionStateService.getReasoningEffortForSession(sessionId) : undefined; + const sessionReasoningEffort = sessionId ? this.sessionStateService.getReasoningEffortForSession(sessionId) : undefined; + const reasoningEffort = sessionReasoningEffort && selectedEndpoint.supportsReasoningEffort?.includes(sessionReasoningEffort) + ? sessionReasoningEffort + : undefined; const doRequest = () => streamingEndpoint.makeChatRequest2({ debugName: 'Claude Copilot Proxy', diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts index 8769c8ebd534e..2dcf78c549b20 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/claudeSessionStateService.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { PermissionMode } from '@anthropic-ai/claude-agent-sdk'; +import { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk'; import { CapturingToken } from '../../../../platform/requestLogger/common/capturingToken'; import { arrayEquals } from '../../../../util/vs/base/common/equals'; import { Emitter } from '../../../../util/vs/base/common/event'; @@ -122,11 +122,11 @@ export class ClaudeSessionStateService extends Disposable implements IClaudeSess }); } - getReasoningEffortForSession(sessionId: string): string | undefined { + getReasoningEffortForSession(sessionId: string): EffortLevel | undefined { return this._sessionState.get(sessionId)?.reasoningEffort; } - setReasoningEffortForSession(sessionId: string, effort: string | undefined): void { + setReasoningEffortForSession(sessionId: string, effort: EffortLevel | undefined): void { const existing = this._sessionState.get(sessionId); if (existing?.reasoningEffort === effort) { return; diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts index 45f682bf5497f..9cfc4f00af161 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeAgent.spec.ts @@ -500,6 +500,79 @@ describe('ClaudeCodeSession', () => { expect(mockService.lastQueryOptions?.resume).toBe('existing-session'); expect(mockService.lastQueryOptions?.sessionId).toBeUndefined(); }); + + it('passes effort in SDK options when reasoning effort is set in session state', async () => { + const serverConfig = { port: 8080, nonce: 'test-nonce' }; + const mockServer = createMockLangModelServer(); + const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService; + + commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID); + sessionStateService.setReasoningEffortForSession('test-session', 'low'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, mockServer, 'test-session', TEST_MODEL_ID, TEST_PERMISSION_MODE, true)); + const stream = new MockChatResponseStream(); + + await session.invoke(createMockChatRequest(), toPromptBlocks('Hello'), {} as vscode.ChatParticipantToolToken, stream, CancellationToken.None); + + expect(mockService.lastQueryOptions?.effort).toBe('low'); + }); + + it('does not include effort in SDK options when reasoning effort is not set', async () => { + const serverConfig = { port: 8080, nonce: 'test-nonce' }; + const mockServer = createMockLangModelServer(); + const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService; + + commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, mockServer, 'test-session', TEST_MODEL_ID, TEST_PERMISSION_MODE, true)); + const stream = new MockChatResponseStream(); + + await session.invoke(createMockChatRequest(), toPromptBlocks('Hello'), {} as vscode.ChatParticipantToolToken, stream, CancellationToken.None); + + expect(mockService.lastQueryOptions?.effort).toBeUndefined(); + }); + + it('restarts session when effort level changes', async () => { + const serverConfig = { port: 8080, nonce: 'test-nonce' }; + const mockServer = createMockLangModelServer(); + const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService; + mockService.queryCallCount = 0; + + commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, mockServer, 'test-session', TEST_MODEL_ID, TEST_PERMISSION_MODE, true)); + + // First request with no effort + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest(), toPromptBlocks('Hello'), {} as vscode.ChatParticipantToolToken, stream1, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + + // Change effort level + sessionStateService.setReasoningEffortForSession('test-session', 'high'); + + // Second request should restart session (new query created) + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest(), toPromptBlocks('Hello again'), {} as vscode.ChatParticipantToolToken, stream2, CancellationToken.None); + expect(mockService.queryCallCount).toBe(2); + }); + + it('does not restart session when effort level is unchanged', async () => { + const serverConfig = { port: 8080, nonce: 'test-nonce' }; + const mockServer = createMockLangModelServer(); + const mockService = instantiationService.invokeFunction(accessor => accessor.get(IClaudeCodeSdkService)) as MockClaudeCodeSdkService; + mockService.queryCallCount = 0; + + commitTestState(sessionStateService, 'test-session', TEST_MODEL_ID); + sessionStateService.setReasoningEffortForSession('test-session', 'medium'); + const session = store.add(instantiationService.createInstance(ClaudeCodeSession, serverConfig, mockServer, 'test-session', TEST_MODEL_ID, TEST_PERMISSION_MODE, true)); + + // First request + const stream1 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest(), toPromptBlocks('Hello'), {} as vscode.ChatParticipantToolToken, stream1, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + + // Second request with same effort level + const stream2 = new MockChatResponseStream(); + await session.invoke(createMockChatRequest(), toPromptBlocks('Hello again'), {} as vscode.ChatParticipantToolToken, stream2, CancellationToken.None); + expect(mockService.queryCallCount).toBe(1); + }); }); describe('ClaudeAgentManager - error handling', () => { diff --git a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts index 035f13740c0bb..ea247ba724aa2 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/claude/node/test/claudeCodeModels.spec.ts @@ -11,7 +11,7 @@ import { Emitter } from '../../../../../util/vs/base/common/event'; import { DisposableStore } from '../../../../../util/vs/base/common/lifecycle'; import { IInstantiationService } from '../../../../../util/vs/platform/instantiation/common/instantiation'; import { createExtensionUnitTestingServices } from '../../../../test/node/services'; -import { ClaudeCodeModels } from '../claudeCodeModels'; +import { ClaudeCodeModels, isEffortLevel } from '../claudeCodeModels'; import { tryParseClaudeModelId } from '../claudeModelId'; /** @@ -308,7 +308,7 @@ describe('ClaudeCodeModels', () => { expect(info[0].configurationSchema).toBeUndefined(); }); - it('omits configurationSchema when endpoint has only one reasoning effort level', async () => { + it('includes configurationSchema when endpoint has only one reasoning effort level', async () => { const { service } = createServiceWithRefreshableEndpoints([ createMockEndpoint({ model: 'claude-sonnet-4-model', @@ -320,7 +320,139 @@ describe('ClaudeCodeModels', () => { const { lm, getCapturedProvider } = createMockLm(); const info = await getProviderInfo(service, lm, getCapturedProvider); - expect(info[0].configurationSchema).toBeUndefined(); + expect(info[0].configurationSchema).toBeDefined(); + const schema = info[0].configurationSchema!; + expect(schema.properties?.['reasoningEffort'].enum).toEqual(['high']); + expect(schema.properties!['reasoningEffort'].default).toBe('high'); + }); + }); + + describe('resolveEndpoint with ParsedClaudeModelId', () => { + it('resolves endpoint when given a ParsedClaudeModelId', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ model: 'claude-sonnet-4', name: 'Claude Sonnet 4', family: 'claude-sonnet-4' }), + ]); + + const parsedId = tryParseClaudeModelId('claude-sonnet-4')!; + const endpoint = await service.resolveEndpoint(parsedId, undefined); + expect(endpoint?.model).toBe('claude-sonnet-4'); + }); + + it('maps ParsedClaudeModelId to endpoint format', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ model: 'claude-opus-4.5', name: 'Claude Opus 4.5', family: 'claude-opus-4.5' }), + ]); + + const parsedId = tryParseClaudeModelId('claude-opus-4-5')!; + const endpoint = await service.resolveEndpoint(parsedId, undefined); + expect(endpoint?.model).toBe('claude-opus-4.5'); + }); + }); + + describe('resolveReasoningEffort', () => { + it('returns requested effort level when endpoint supports it', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['low', 'medium', 'high'], + }), + ]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high'); + expect(result).toBe('high'); + }); + + it('returns undefined when endpoint does not support reasoning effort', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + }), + ]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when endpoint has empty reasoning effort array', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: [], + }), + ]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high'); + expect(result).toBeUndefined(); + }); + + it('returns the single supported level when endpoint supports exactly one', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['high'], + }), + ]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', undefined); + expect(result).toBe('high'); + }); + + it('returns undefined when requested effort is not supported by the endpoint', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['low', 'medium'], + }), + ]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when requested effort is not a valid EffortLevel', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['low', 'medium', 'high'], + }), + ]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', 'invalid-level'); + expect(result).toBeUndefined(); + }); + + it('returns undefined when no endpoints are available', async () => { + const { service } = createServiceWithRefreshableEndpoints([]); + + const result = await service.resolveReasoningEffort('claude-sonnet-4', 'high'); + expect(result).toBeUndefined(); + }); + + it('accepts a ParsedClaudeModelId for requestedModel', async () => { + const { service } = createServiceWithRefreshableEndpoints([ + createMockEndpoint({ + model: 'claude-sonnet-4', + name: 'Claude Sonnet 4', + family: 'claude-sonnet-4', + supportsReasoningEffort: ['low', 'medium', 'high'], + }), + ]); + + const parsedId = tryParseClaudeModelId('claude-sonnet-4')!; + const result = await service.resolveReasoningEffort(parsedId, 'medium'); + expect(result).toBe('medium'); }); }); @@ -370,3 +502,18 @@ describe('ClaudeCodeModels', () => { }); }); }); + +describe('isEffortLevel', () => { + it('returns true for valid effort levels', () => { + expect(isEffortLevel('low')).toBe(true); + expect(isEffortLevel('medium')).toBe(true); + expect(isEffortLevel('high')).toBe(true); + }); + + it('returns false for invalid effort levels', () => { + expect(isEffortLevel('invalid')).toBe(false); + expect(isEffortLevel('')).toBe(false); + expect(isEffortLevel('HIGH')).toBe(false); + expect(isEffortLevel('Low')).toBe(false); + }); +}); diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index 4eaa05a8f05f9..abfb16436e390 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -21,7 +21,7 @@ import { generateUuid } from '../../../util/vs/base/common/uuid'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent'; -import { CLAUDE_REASONING_EFFORT_PROPERTY } from '../claude/node/claudeCodeModels'; +import { CLAUDE_REASONING_EFFORT_PROPERTY, IClaudeCodeModels } from '../claude/node/claudeCodeModels'; import { IClaudeCodeSdkService } from '../claude/node/claudeCodeSdkService'; import { parseClaudeModelId } from '../claude/node/claudeModelId'; import { IClaudeSessionStateService } from '../claude/common/claudeSessionStateService'; @@ -61,6 +61,7 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco @IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService, @IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService, @IConfigurationService configurationService: IConfigurationService, + @IClaudeCodeModels private readonly claudeModels: IClaudeCodeModels, @IChatFolderMruService folderMruService: IChatFolderMruService, @IWorkspaceService workspaceService: IWorkspaceService, @INativeEnvService envService: INativeEnvService, @@ -124,8 +125,8 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco this.sessionStateService.setFolderInfoForSession(effectiveSessionId, folderInfo); const rawReasoningEffort = request.modelConfiguration?.[CLAUDE_REASONING_EFFORT_PROPERTY]; - const normalizedReasoningEffort = typeof rawReasoningEffort === 'string' ? rawReasoningEffort.trim() || undefined : undefined; - this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, normalizedReasoningEffort); + const reasoningEffort = await this.claudeModels.resolveReasoningEffort(modelId, rawReasoningEffort); + this.sessionStateService.setReasoningEffortForSession(effectiveSessionId, reasoningEffort); // Set usage handler to report token usage for context window widget this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, (usage) => { From 1a7db073c841017c2ff1965377af68f3ab3d5bad Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:41:47 -0400 Subject: [PATCH 23/35] sessions: restore terminal after editor maximize (#311961) * sessions: restore terminal after editor maximize Restore the terminal panel when the sessions maximize editor flow hid it, so maximize and restore behave symmetrically. Add regression coverage for the maximize and restore command behavior and update the sessions layout spec. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: fix editor contribution test cleanup Dispose the per-test instantiation service in the editor contribution regression tests and assert the maximize call order described by the test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: fix editor contribution test disposal Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/sessions/LAYOUT.md | 2 + .../editor/browser/editor.contribution.ts | 25 +++ .../test/browser/editor.contribution.test.ts | 199 ++++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 76aef5cbf750d..e22625bd3b037 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -257,6 +257,7 @@ setPartHidden(hidden: boolean, part: Parts): void - **Editor Part:** - The main editor part is hidden by default but can be shown for explicit editor workflows that target the main editor part - Modal editor opens do not change the current main editor visibility state + - The sessions **Maximize Editor** action temporarily hides the panel when the visible panel is the terminal view, and the matching **Restore Editor** action reopens that terminal panel if maximize hid it - All editors open via `MODAL_GROUP` into the `ModalEditorPart` overlay, which manages its own lifecycle ### 6.2 Part Sizing @@ -668,6 +669,7 @@ interface IPartVisibilityState { | 2026-04-22 | Added a sessions-workbench notification offset override so the shared notification controllers no longer push top-right notifications down to `42px`; sessions now reapply a fixed `40px` top offset for top-right notification center/toast placement. | | 2026-04-22 | Generalized the auxiliary bar snap-close prevention to trigger whenever the main editor part is visible (any editor type), so the behavior now applies automatically without maintaining an editor-type allowlist. | | 2026-04-22 | Updated the sessions auxiliary bar sizing rules so attached diff editors and integrated browser editors keep the normal 270px auxiliary-bar minimum width while disabling sash snap-to-close in that state, and the titlebar toggle continues to hide/show the secondary sidebar normally. | +| 2026-04-22 | Updated the sessions **Maximize Editor** and **Restore Editor** actions so maximize hides the panel only when the terminal view is currently visible, and restore reopens that terminal panel when maximize hid it. | | 2026-04-21 | Renamed the command-center "Add Chat" titlebar action to "New Sub-Session" so the plus-button tooltip matches the sub-session workflow. | | 2026-04-21 | Removed the remaining left-margin spacing after the titlebar's VS Code and session-picker items, and dropped the command-center "Mark as Done" checkmark button next to the active session title. | | 2026-04-21 | Removed the titlebar's vertical separator bars in favor of spacing-only group separation, and removed the dot separator between the active session title and its folder/worktree metadata. | diff --git a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts index 7fa989f72789b..9621a0b6e3c0d 100644 --- a/src/vs/sessions/contrib/editor/browser/editor.contribution.ts +++ b/src/vs/sessions/contrib/editor/browser/editor.contribution.ts @@ -21,6 +21,9 @@ import { ChangesViewPane } from '../../changes/browser/changesView.js'; import { prepareMoveCopyEditors } from '../../../../workbench/browser/parts/editor/editor.js'; import { Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; import { MOVE_MODAL_EDITOR_TO_MAIN_COMMAND_ID } from '../../../../workbench/browser/parts/editor/editorCommands.js'; +import { TERMINAL_VIEW_ID } from '../../../../workbench/contrib/terminal/common/terminal.js'; + +const terminalPanelHiddenForMaximizedEditor = new WeakSet(); class MaximizeMainEditorPartAction extends Action2 { static readonly ID = 'workbench.action.agentSessions.maximizeMainEditorPart'; @@ -44,6 +47,20 @@ class MaximizeMainEditorPartAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const layoutService = accessor.get(IAgentWorkbenchLayoutService); + const viewsService = accessor.get(IViewsService); + let hidTerminalPanel = false; + + if (layoutService.isVisible(Parts.PANEL_PART) && viewsService.isViewVisible(TERMINAL_VIEW_ID)) { + layoutService.setPartHidden(true, Parts.PANEL_PART); + hidTerminalPanel = true; + } + + if (hidTerminalPanel) { + terminalPanelHiddenForMaximizedEditor.add(layoutService); + } else { + terminalPanelHiddenForMaximizedEditor.delete(layoutService); + } + layoutService.setEditorMaximized(true); } } @@ -72,7 +89,15 @@ class RestoreMainEditorPartAction extends Action2 { async run(accessor: ServicesAccessor): Promise { const layoutService = accessor.get(IAgentWorkbenchLayoutService); + const shouldRestoreTerminalPanel = terminalPanelHiddenForMaximizedEditor.has(layoutService); + layoutService.setEditorMaximized(false); + + if (shouldRestoreTerminalPanel && !layoutService.isVisible(Parts.PANEL_PART)) { + layoutService.setPartHidden(false, Parts.PANEL_PART); + } + + terminalPanelHiddenForMaximizedEditor.delete(layoutService); } } diff --git a/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts new file mode 100644 index 0000000000000..d0cfe129ff4c1 --- /dev/null +++ b/src/vs/sessions/contrib/editor/test/browser/editor.contribution.test.ts @@ -0,0 +1,199 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import assert from 'assert'; +import { mock } from '../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; +import { TestInstantiationService } from '../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { Parts } from '../../../../../workbench/services/layout/browser/layoutService.js'; +import { IViewsService } from '../../../../../workbench/services/views/common/viewsService.js'; +import { TERMINAL_VIEW_ID } from '../../../../../workbench/contrib/terminal/common/terminal.js'; +import { IAgentWorkbenchLayoutService } from '../../../../browser/workbench.js'; + +// Import editor contribution to trigger action registration. +import '../../browser/editor.contribution.js'; + +suite('Sessions - Editor Contribution', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('maximize editor hides the terminal panel before maximizing', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const layoutService = new class extends mock() { + readonly calls: string[] = []; + readonly hiddenParts: Parts[] = []; + editorMaximized = false; + panelVisible = true; + + override isVisible(part: Parts): boolean { + return part === Parts.PANEL_PART ? this.panelVisible : false; + } + + override setPartHidden(hidden: boolean, part: Parts): void { + if (part === Parts.PANEL_PART) { + this.panelVisible = !hidden; + } + + if (hidden && part === Parts.PANEL_PART) { + this.calls.push('hidePanel'); + this.hiddenParts.push(part); + } + } + + override setEditorMaximized(maximized: boolean): void { + this.calls.push(maximized ? 'maximizeEditor' : 'restoreEditor'); + this.editorMaximized = maximized; + } + }; + instantiationService.set(IAgentWorkbenchLayoutService, layoutService); + instantiationService.set(IViewsService, new class extends mock() { + override isViewVisible(id: string): boolean { + return id === TERMINAL_VIEW_ID; + } + }); + + const handler = CommandsRegistry.getCommand('workbench.action.agentSessions.maximizeMainEditorPart')?.handler; + assert.ok(handler, 'Command handler should be registered'); + + await handler(instantiationService); + + assert.deepStrictEqual(layoutService.calls, ['hidePanel', 'maximizeEditor']); + assert.deepStrictEqual(layoutService.hiddenParts, [Parts.PANEL_PART]); + assert.strictEqual(layoutService.editorMaximized, true); + }); + + test('maximize editor keeps non-terminal panels visible', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const layoutService = new class extends mock() { + readonly hiddenParts: Parts[] = []; + editorMaximized = false; + panelVisible = true; + + override isVisible(part: Parts): boolean { + return part === Parts.PANEL_PART ? this.panelVisible : false; + } + + override setPartHidden(hidden: boolean, part: Parts): void { + if (part === Parts.PANEL_PART) { + this.panelVisible = !hidden; + } + + if (hidden && part === Parts.PANEL_PART) { + this.hiddenParts.push(part); + } + } + + override setEditorMaximized(maximized: boolean): void { + this.editorMaximized = maximized; + } + }; + instantiationService.set(IAgentWorkbenchLayoutService, layoutService); + instantiationService.set(IViewsService, new class extends mock() { + override isViewVisible(_id: string): boolean { + return false; + } + }); + + const handler = CommandsRegistry.getCommand('workbench.action.agentSessions.maximizeMainEditorPart')?.handler; + assert.ok(handler, 'Command handler should be registered'); + + await handler(instantiationService); + + assert.deepStrictEqual(layoutService.hiddenParts, []); + assert.strictEqual(layoutService.editorMaximized, true); + }); + + test('restore editor reopens the terminal panel when maximize hid it', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const layoutService = new class extends mock() { + readonly hiddenParts: Parts[] = []; + readonly shownParts: Parts[] = []; + readonly maximizedStates: boolean[] = []; + panelVisible = true; + + override isVisible(part: Parts): boolean { + return part === Parts.PANEL_PART ? this.panelVisible : false; + } + + override setPartHidden(hidden: boolean, part: Parts): void { + if (part === Parts.PANEL_PART) { + this.panelVisible = !hidden; + if (hidden) { + this.hiddenParts.push(part); + } else { + this.shownParts.push(part); + } + } + } + + override setEditorMaximized(maximized: boolean): void { + this.maximizedStates.push(maximized); + } + }; + instantiationService.set(IAgentWorkbenchLayoutService, layoutService); + instantiationService.set(IViewsService, new class extends mock() { + override isViewVisible(id: string): boolean { + return id === TERMINAL_VIEW_ID; + } + }); + + const maximizeHandler = CommandsRegistry.getCommand('workbench.action.agentSessions.maximizeMainEditorPart')?.handler; + const restoreHandler = CommandsRegistry.getCommand('workbench.action.agentSessions.restoreMainEditorPart')?.handler; + assert.ok(maximizeHandler, 'Maximize command handler should be registered'); + assert.ok(restoreHandler, 'Restore command handler should be registered'); + + await maximizeHandler(instantiationService); + await restoreHandler(instantiationService); + + assert.deepStrictEqual(layoutService.hiddenParts, [Parts.PANEL_PART]); + assert.deepStrictEqual(layoutService.shownParts, [Parts.PANEL_PART]); + assert.deepStrictEqual(layoutService.maximizedStates, [true, false]); + assert.strictEqual(layoutService.panelVisible, true); + }); + + test('restore editor does not reopen the panel when maximize left it visible', async () => { + const instantiationService = store.add(new TestInstantiationService()); + const layoutService = new class extends mock() { + readonly shownParts: Parts[] = []; + readonly maximizedStates: boolean[] = []; + panelVisible = true; + + override isVisible(part: Parts): boolean { + return part === Parts.PANEL_PART ? this.panelVisible : false; + } + + override setPartHidden(hidden: boolean, part: Parts): void { + if (part === Parts.PANEL_PART) { + this.panelVisible = !hidden; + if (!hidden) { + this.shownParts.push(part); + } + } + } + + override setEditorMaximized(maximized: boolean): void { + this.maximizedStates.push(maximized); + } + }; + instantiationService.set(IAgentWorkbenchLayoutService, layoutService); + instantiationService.set(IViewsService, new class extends mock() { + override isViewVisible(_id: string): boolean { + return false; + } + }); + + const maximizeHandler = CommandsRegistry.getCommand('workbench.action.agentSessions.maximizeMainEditorPart')?.handler; + const restoreHandler = CommandsRegistry.getCommand('workbench.action.agentSessions.restoreMainEditorPart')?.handler; + assert.ok(maximizeHandler, 'Maximize command handler should be registered'); + assert.ok(restoreHandler, 'Restore command handler should be registered'); + + await maximizeHandler(instantiationService); + await restoreHandler(instantiationService); + + assert.deepStrictEqual(layoutService.shownParts, []); + assert.deepStrictEqual(layoutService.maximizedStates, [true, false]); + assert.strictEqual(layoutService.panelVisible, true); + }); +}); From 0a8abf6b2b34c122d0764dac98dffe9f278d8dda Mon Sep 17 00:00:00 2001 From: Hawk Ticehurst <39639992+hawkticehurst@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:05:24 -0400 Subject: [PATCH 24/35] sessions: show pointer cursor on new session button (#312046) * sessions: show pointer cursor on new session button (#311982) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: preserve disabled new session cursor (#311982) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../contrib/sessions/browser/media/sessionsViewPane.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css index c16dc394883f5..a7ace353613ee 100644 --- a/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css +++ b/src/vs/sessions/contrib/sessions/browser/media/sessionsViewPane.css @@ -168,6 +168,10 @@ white-space: nowrap; } + .agent-sessions-compact-new-button.monaco-button:not(.disabled) { + cursor: pointer; + } + .agent-sessions-compact-new-button.monaco-button.default-colors:hover { background-color: var(--vscode-agentsNewSessionButton-hoverBackground, var(--vscode-toolbar-hoverBackground)); } From b88dca159c0b8ced67a7cd3371005aa76e48cb00 Mon Sep 17 00:00:00 2001 From: Tyler James Leonhardt <2644648+TylerLeonhardt@users.noreply.github.com> Date: Wed, 22 Apr 2026 19:46:28 -0700 Subject: [PATCH 25/35] feat(claude): implement workspace folder service for tracking file changes in chat sessions (#312043) * feat(claude): implement workspace folder service for tracking file changes in chat sessions For now, we implement a Claude specific workspace folder service... although there is nothing Claude specific about it. This gets changes to show up in Claude sessions in the Agents App. * docs * feedback --- .../extension/chatSessions/claude/AGENTS.md | 2 + .../claude/CLAUDE_SESSION_USER_GUIDE.md | 1 + .../common/claudeWorkspaceFolderService.ts | 26 ++ .../chatSessions/vscode-node/chatSessions.ts | 3 + .../claudeChatSessionContentProvider.ts | 54 +++-- .../claudeWorkspaceFolderServiceImpl.ts | 225 ++++++++++++++++++ .../claudeChatSessionContentProvider.spec.ts | 107 +++++++++ .../test/claudeWorkspaceFolderService.spec.ts | 219 +++++++++++++++++ 8 files changed, 618 insertions(+), 19 deletions(-) create mode 100644 extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts create mode 100644 extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts diff --git a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md index 9e8ba850f03bf..fa726f71d883d 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md +++ b/extensions/copilot/src/extension/chatSessions/claude/AGENTS.md @@ -214,6 +214,8 @@ In multi-root and empty workspaces, a folder picker option appears in the chat s ### Key Files - **`common/claudeFolderInfo.ts`**: `ClaudeFolderInfo` interface +- **`../../chatSessions/common/claudeWorkspaceFolderService.ts`**: `IClaudeWorkspaceFolderService` interface — computes git diff changes for session items +- **`../../chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts`**: Implementation — diffs the session's branch against its base branch, caches results, and maps changes to `ChatSessionChangedFile[]` for display in the Sessions view - **`../../chatSessions/vscode-node/claudeChatSessionContentProvider.ts`**: Folder resolution, picker options, and handler integration - **`../../chatSessions/vscode-node/folderRepositoryManagerImpl.ts`**: `FolderRepositoryManager` (abstract base) with `ClaudeFolderRepositoryManager` subclass — the Claude subclass does not depend on `ICopilotCLISessionService` (CopilotCLI has its own subclass `CopilotCLIFolderRepositoryManager`) - **`node/claudeCodeAgent.ts`**: Consumes `ClaudeFolderInfo` in `ClaudeCodeSession._startSession()` diff --git a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md index 669f49ac5a375..d2cb58a4587c2 100644 --- a/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md +++ b/extensions/copilot/src/extension/chatSessions/claude/CLAUDE_SESSION_USER_GUIDE.md @@ -225,6 +225,7 @@ Each session in the list displays: | **Blue dot** | Indicates an unread or recently active session | | **Status icon** | Shows whether the session is completed, in progress, needs input, or failed | | **Folder badge** | In multi-root or empty workspaces, shows which folder the session ran in | +| **Change stats** | Shows lines added and removed (e.g., `+584 -17`) — a quick summary of the session's code impact, computed by diffing the session's branch against its base branch | Sessions are sorted by recency — the most recent session appears at the top. In the dedicated sidebar, they're also grouped by time period. diff --git a/extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts b/extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts new file mode 100644 index 0000000000000..a5ae09dfaac59 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/common/claudeWorkspaceFolderService.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type * as vscode from 'vscode'; +import { createServiceIdentifier } from '../../../util/common/services'; + +export const IClaudeWorkspaceFolderService = createServiceIdentifier('IClaudeWorkspaceFolderService'); + +/** + * Service for computing and caching workspace file changes for Claude chat sessions. + */ +export interface IClaudeWorkspaceFolderService { + readonly _serviceBrand: undefined; + /** + * Computes file changes for a workspace directory by diffing the current branch against a base branch. + * Results are cached per unique (cwd, gitBranch, gitBaseBranch) combination. + * + * @param cwd The working directory of the session. + * @param gitBranch The current git branch name, or `undefined` if unknown. + * @param gitBaseBranch The base branch to diff against, or `undefined` to diff against HEAD. + * @param forceRefresh When `true`, bypasses the cache and recomputes changes. + */ + getWorkspaceChanges(cwd: string, gitBranch: string | undefined, gitBaseBranch: string | undefined, forceRefresh?: boolean): Promise; +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts index da43cfc3bdb2e..40b5162b95280 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/chatSessions.ts @@ -38,6 +38,7 @@ import { ClaudeSlashCommandService, IClaudeSlashCommandService } from '../claude import { IAgentSessionsWorkspace } from '../common/agentSessionsWorkspace'; import { IChatSessionMetadataStore } from '../common/chatSessionMetadataStore'; import { IChatSessionWorkspaceFolderService } from '../common/chatSessionWorkspaceFolderService'; +import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService'; import { IChatSessionWorktreeCheckpointService } from '../common/chatSessionWorktreeCheckpointService'; import { IChatSessionWorktreeService } from '../common/chatSessionWorktreeService'; import { IChatFolderMruService, IFolderRepositoryManager } from '../common/folderRepositoryManager'; @@ -61,6 +62,7 @@ import { UserQuestionHandler } from './askUserQuestionHandler'; import { ChatSessionMetadataStore } from './chatSessionMetadataStoreImpl'; import { ChatSessionRepositoryTracker } from './chatSessionRepositoryTracker'; import { ChatSessionWorkspaceFolderService } from './chatSessionWorkspaceFolderServiceImpl'; +import { ClaudeWorkspaceFolderService } from './claudeWorkspaceFolderServiceImpl'; import { ChatSessionWorktreeCheckpointService } from './chatSessionWorktreeCheckpointServiceImpl'; import { ChatSessionWorktreeService } from './chatSessionWorktreeServiceImpl'; import { ClaudeChatSessionContentProvider } from './claudeChatSessionContentProvider'; @@ -142,6 +144,7 @@ export class ChatSessionsContrib extends Disposable implements IExtensionContrib [IChatSessionWorktreeService, new SyncDescriptor(ChatSessionWorktreeService)], [IChatSessionWorktreeCheckpointService, new SyncDescriptor(ChatSessionWorktreeCheckpointService)], [IChatSessionWorkspaceFolderService, new SyncDescriptor(ChatSessionWorkspaceFolderService)], + [IClaudeWorkspaceFolderService, new SyncDescriptor(ClaudeWorkspaceFolderService)], [IFolderRepositoryManager, new SyncDescriptor(ClaudeFolderRepositoryManager)], [IChatFolderMruService, new SyncDescriptor(ClaudeCodeFolderMruService)], [IClaudeRuntimeDataService, new SyncDescriptor(ClaudeRuntimeDataService)], diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts index abfb16436e390..56bdd49a90938 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeChatSessionContentProvider.ts @@ -18,6 +18,7 @@ import { autorun, derived, IObservable, ISettableObservable, observableFromEvent import { basename } from '../../../util/vs/base/common/resources'; import { URI } from '../../../util/vs/base/common/uri'; import { generateUuid } from '../../../util/vs/base/common/uuid'; +import { IInstantiationService } from '../../../util/vs/platform/instantiation/common/instantiation'; import { ClaudeFolderInfo } from '../claude/common/claudeFolderInfo'; import { ClaudeSessionUri } from '../claude/common/claudeSessionUri'; import { ClaudeAgentManager } from '../claude/node/claudeCodeAgent'; @@ -29,6 +30,7 @@ import { IClaudeCodeSessionService } from '../claude/node/sessionParser/claudeCo import { IClaudeCodeSessionInfo } from '../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../claude/vscode-node/claudeSlashCommandService'; import { IChatFolderMruService } from '../common/folderRepositoryManager'; +import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService'; import { buildChatHistory } from './chatHistoryBuilder'; import { ClaudeSessionOptionBuilder, buildPermissionModeItems, FOLDER_OPTION_ID, isPermissionMode, PERMISSION_MODE_OPTION_ID } from './claudeSessionOptionBuilder'; import { toWorkspaceFolderOptionItem } from './sessionOptionGroupBuilder'; @@ -60,21 +62,11 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco @IClaudeCodeSessionService private readonly sessionService: IClaudeCodeSessionService, @IClaudeSessionStateService private readonly sessionStateService: IClaudeSessionStateService, @IClaudeSlashCommandService private readonly slashCommandService: IClaudeSlashCommandService, - @IConfigurationService configurationService: IConfigurationService, @IClaudeCodeModels private readonly claudeModels: IClaudeCodeModels, - @IChatFolderMruService folderMruService: IChatFolderMruService, - @IWorkspaceService workspaceService: IWorkspaceService, - @INativeEnvService envService: INativeEnvService, - @IGitService gitService: IGitService, - @IClaudeCodeSdkService sdkService: IClaudeCodeSdkService, - @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService ) { super(); - this._controller = this._register(new ClaudeChatSessionItemController( - sessionService, sessionStateService, configurationService, - folderMruService, workspaceService, envService, - gitService, sdkService, logService, - )); + this._controller = this._register(instantiationService.createInstance(ClaudeChatSessionItemController)); } // #region Chat Participant Handler @@ -134,9 +126,9 @@ export class ClaudeChatSessionContentProvider extends Disposable implements vsco }); const prompt = request.prompt; - this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.InProgress, prompt); + await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.InProgress, prompt); const result = await this.claudeAgentManager.handleRequest(effectiveSessionId, request, context, stream, token, isNewSession, yieldRequested); - this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt); + await this._controller.updateItemStatus(effectiveSessionId, vscode.ChatSessionStatus.Completed, prompt); // Clear usage handler after request completes this.sessionStateService.setUsageHandlerForSession(effectiveSessionId, undefined); @@ -217,6 +209,7 @@ export class ClaudeChatSessionItemController extends Disposable { @IGitService private readonly _gitService: IGitService, @IClaudeCodeSdkService private readonly _sdkService: IClaudeCodeSdkService, @ILogService private readonly _logService: ILogService, + @IClaudeWorkspaceFolderService private readonly _claudeWorkspaceFolderService: IClaudeWorkspaceFolderService, ) { super(); this._optionBuilder = new ClaudeSessionOptionBuilder(_configurationService, folderMruService, _workspaceService); @@ -642,7 +635,7 @@ export class ClaudeChatSessionItemController extends Disposable { if (!item) { const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None); if (session) { - item = this._createClaudeChatSessionItem(session); + item = await this._createClaudeChatSessionItem(session); } else { const newlyCreatedSessionInfo: IClaudeCodeSessionInfo = { id: sessionId, @@ -651,7 +644,7 @@ export class ClaudeChatSessionItemController extends Disposable { lastRequestEnded: Date.now(), folderName: undefined }; - item = this._createClaudeChatSessionItem(newlyCreatedSessionInfo); + item = await this._createClaudeChatSessionItem(newlyCreatedSessionInfo); } this._controller.items.add(item); @@ -676,18 +669,37 @@ export class ClaudeChatSessionItemController extends Disposable { } else { item.timing = { ...item.timing, lastRequestEnded: Date.now() }; } + const session = await this._claudeCodeSessionService.getSession(resource, CancellationToken.None); + if (session?.cwd) { + item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( + session.cwd, + session.gitBranch, + undefined, + true, + ); + } } } } private async _refreshItems(token: vscode.CancellationToken): Promise { const sessions = await this._claudeCodeSessionService.getAllSessions(token); - const items = sessions.map(session => this._createClaudeChatSessionItem(session)); + const results = await Promise.allSettled(sessions.map(session => this._createClaudeChatSessionItem(session))); + const items: vscode.ChatSessionItem[] = []; + for (let i = 0; i < results.length; i++) { + const result = results[i]; + if (result.status === 'fulfilled') { + items.push(result.value); + } else { + const session = sessions[i]; + this._logService.warn(`Failed to create Claude chat session item for ${session.id} (${session.label}) ${result.reason}`); + } + } items.push(...this._inProgressItems.values()); this._controller.items.replace(items); } - private _createClaudeChatSessionItem(session: IClaudeCodeSessionInfo): vscode.ChatSessionItem { + private async _createClaudeChatSessionItem(session: IClaudeCodeSessionInfo): Promise { let badge: vscode.MarkdownString | undefined; if (session.folderName && this._showBadge) { badge = new vscode.MarkdownString(`$(folder) ${session.folderName}`); @@ -704,8 +716,12 @@ export class ClaudeChatSessionItemController extends Disposable { }; item.iconPath = new vscode.ThemeIcon('claude'); if (session.cwd) { - // Agents app needs this to decide the working directory for the session item.metadata = { workingDirectoryPath: session.cwd }; + item.changes = await this._claudeWorkspaceFolderService.getWorkspaceChanges( + session.cwd, + session.gitBranch, + undefined, + ); } return item; } diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts new file mode 100644 index 0000000000000..8d403f7c6bdb2 --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/claudeWorkspaceFolderServiceImpl.ts @@ -0,0 +1,225 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../platform/extContext/common/extensionContext'; +import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; +import { IGitService } from '../../../platform/git/common/gitService'; +import { toGitUri } from '../../../platform/git/common/utils'; +import { buildTempIndexEnv, getUncommittedFilePaths, parseGitChangesRaw } from '../../../platform/git/vscode-node/utils'; +import { DiffChange } from '../../../platform/git/vscode/git'; +import { ILogService } from '../../../platform/log/common/logService'; +import * as path from '../../../util/vs/base/common/path'; +import { Disposable } from '../../../util/vs/base/common/lifecycle'; +import { generateUuid } from '../../../util/vs/base/common/uuid'; +import { ChatSessionWorktreeFile } from '../common/chatSessionWorktreeService'; +import { IClaudeWorkspaceFolderService } from '../common/claudeWorkspaceFolderService'; + +// #region Constants + +const EMPTY_TREE_OBJECT = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'; + +// #endregion + +export class ClaudeWorkspaceFolderService extends Disposable implements IClaudeWorkspaceFolderService { + declare _serviceBrand: undefined; + + private readonly _cache = new Map(); + private readonly _inflight = new Map>(); + + constructor( + @IGitService private readonly _gitService: IGitService, + @ILogService private readonly _logService: ILogService, + @IVSCodeExtensionContext private readonly _extensionContext: IVSCodeExtensionContext, + @IFileSystemService private readonly _fileSystemService: IFileSystemService, + ) { + super(); + } + + override dispose(): void { + this._cache.clear(); + this._inflight.clear(); + super.dispose(); + } + + async getWorkspaceChanges( + cwd: string, + gitBranch: string | undefined, + gitBaseBranch: string | undefined, + forceRefresh?: boolean, + ): Promise { + const cacheKey = `${cwd}\0${gitBranch ?? ''}\0${gitBaseBranch ?? ''}`; + + if (!forceRefresh) { + const cached = this._cache.get(cacheKey); + if (cached) { + return cached; + } + } + + const existing = this._inflight.get(cacheKey); + if (existing) { + return existing; + } + + const promise = this._computeAndCacheChanges(cacheKey, cwd, gitBranch, gitBaseBranch); + this._inflight.set(cacheKey, promise); + try { + return await promise; + } finally { + this._inflight.delete(cacheKey); + } + } + + private async _computeAndCacheChanges( + cacheKey: string, + cwd: string, + gitBranch: string | undefined, + gitBaseBranch: string | undefined, + ): Promise { + const result = await this.computeRepositoryChanges(cwd, gitBranch, gitBaseBranch); + if (!result) { + return []; + } + + const originalRef = result.mergeBaseCommit ?? 'HEAD'; + const changes = result.changes.map(change => new vscode.ChatSessionChangedFile( + vscode.Uri.file(change.filePath), + change.originalFilePath + ? toGitUri(vscode.Uri.file(change.originalFilePath), originalRef) + : undefined, + change.modifiedFilePath + ? vscode.Uri.file(change.modifiedFilePath) + : undefined, + change.statistics.additions, + change.statistics.deletions, + )); + + this._cache.set(cacheKey, changes); + return changes; + } + + private async computeRepositoryChanges( + repositoryPath: string, + branchName: string | undefined, + baseBranchName: string | undefined, + ): Promise<{ + readonly changes: ChatSessionWorktreeFile[]; + readonly mergeBaseCommit?: string; + } | undefined> { + const repository = await this._gitService.getRepository(vscode.Uri.file(repositoryPath)); + if (!repository?.changes) { + this._logService.warn(`[ClaudeWorkspaceFolderService] No repository found at ${repositoryPath}`); + return undefined; + } + + let resolvedBaseBranchName = baseBranchName; + if (!resolvedBaseBranchName && branchName && repository.headCommitHash) { + try { + const branchBase = await this._gitService.getBranchBase(repository.rootUri, branchName); + resolvedBaseBranchName = branchBase?.name; + } catch (error) { + this._logService.warn(`[ClaudeWorkspaceFolderService] Failed to resolve base branch for ${branchName}: ${error}`); + } + } + + // Check for untracked changes, only if the session branch matches the current branch + const hasUntrackedChanges = branchName === repository.headBranchName + ? [ + ...repository.changes?.workingTree ?? [], + ...repository.changes?.untrackedChanges ?? [], + ].some(change => change.status === 7 /* UNTRACKED */) + : false; + + const diffChanges: DiffChange[] = []; + + // If the repository is using a virtual file system, we need to + // disable rename detection to avoid expensive git operations + const noRenamesArg = repository.isUsingVirtualFileSystem + ? ['--no-renames'] + : []; + + const mergeBaseArg = resolvedBaseBranchName + ? ['--merge-base', resolvedBaseBranchName] + : ['HEAD']; + + if (hasUntrackedChanges) { + // Tracked + untracked changes + const tmpDirName = `vscode-sessions-${generateUuid()}`; + const diffIndexFile = path.join(this._extensionContext.globalStorageUri.fsPath, tmpDirName, 'diff.index'); + const pathspecFile = path.join(this._extensionContext.globalStorageUri.fsPath, tmpDirName, `pathspec.txt`); + + const env = buildTempIndexEnv(repository, diffIndexFile); + + try { + // Create temp index file directory + await this._fileSystemService.createDirectory(vscode.Uri.file(path.dirname(diffIndexFile))); + + try { + // Populate temp index from HEAD, fall back to empty tree if no commits exist + await this._gitService.exec(repository.rootUri, ['read-tree', 'HEAD'], env); + } catch { + // Fall back to empty tree for repositories with no commits + await this._gitService.exec(repository.rootUri, ['read-tree', EMPTY_TREE_OBJECT], env); + } + + // Stage entire working directory into temp index + const uncommittedFilePaths = getUncommittedFilePaths(repository); + await this._fileSystemService.writeFile(vscode.Uri.file(pathspecFile), new TextEncoder().encode(uncommittedFilePaths.join('\n'))); + await this._gitService.exec(repository.rootUri, ['add', '-A', `--pathspec-from-file=${pathspecFile}`], env); + + // Diff the temp index with the base branch + const result = await this._gitService.exec(repository.rootUri, ['diff', '--cached', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--'], env); + diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while processing workspace changes: ${error}`); + return undefined; + } finally { + try { + await this._fileSystemService.delete(vscode.Uri.file(path.dirname(diffIndexFile)), { recursive: true }); + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while cleaning up temp index file: ${error}`); + } + } + } else { + // Tracked changes + try { + const result = await this._gitService.exec(repository.rootUri, ['diff', '--raw', '--numstat', '--diff-filter=ADMR', ...noRenamesArg, '-z', ...mergeBaseArg, '--']); + diffChanges.push(...parseGitChangesRaw(repository.rootUri.fsPath, result)); + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while processing workspace changes: ${error}`); + return undefined; + } + } + + // Since the diff may be computed using the merge base commit of the current + // branch and the base branch, we need to compute it as well so that we can use + // it as the originalRef (left-hand side) of the diff editor + let mergeBaseCommit: string | undefined; + try { + if (branchName && resolvedBaseBranchName) { + mergeBaseCommit = await this._gitService.getMergeBase(repository.rootUri, branchName, resolvedBaseBranchName); + } + } catch (error) { + this._logService.error(`[ClaudeWorkspaceFolderService] Error while getting merge base (${branchName}, ${resolvedBaseBranchName}): ${error}`); + } + + const changes = diffChanges.map(change => ({ + filePath: change.uri.fsPath, + originalFilePath: change.status !== 1 /* INDEX_ADDED */ + ? change.originalUri?.fsPath + : undefined, + modifiedFilePath: change.status !== 6 /* DELETED */ + ? change.uri.fsPath + : undefined, + statistics: { + additions: change.insertions, + deletions: change.deletions + } + } satisfies ChatSessionWorktreeFile)); + + return { changes, mergeBaseCommit }; + } +} diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts index 2bd2a3126ab37..a9246a68bb487 100644 --- a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeChatSessionContentProvider.spec.ts @@ -33,6 +33,7 @@ import { IClaudeCodeSessionService } from '../../claude/node/sessionParser/claud import { IClaudeCodeSessionInfo } from '../../claude/node/sessionParser/claudeSessionSchema'; import { IClaudeSlashCommandService } from '../../claude/vscode-node/claudeSlashCommandService'; import { FolderRepositoryMRUEntry, IChatFolderMruService } from '../../common/folderRepositoryManager'; +import { IClaudeWorkspaceFolderService } from '../../common/claudeWorkspaceFolderService'; import { ClaudeChatSessionContentProvider, ClaudeChatSessionItemController } from '../claudeChatSessionContentProvider'; // Expose the most recently created items map so tests can inspect controller items. @@ -256,6 +257,10 @@ function createProviderWithServices( listSubagents: vi.fn().mockResolvedValue([]), getSubagentMessages: vi.fn().mockResolvedValue([]), }); + serviceCollection.define(IClaudeWorkspaceFolderService, { + _serviceBrand: undefined, + getWorkspaceChanges: vi.fn().mockResolvedValue([]), + }); const accessor = serviceCollection.createTestingAccessor(); const instaService = accessor.get(IInstantiationService); @@ -1240,6 +1245,10 @@ describe('ClaudeChatSessionItemController', () => { getSubagentMessages: vi.fn().mockResolvedValue([]), }; serviceCollection.define(IClaudeCodeSdkService, mockSdkService); + serviceCollection.define(IClaudeWorkspaceFolderService, { + _serviceBrand: undefined, + getWorkspaceChanges: vi.fn().mockResolvedValue([]), + }); const accessor = serviceCollection.createTestingAccessor(); lastControllerAccessor = accessor; const ctrl = accessor.get(IInstantiationService).createInstance(ClaudeChatSessionItemController); @@ -1356,6 +1365,77 @@ describe('ClaudeChatSessionItemController', () => { expect(itemA!.status).toBe(ChatSessionStatus.Completed); expect(itemB!.status).toBe(ChatSessionStatus.InProgress); }); + + it('calls getWorkspaceChanges on Completed status when session has cwd', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'changes-session', + label: 'Changes Session', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + gitBranch: 'feature-branch', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }]; + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any); + + await controller.updateItemStatus('changes-session', ChatSessionStatus.InProgress, 'Prompt'); + await controller.updateItemStatus('changes-session', ChatSessionStatus.Completed, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith( + '/home/user/my-project', + 'feature-branch', + undefined, + true, + ); + const item = getItem('changes-session'); + expect(item!.changes).toBe(mockChanges); + }); + + it('does not call getWorkspaceChanges on Completed when session has no cwd', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'no-cwd', + label: 'No CWD', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: undefined, + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + + await controller.updateItemStatus('no-cwd', ChatSessionStatus.InProgress, 'Prompt'); + await controller.updateItemStatus('no-cwd', ChatSessionStatus.Completed, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalled(); + }); + + it('does not call getWorkspaceChanges with forceRefresh on InProgress status', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'in-progress', + label: 'In Progress', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + gitBranch: 'feature-branch', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + + await controller.updateItemStatus('in-progress', ChatSessionStatus.InProgress, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + true, + ); + }); }); // #endregion @@ -1433,6 +1513,33 @@ describe('ClaudeChatSessionItemController', () => { const item = getItem('no-cwd-session'); expect(item!.metadata).toBeUndefined(); }); + + it('populates item.changes when session has cwd and gitBranch', async () => { + const diskSession: IClaudeCodeSessionInfo = { + id: 'changes-item', + label: 'Changes Item', + created: Date.now(), + lastRequestEnded: Date.now(), + folderName: 'my-project', + cwd: '/home/user/my-project', + gitBranch: 'feature-branch', + }; + vi.mocked(mockSessionService.getSession).mockResolvedValue(diskSession as any); + + const mockChanges = [{ uri: URI.file('/home/user/my-project/file.ts') }]; + const workspaceFolderService = lastControllerAccessor.get(IClaudeWorkspaceFolderService); + vi.mocked(workspaceFolderService.getWorkspaceChanges).mockResolvedValue(mockChanges as any); + + await controller.updateItemStatus('changes-item', ChatSessionStatus.InProgress, 'Prompt'); + + expect(workspaceFolderService.getWorkspaceChanges).toHaveBeenCalledWith( + '/home/user/my-project', + 'feature-branch', + undefined, + ); + const item = getItem('changes-item'); + expect(item!.changes).toBe(mockChanges); + }); }); // #endregion diff --git a/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts new file mode 100644 index 0000000000000..cf0734a4fbd8b --- /dev/null +++ b/extensions/copilot/src/extension/chatSessions/vscode-node/test/claudeWorkspaceFolderService.spec.ts @@ -0,0 +1,219 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { IVSCodeExtensionContext } from '../../../../platform/extContext/common/extensionContext'; +import { MockFileSystemService } from '../../../../platform/filesystem/node/test/mockFileSystemService'; +import { RepoContext } from '../../../../platform/git/common/gitService'; +import { MockGitService } from '../../../../platform/ignore/node/test/mockGitService'; +import { ILogService } from '../../../../platform/log/common/logService'; +import { mock } from '../../../../util/common/test/simpleMock'; +import { constObservable } from '../../../../util/vs/base/common/observableInternal'; +import { URI } from '../../../../util/vs/base/common/uri'; +import { ClaudeWorkspaceFolderService } from '../claudeWorkspaceFolderServiceImpl'; + +class MockLogService extends mock() { + override trace = vi.fn(); + override info = vi.fn(); + override warn = vi.fn(); + override error = vi.fn(); + override debug = vi.fn(); +} + +class MockExtensionContext extends mock() { + override globalStorageUri = vscode.Uri.file('/mock/global/storage'); +} + +function createMockRepoContext(overrides?: Partial): RepoContext { + return { + rootUri: URI.file('/mock/repo'), + kind: 0 as any, + isUsingVirtualFileSystem: false, + headIncomingChanges: undefined, + headOutgoingChanges: undefined, + headBranchName: 'feature-branch', + headCommitHash: 'abc123', + upstreamBranchName: undefined, + upstreamRemote: undefined, + isRebasing: false, + remotes: [], + worktrees: [], + changes: { + mergeChanges: [], + indexChanges: [], + workingTree: [], + untrackedChanges: [], + }, + headBranchNameObs: constObservable('feature-branch'), + headCommitHashObs: constObservable('abc123'), + upstreamBranchNameObs: constObservable(undefined), + upstreamRemoteObs: constObservable(undefined), + isRebasingObs: constObservable(false), + isIgnored: vi.fn().mockResolvedValue(false), + ...overrides, + }; +} + +describe('ClaudeWorkspaceFolderService', () => { + let gitService: MockGitService; + let logService: MockLogService; + let extensionContext: MockExtensionContext; + let fileSystemService: MockFileSystemService; + let service: ClaudeWorkspaceFolderService; + + beforeEach(() => { + gitService = new MockGitService(); + logService = new MockLogService(); + extensionContext = new MockExtensionContext(); + fileSystemService = new MockFileSystemService(); + service = new ClaudeWorkspaceFolderService(gitService, logService, extensionContext, fileSystemService); + }); + + describe('getWorkspaceChanges', () => { + it('returns empty array when repository is not found', async () => { + gitService.getRepository = vi.fn().mockResolvedValue(undefined); + + const result = await service.getWorkspaceChanges('/nonexistent', 'main', undefined); + + expect(result).toEqual([]); + expect(logService.warn).toHaveBeenCalled(); + }); + + it('returns empty array when repository has no changes object', async () => { + gitService.getRepository = vi.fn().mockResolvedValue( + createMockRepoContext({ changes: undefined }), + ); + + const result = await service.getWorkspaceChanges('/mock/repo', 'main', undefined); + + expect(result).toEqual([]); + }); + + it('returns cached result on second call with same inputs', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockResolvedValue(''); + + const result1 = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + const result2 = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result1).toBe(result2); + expect(gitService.exec).toHaveBeenCalledTimes(1); + }); + + it('bypasses cache when forceRefresh is true', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined, true); + + expect(gitService.exec).toHaveBeenCalledTimes(2); + }); + + it('returns empty array on git exec error', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockRejectedValue(new Error('git failed')); + + const result = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result).toEqual([]); + expect(logService.error).toHaveBeenCalled(); + }); + }); + + describe('base branch auto-resolution', () => { + it('calls getBranchBase when gitBaseBranch is undefined and gitBranch is provided', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn().mockResolvedValue({ name: 'main', commit: 'def456', type: 0 }); + gitService.exec = vi.fn().mockResolvedValue(''); + gitService.getMergeBase = vi.fn().mockResolvedValue('def456'); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(gitService.getBranchBase).toHaveBeenCalledWith(repo.rootUri, 'feature-branch'); + expect(gitService.exec).toHaveBeenCalledWith( + repo.rootUri, + expect.arrayContaining(['--merge-base', 'main']), + ); + }); + + it('does not call getBranchBase when gitBaseBranch is explicitly provided', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn(); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', 'develop'); + + expect(gitService.getBranchBase).not.toHaveBeenCalled(); + expect(gitService.exec).toHaveBeenCalledWith( + repo.rootUri, + expect.arrayContaining(['--merge-base', 'develop']), + ); + }); + + it('handles getBranchBase returning undefined gracefully', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn().mockResolvedValue(undefined); + gitService.exec = vi.fn().mockResolvedValue(''); + + const result = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result).toEqual([]); + expect(gitService.exec).toHaveBeenCalledWith( + repo.rootUri, + expect.not.arrayContaining(['--merge-base']), + ); + }); + + it('handles getBranchBase throwing an error gracefully', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn().mockRejectedValue(new Error('branch not found')); + gitService.exec = vi.fn().mockResolvedValue(''); + + const result = await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(result).toEqual([]); + expect(logService.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to resolve base branch'), + ); + }); + + it('does not call getBranchBase when headCommitHash is undefined', async () => { + const repo = createMockRepoContext({ headCommitHash: undefined }); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.getBranchBase = vi.fn(); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(gitService.getBranchBase).not.toHaveBeenCalled(); + }); + }); + + describe('dispose', () => { + it('clears the cache on dispose', async () => { + const repo = createMockRepoContext(); + gitService.getRepository = vi.fn().mockResolvedValue(repo); + gitService.exec = vi.fn().mockResolvedValue(''); + + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + service.dispose(); + + gitService.exec = vi.fn().mockResolvedValue(''); + await service.getWorkspaceChanges('/mock/repo', 'feature-branch', undefined); + + expect(gitService.exec).toHaveBeenCalledTimes(1); + }); + }); +}); From b646274e4236933f83ac4f0ba98b7b1970cf9bc8 Mon Sep 17 00:00:00 2001 From: Vikram Nitin Date: Wed, 22 Apr 2026 22:13:18 -0500 Subject: [PATCH 26/35] [Feature] Execution Subagent Custom Model (#311602) * Added option for Proxy exec subagent endpoint * New prompt * Small gating change in prompts * Update extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update extensions/copilot/src/platform/endpoint/node/proxyAgenticExecutionEndpoint.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Merged ProxyAgenticExecutionEndpoint with Search * Use ToolName in prompt and remove unnecessary logging statement * Fixed type error * Removed extra line --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- extensions/copilot/package.json | 10 +++++++ extensions/copilot/package.nls.json | 3 +- .../node/executionSubagentToolCallingLoop.ts | 30 +++++++++++++------ .../node/searchSubagentToolCallingLoop.ts | 4 +-- .../prompts/node/agent/anthropicPrompts.tsx | 4 +-- .../node/agent/defaultAgentInstructions.tsx | 2 +- .../node/agent/executionSubagentPrompt.tsx | 19 ++++++++++-- .../common/configurationService.ts | 4 ++- ...rchEndpoint.ts => proxyAgenticEndpoint.ts} | 2 +- 9 files changed, 58 insertions(+), 20 deletions(-) rename extensions/copilot/src/platform/endpoint/node/{proxyAgenticSearchEndpoint.ts => proxyAgenticEndpoint.ts} (98%) diff --git a/extensions/copilot/package.json b/extensions/copilot/package.json index 3647fe870ad83..70241a6127f6d 100644 --- a/extensions/copilot/package.json +++ b/extensions/copilot/package.json @@ -4385,6 +4385,16 @@ "onExp" ] }, + "github.copilot.chat.executionSubagent.useAgenticProxy": { + "type": "boolean", + "default": false, + "markdownDescription": "%github.copilot.config.executionSubagent.useAgenticProxy%", + "tags": [ + "advanced", + "experimental", + "onExp" + ] + }, "github.copilot.chat.executionSubagent.toolCallLimit": { "type": "number", "default": 10, diff --git a/extensions/copilot/package.nls.json b/extensions/copilot/package.nls.json index fe77f97c4a27a..0eeded7c48f73 100644 --- a/extensions/copilot/package.nls.json +++ b/extensions/copilot/package.nls.json @@ -493,7 +493,8 @@ "copilot.tools.executionSubagent.name": "Execution Subagent", "copilot.tools.executionSubagent.description": "Launch an execution-focused subagent that runs one or more terminal commands to accomplish a task. It is designed to select an efficient summary of the terminal outputs to return to the main agent context.", "github.copilot.config.executionSubagent.enabled": "Enable the Execution Subagent tool in Copilot Chat. The Execution Subagent is designed to run terminal commands to accomplish an execution-based task.", - "github.copilot.config.executionSubagent.model": "The model to use for the Execution Subagent tool in Copilot Chat. Leave empty to use the default model.", + "github.copilot.config.executionSubagent.useAgenticProxy": "Use the agentic proxy endpoint for the execution subagent.", + "github.copilot.config.executionSubagent.model": "The model to use for the Execution Subagent tool in Copilot Chat. When useAgenticProxy is enabled, defaults to 'exec-subagent-router-a'. Otherwise defaults to the main agent model.", "github.copilot.config.executionSubagent.toolCallLimit": "Maximum number of tool calls the Execution Subagent can make during execution.", "github.copilot.session.providerDescription.claude": "Delegate tasks to the Claude SDK running locally on your machine. The agent iterates via chat and works asynchronously to implement changes.", "github.copilot.session.providerDescription.cloud": "Delegate tasks to the GitHub Copilot coding agent. The agent iterates via chat and works asynchronously in the cloud to implement changes and pull requests as needed.", diff --git a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts index 53f42df6b694a..5789ccac0bc80 100644 --- a/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts +++ b/extensions/copilot/src/extension/prompt/node/executionSubagentToolCallingLoop.ts @@ -11,6 +11,7 @@ import { ChatLocation, ChatResponse } from '../../../platform/chat/common/common import { ISessionTranscriptService } from '../../../platform/chat/common/sessionTranscriptService'; import { ConfigKey, IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ChatEndpointFamily, IEndpointProvider } from '../../../platform/endpoint/common/endpointProvider'; +import { ProxyAgenticEndpoint } from '../../../platform/endpoint/node/proxyAgenticEndpoint'; import { IFileSystemService } from '../../../platform/filesystem/common/fileSystemService'; import { IGitService } from '../../../platform/git/common/gitService'; import { ILogService } from '../../../platform/log/common/logService'; @@ -76,25 +77,36 @@ export class ExecutionSubagentToolCallingLoop extends ToolCallingLoop {!this.props.codesearchMode && <>Think creatively and explore the workspace in order to make a complete fix.
} Don't repeat yourself after a tool call, pick up where you left off.
{!this.props.codesearchMode && tools.hasSomeEditTool && <>NEVER print out a codeblock with file changes unless the user asked for it. Use the appropriate edit tool instead.
} - {tools[ToolName.CoreRunInTerminal] && <>NEVER print out a codeblock with a terminal command to run unless the user asked for it. Use the {ToolName.ExecutionSubagent} or {ToolName.CoreRunInTerminal} tool instead.
} + {tools[ToolName.CoreRunInTerminal] && <>NEVER print out a codeblock with a terminal command to run unless the user asked for it. Use the {tools[ToolName.ExecutionSubagent] && <>{ToolName.ExecutionSubagent} or }{ToolName.CoreRunInTerminal} tool instead.
} You don't need to read a file if it's already provided in context. diff --git a/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx b/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx index efb9de2163487..d8e01250aa8a1 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/defaultAgentInstructions.tsx @@ -128,7 +128,7 @@ export class DefaultAgentPrompt extends PromptElement { {!this.props.codesearchMode && <>Think creatively and explore the workspace in order to make a complete fix.
} Don't repeat yourself after a tool call, pick up where you left off.
{!this.props.codesearchMode && tools.hasSomeEditTool && <>NEVER print out a codeblock with file changes unless the user asked for it. Use the appropriate edit tool instead.
} - {tools[ToolName.CoreRunInTerminal] && <>NEVER print out a codeblock with a terminal command to run unless the user asked for it. Use the {ToolName.ExecutionSubagent} or {ToolName.CoreRunInTerminal} tool instead.
} + {tools[ToolName.CoreRunInTerminal] && <>NEVER print out a codeblock with a terminal command to run unless the user asked for it. Use the {tools[ToolName.ExecutionSubagent] && <>{ToolName.ExecutionSubagent} or }{ToolName.CoreRunInTerminal} tool instead.
} You don't need to read a file if it's already provided in context.
diff --git a/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx b/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx index 663a2cd3fc13b..6e5244a9dc024 100644 --- a/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx +++ b/extensions/copilot/src/extension/prompts/node/agent/executionSubagentPrompt.tsx @@ -5,6 +5,7 @@ import { PromptElement, PromptSizing, SystemMessage, UserMessage } from '@vscode/prompt-tsx'; import { GenericBasePromptElementProps } from '../../../context/node/resolvers/genericPanelIntentInvocation'; +import { ToolName } from '../../../tools/common/toolNames'; import { CopilotToolMode } from '../../../tools/common/toolsRegistry'; import { SafetyRules } from '../base/safetyRules'; import { TerminalStatePromptElement } from '../base/terminalState'; @@ -36,14 +37,26 @@ export class ExecutionSubagentPrompt extends PromptElement For example, if you are asked to `make` a project but there is no Makefile, you might instead run "cmake . && make" to successfully build the code.

- Always use mode="sync" when calling run_in_terminal. Use a generous timeout (e.g. 30000ms or more) so commands have time to finish. Do NOT use mode="async" — you must wait for each command to complete before proceeding. If a sync command times out, use get_terminal_output to check its status, send_to_terminal if it needs input, or kill_terminal to stop it.
-

+ When calling {ToolName.CoreRunInTerminal}, you MUST follow these rules:
+ - Always use mode="sync".
+ - Always include "timeout" in milliseconds. Use timeout=30000 for short commands, or timeout=120000 for builds and test suites.
+ - Only call {ToolName.CoreRunInTerminal} once per turn. Do NOT call it in parallel.
+ - If a command may prompt for confirmation, use flags like --yes, -y, or pipe from `yes` to auto-confirm.
+
Once you have finished, return a message with ONLY: the <final_answer> tag to provide a compact summary of each command that was run.

Example:

+ [Call {ToolName.CoreRunInTerminal} with {'{'}"command": "make", "explanation": "Build the project", "goal": "Build the project", "mode": "sync", "timeout": 30000{'}'}]
+
+ [Result: No Makefile found.]
+
+ [Call {ToolName.CoreRunInTerminal} with {'{'}"command": "cmake . && make", "explanation": "Build with cmake", "goal": "Build the project", "mode": "sync", "timeout": 120000{'}'}]
+
+ [Result: Build unsuccessful with errors.]
+
<final_answer>
Command: make
Summary: No Makefile found.
@@ -73,4 +86,4 @@ export class ExecutionSubagentPrompt extends PromptElement ); } -} +} \ No newline at end of file diff --git a/extensions/copilot/src/platform/configuration/common/configurationService.ts b/extensions/copilot/src/platform/configuration/common/configurationService.ts index 3f105b8109018..f440a451d2854 100644 --- a/extensions/copilot/src/platform/configuration/common/configurationService.ts +++ b/extensions/copilot/src/platform/configuration/common/configurationService.ts @@ -663,7 +663,9 @@ export namespace ConfigKey { export const SearchSubagentThoroughnessEnabled = defineSetting('chat.searchSubagent.thoroughnessEnabled', ConfigType.ExperimentBased, false); export const ExecutionSubagentToolEnabled = defineSetting('chat.executionSubagent.enabled', ConfigType.ExperimentBased, false); - /** Model to use for the execution subagent */ + /** Use the agentic proxy for the execution subagent */ + export const ExecutionSubagentUseAgenticProxy = defineSetting('chat.executionSubagent.useAgenticProxy', ConfigType.ExperimentBased, false); + /** Model to use for the execution subagent. When useAgenticProxy is true, defaults to 'exec-subagent-router-a'. When false, defaults to the main agent model. */ export const ExecutionSubagentModel = defineSetting('chat.executionSubagent.model', ConfigType.ExperimentBased, ''); /** Maximum number of tool calls the execution subagent can make */ export const ExecutionSubagentToolCallLimit = defineSetting('chat.executionSubagent.toolCallLimit', ConfigType.ExperimentBased, 10); diff --git a/extensions/copilot/src/platform/endpoint/node/proxyAgenticSearchEndpoint.ts b/extensions/copilot/src/platform/endpoint/node/proxyAgenticEndpoint.ts similarity index 98% rename from extensions/copilot/src/platform/endpoint/node/proxyAgenticSearchEndpoint.ts rename to extensions/copilot/src/platform/endpoint/node/proxyAgenticEndpoint.ts index 6cc238213ebd8..cbdab7614d037 100644 --- a/extensions/copilot/src/platform/endpoint/node/proxyAgenticSearchEndpoint.ts +++ b/extensions/copilot/src/platform/endpoint/node/proxyAgenticEndpoint.ts @@ -20,7 +20,7 @@ import { IDomainService } from '../common/domainService'; import { IChatModelInformation } from '../common/endpointProvider'; import { ChatEndpoint } from './chatEndpoint'; -export class ProxyAgenticSearchEndpoint extends ChatEndpoint { +export class ProxyAgenticEndpoint extends ChatEndpoint { constructor( modelName: string, From f67b29760164e1dc9ce6a27942a3c60cec5ca460 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:17:42 -0700 Subject: [PATCH 27/35] Account policy access restrictions: gate AI features behind approved-org sign-in (#311487) * Implement account policy gate for AI features - Introduced AccountPolicyGateContribution to manage account policy state and notifications. - Added support for "Require Approved Account" policy, restricting AI features based on account approval. - Enhanced AccountPolicyService to handle gate state and reasons for unsatisfaction. - Updated configuration for chat features to include policy definitions. - Added tests to validate gate behavior under various account scenarios. * Refactor account policy gate logic to focus on approved organizations and update related descriptions * Add Account Policy Gate service and integrate with existing policy services * Add account policy gate information to PolicyDiagnosticsAction * Fix CI: layer violation, ESLint, i18n entry, policyData export - Move ChatAccountPolicyGateActiveContext to services/policies/common to avoid services-layer import from contrib (chatContextKeys re-exports). - Replace 'in' operator in test helper with explicit undefined check. - Add vs/workbench/services/policies entry to i18n.resources.json. - Append ChatDisableAIFeatures and ChatApprovedAccountOrganizations to build/lib/policies/policyData.jsonc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add account policy settings for approved organizations and AI feature control * Switch ChatApprovedAccountOrganizations to type:'array' Use the platform's array-typed policy contract instead of a custom comma-separated string format. Mirrors PolicyConfiguration's existing normalisation: PolicyValue is always string|number|boolean, so array policies arrive at the policy service as JSON-stringified arrays. - chat.contribution.ts: type:'string' -> type:'array', items:string - accountPolicyService: simpler parser (JSON.parse + Array.isArray) - tests: pass arrays via JSON.stringify in setupGate helper Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Don't restrict policies during policyNotResolved boot window When the user IS signed into an approved org but account-side policy data hasn't loaded yet (policyNotResolved), skip applying restricted values. Policies with `value` callbacks naturally return undefined when policyData is null, so no account-level overrides slip through. This eliminates: - Transient 'Unable to write chat.disableAIFeatures' error on boot - Flash of the gate notification that auto-dismisses seconds later - Brief UI hide/show cycle as ChatDisableAIFeatures toggles For stable restricted reasons (noAccount, wrongProvider, orgNotApproved) restrictions still apply immediately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Contact Administrator and Learn More links to gate notification Replace the 'Don't Show Again' button with: - 'Contact Your informational guidanceAdministrator' - 'Learn opens enterprise docs overview pageMore' Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Show approved organizations in gate notification Add approved org list to IAccountPolicyGateInfo so the notification can display which organizations the admin requires. Shown as a suffix like 'Approved organizations: github, microsoft.' when the list is concrete (not the wildcard '*'). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Move 'contact your administrator' from button to message text Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: check org membership before policyData resolution Move the org-membership check before the policyData null check in computeGateInfo. This ensures users NOT in an approved org are restricted immediately (orgNotApproved), even while policy data is loading. The policyNotResolved reason now only applies to users who ARE in an approved making it safe to skip restrictions for thatorg transient state without leaving a security gap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Directly set chatSetupHidden context key when gate is restricted entitlement pipeline to force chat.disableAIFeatures=true (which has timing issues in the multiplex policy service), directly toggle the chatSetupHidden context key from the gate contribution. This is the same key that drives sentiment.hidden across the entire chat UI. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use IChatEntitlementService.setForceHidden to hide chat when gate restricted Add setForceHidden(hidden) API to IChatEntitlementService so the gate contribution can cleanly force the hidden state without fighting with the entitlement context's own update cycle. The gate contribution calls setForceHidden(true) when restricted and setForceHidden(false) when satisfied/inactive. Inside ChatEntitlementContext, _forceHidden is checked in withConfiguration alongside the existing chat.disableAIFeatures either one forces hidden: true on the state.setting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix setForceHidden fallback when no ChatEntitlementContext In Code OSS Dev (and any build without productService.defaultChatAgent), ChatEntitlementContext is never created, so setForceHidden was a no-op. Fall back to directly setting the chatSetupHidden context key when the context is unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add trace logging to AccountPolicyGateContribution Logs state, reason, and isRestricted on every gate apply so we can diagnose why setForceHidden might not be taking effect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Gate chat view on accountPolicyGateActive context key The chat view's `when` clause had an OR with panelParticipantRegistered that bypassed the hidden state once the Copilot extension registered. Wrap the entire condition with accountPolicyGateActive.negate() so the chat view is hidden whenever the gate is restricted, regardless of extension registration state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Re-show notification on account swap, include account name and org list - Track dismissal by reason+account combo so swapping to a different account (while still blocked) triggers a fresh notification. - Show the current account name in the orgNotApproved message so the user knows which account is being evaluated. - Format approved org list as bulleted lines for readability. - Vary message text by reason (noAccount vs orgNotApproved). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Generalize sessions blocked overlay for account policy gate The sessions (Agents) app now shows a full-screen blocking overlay when the account policy gate restricts access, reusing the same pattern as the existing 'agent disabled' overlay. - SessionsPolicyBlockedOverlay now accepts ISessionsBlockedOverlayOptions with a reason enum (AgentDisabled | AccountPolicyGate) and optional account name / approved organizations - AccountPolicyGate variant shows 'Sign-In Required' title, approved org list, contact admin text, and Sign In + Open VS Code buttons - SessionsPolicyBlockedContribution listens to both ChatConfiguration and IAccountPolicyGateService, prioritizing agent-disabled over gate - Added CSS for org list and footer sections - Updated component fixture with new variants for screenshot testing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix notification formatting: use inline comma-separated org list Notifications render as plain inline text, so the bullet-point and newline formatting was collapsing into a single unreadable line. Switch to a parenthesized comma-separated list instead. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix sessions overlay: remove workbench notification, handle gate natively The workbench-layer AccountPolicyGateContribution (which shows a notification toast) was imported in sessions.common.main.ts, causing a notification to appear instead of the full-screen blocking overlay. - Remove accountPolicyGate.contribution.js import from sessions - SessionsPolicyBlockedContribution now handles context key, setForceHidden, and telemetry directly (same as the workbench contribution, but with an overlay instead of a notification) - Overlay properly recreates on account changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer notification until account service has settled On startup, computeGateInfo fires with reason=noAccount before the default account service has loaded the persisted session. This caused the notification to show 'Sign in...' even when the user was already signed in but the account just hadn't loaded yet. Fix: set context key + setForceHidden immediately (fail-closed), but defer the notification until the first onDidChangeGateInfo event, which fires after the account service has had time to resolve. A 5-second fallback timer ensures the notification still appears if the gate never transitions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix gate stuck on noAccount: re-evaluate after account init barrier DefaultAccountService.setDefaultAccountProvider sets currentDefaultAccount via provider.refresh() but does NOT fire onDidChangeDefaultAccount for the initial load. This caused computeGateInfo() to permanently stay on noAccount even though the user was signed in. Fix: await getDefaultAccount() (which waits for the init barrier) then re-evaluate the gate. This ensures the gate transitions from noAccount to the correct state once the persisted session loads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add 'Sign into an approved GitHub account' to notification messages Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Regenerate policyData.jsonc to match array type for ChatApprovedAccountOrganizations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove ChatDisableAIFeatures policy registration This policy was dead enforcement is handled by setForceHiddencode and the accountPolicyGateActive context key, not the policy pipeline. Regenerated policyData.jsonc. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address code review: fix duplicate IPC, remove unused import - Remove duplicate updatePolicyDefinitions call on managed policy service. AccountPolicyService now uses a read-only reference (managedPolicyReader) for getPolicyValue/onDidChange only. MultiplexPolicyService handles pushing definitions to all child services. (Reviews #1 & #4) - Remove unused Emitter import and void workaround in test file (Review #2) - Removed the fail-closed try/catch that was guarding the now-removed updatePolicyDefinitions call (Review # the duplicate call that could3 fail-open is gone entirely) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove JSDoc from currentDefaultAccount interface addition Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert sessions overlay will revisit approachchanges Reverts all changes to the sessions (Agents) policyBlocked overlay, CSS, fixture, and contribution. Re-adds the workbench-layer accountPolicyGate.contribution import so sessions still gets the notification + context key + telemetry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore sessions overlay with loading state for transient restrictions Bring back the generalized sessions overlay with three states: - AgentDisabled: existing 'Agents Disabled' message (unchanged) - Loading: just the logo + animated progress bar for transient states (noAccount before account loads, policyNotResolved) blocks the UI without showing an incorrect message - AccountPolicyGate: 'Sign-In Required' with sign-in button, org list, and contact admin footer for stable restrictions (orgNotApproved, wrongProvider) The loading state uses the same progress bar animation as the welcome/walkthrough overlay. This avoids the flash of 'Agents Disabled' that appeared during the fail-closed transient window when the user IS actually in an approved org. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Don't show overlay for noAccount/ let welcome screen handle sign-inwrongProvider When the user hasn't signed in yet (noAccount) or is signed into the wrong provider (wrongProvider), the sessions welcome/walkthrough screen already handles the sign-in flow. Showing our 'Agents Disabled' or loading overlay on top would block the user from signing in. Only show the overlay for: - orgNotApproved: user signed in but wrong org (stable restriction) - policyNotResolved: loading bar while waiting for policy data Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove 'Open VS Code' button from account policy gate overlay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix: don't show 'Agents Disabled' when gate is forcing restrictedValue When the account policy gate is active, it forces chat.agent.enabled to false via restrictedValue. The overlay was checking that config first and incorrectly showing 'Agents Disabled'. Now we skip the agent-disabled check when the gate is active, since the value is being artificially restricted by our own not by an admingate explicitly disabling agents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Defer all stable gate-blocked states to welcome screen When the account policy gate is unsatisfied for any user-actionable reason (noAccount, wrongProvider, orgNotApproved), don't show the policy-blocked overlay. Instead, defer to the sessions welcome/walkthrough screen so the user can sign in or switch accounts via the standard sign-in flow. The Loading overlay is still shown during the transient PolicyNotResolved state to prevent flashing the welcome screen while data is in flight. Removes the now-dead AccountPolicyGate overlay variant and its supporting code (organizations list, footer styles, fixtures). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Show AccountPolicyGate overlay for orgNotApproved only When the user is definitively signed into a non-approved org, show the custom Sign-In Required overlay with org list and switch-account button. noAccount/wrongProvider still defer to the welcome screen. PolicyNotResolved still shows the loading bar. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix boot-race test to match managed policy reader pattern The test was relying on AccountPolicyService calling updatePolicyDefinitions on the managed service, but that no longer happens (the MultiplexPolicyService handles it). Updated the test to explicitly seed the managed service and Restricted after seeding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address PR review feedback - Fix setForceHidden signature in test mocks to match interface - Include approvedOrganizations in gateInfoChanged detection - Replace raw setTimeout with disposableTimeout for proper cleanup - Fix AgentDisabled overlay: suppress only when gate forces the value, not when gate is merely active (handles Satisfied+AgentDisabled case) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Polish ChatApprovedAccountOrganizations policy description Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Trim self-explanatory comments Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/add-policy/SKILL.md | 4 + build/lib/i18n.resources.json | 4 + build/lib/policies/policyData.jsonc | 15 + src/vs/base/common/policy.ts | 12 + .../inlineCompletions/test/browser/utils.ts | 1 + .../standalone/browser/standaloneServices.ts | 1 + .../configuration/common/configurations.ts | 3 +- .../defaultAccount/common/defaultAccount.ts | 1 + src/vs/platform/policy/common/policy.ts | 17 ++ .../browser/media/sessionsPolicyBlocked.css | 52 ++++ .../browser/policyBlocked.contribution.ts | 70 ++++- .../browser/sessionsPolicyBlocked.ts | 99 +++++- .../browser/sessionsPolicyBlocked.fixture.ts | 36 ++- .../electron-browser/sessions.main.ts | 9 +- src/vs/sessions/test/web.test.ts | 2 + .../browser/actions/developerActions.ts | 26 ++ src/vs/workbench/browser/web.main.ts | 3 +- .../contrib/chat/browser/chat.contribution.ts | 20 +- .../browser/chatParticipant.contribution.ts | 17 +- .../chat/common/actions/chatContextKeys.ts | 2 + .../electron-browser/desktop.main.ts | 9 +- .../accounts/browser/defaultAccount.ts | 1 + .../chat/common/chatEntitlementService.ts | 30 +- .../browser/accountPolicyGate.contribution.ts | 9 + .../browser/accountPolicyGateContribution.ts | 210 +++++++++++++ .../policies/common/accountPolicyService.ts | 173 ++++++++++- .../test/browser/accountPolicyService.test.ts | 284 +++++++++++++++++- .../browser/componentFixtures/fixtureUtils.ts | 1 + .../test/common/workbenchTestServices.ts | 1 + src/vs/workbench/workbench.common.main.ts | 3 + 30 files changed, 1058 insertions(+), 57 deletions(-) create mode 100644 src/vs/workbench/services/policies/browser/accountPolicyGate.contribution.ts create mode 100644 src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts diff --git a/.github/skills/add-policy/SKILL.md b/.github/skills/add-policy/SKILL.md index 3c8b4c947c48a..28d67bc221239 100644 --- a/.github/skills/add-policy/SKILL.md +++ b/.github/skills/add-policy/SKILL.md @@ -230,3 +230,7 @@ See `chat.tools.global.autoApprove` and `chat.useHooks` in `src/vs/workbench/con ## Examples Search the codebase for `policy:` to find all the examples of different policy configurations. + +## Learnings + +* Never hand-edit `build/lib/policies/policyData.jsonc` (its header explicitly forbids it). If `npm run export-policy-data` is failing, fix the script — don't patch the JSON. Common cause: running it in the wrong working directory (e.g. main repo instead of a worktree), which silently exports the wrong source tree. diff --git a/build/lib/i18n.resources.json b/build/lib/i18n.resources.json index d8d040444942d..fa167fffcc93d 100644 --- a/build/lib/i18n.resources.json +++ b/build/lib/i18n.resources.json @@ -434,6 +434,10 @@ "name": "vs/workbench/services/language", "project": "vscode-workbench" }, + { + "name": "vs/workbench/services/policies", + "project": "vscode-workbench" + }, { "name": "vs/workbench/services/progress", "project": "vscode-workbench" diff --git a/build/lib/policies/policyData.jsonc b/build/lib/policies/policyData.jsonc index 8c2f5658ea121..4c5d01a88a864 100644 --- a/build/lib/policies/policyData.jsonc +++ b/build/lib/policies/policyData.jsonc @@ -68,6 +68,21 @@ "default": "", "included": false }, + { + "key": "chat.approvedAccountOrganizations", + "name": "ChatApprovedAccountOrganizations", + "category": "InteractiveSession", + "minimumVersion": "1.118", + "localization": { + "description": { + "key": "chat.approvedAccountOrganizations.policy.description", + "value": "Setting this policy to a non-empty list activates the Approved Account gate: all AI features are disabled until the user signs into a GitHub account whose organizations intersect this list AND the account-side policy data has resolved. Comparison is case-insensitive. Use '*' as a wildcard to accept any signed-in GitHub or GHE account (use this for GHE deployments where the organization list is not surfaced)." + } + }, + "type": "array", + "default": [], + "included": false + }, { "key": "extensions.allowed", "name": "AllowedExtensions", diff --git a/src/vs/base/common/policy.ts b/src/vs/base/common/policy.ts index c27030fe03ac4..b1e697d5eb0a6 100644 --- a/src/vs/base/common/policy.ts +++ b/src/vs/base/common/policy.ts @@ -97,4 +97,16 @@ export interface IPolicy { * If `undefined`, the feature's setting is not locked and can be overridden by other means. */ readonly value?: (policyData: IPolicyData) => string | number | boolean | undefined; + + /** + * The most-restrictive value that should be applied when the user is subject to the + * "Require Approved Account" gate but the gate is not yet satisfied (i.e. no approved + * GitHub account is signed in or the account-side policy data has not yet resolved). + * + * If omitted, the gate falls back to a type-driven safe default + * (`false` for boolean, `0` for number, `''` for string). + * + * Only consulted while the gate is active and unsatisfied; ignored otherwise. + */ + readonly restrictedValue?: string | number | boolean; } diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index 3f5c6ba28ceff..f23f8a3f467a2 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -274,6 +274,7 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( onDidChangeDefaultAccount: Event.None, onDidChangePolicyData: Event.None, policyData: null, + currentDefaultAccount: null, copilotTokenInfo: null, onDidChangeCopilotTokenInfo: Event.None, getDefaultAccount: async () => null, diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 9821502e4fe23..d17970052632c 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -1125,6 +1125,7 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { readonly onDidChangeDefaultAccount: Event = Event.None; readonly onDidChangePolicyData: Event = Event.None; readonly policyData: IPolicyData | null = null; + readonly currentDefaultAccount: IDefaultAccount | null = null; readonly copilotTokenInfo = null; readonly onDidChangeCopilotTokenInfo: Event = Event.None; diff --git a/src/vs/platform/configuration/common/configurations.ts b/src/vs/platform/configuration/common/configurations.ts index e769804c41dff..887aadae5ce52 100644 --- a/src/vs/platform/configuration/common/configurations.ts +++ b/src/vs/platform/configuration/common/configurations.ts @@ -139,11 +139,12 @@ export class PolicyConfiguration extends Disposable implements IPolicyConfigurat this.logService.warn(`Policy ${config.policy.name} has unsupported type ${config.type}`); continue; } - const { value } = config.policy; + const { value, restrictedValue } = config.policy; keys.push(key); policyDefinitions[config.policy.name] = { type: config.type === 'number' ? 'number' : config.type === 'boolean' ? 'boolean' : 'string', value, + restrictedValue, }; } } diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index ff1f863bbb3e7..f2d19fa45db35 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -27,6 +27,7 @@ export interface IDefaultAccountService { readonly onDidChangeDefaultAccount: Event; readonly onDidChangePolicyData: Event; readonly policyData: IPolicyData | null; + readonly currentDefaultAccount: IDefaultAccount | null; readonly copilotTokenInfo: ICopilotTokenInfo | null; readonly onDidChangeCopilotTokenInfo: Event; getDefaultAccount(): Promise; diff --git a/src/vs/platform/policy/common/policy.ts b/src/vs/platform/policy/common/policy.ts index f8b86c8070457..b347d81213cc2 100644 --- a/src/vs/platform/policy/common/policy.ts +++ b/src/vs/platform/policy/common/policy.ts @@ -15,8 +15,25 @@ export type PolicyValue = string | number | boolean; export type PolicyDefinition = { type: 'string' | 'number' | 'boolean'; value?: (policyData: IPolicyData) => string | number | boolean | undefined; + restrictedValue?: PolicyValue; }; +/** + * Returns the value to apply for `definition` when the account-policy gate is active + * but not satisfied. Uses `definition.restrictedValue` when specified, otherwise falls + * back to a type-driven safe default. + */ +export function getRestrictedPolicyValue(definition: PolicyDefinition): PolicyValue { + if (definition.restrictedValue !== undefined) { + return definition.restrictedValue; + } + switch (definition.type) { + case 'boolean': return false; + case 'number': return 0; + case 'string': return ''; + } +} + export const IPolicyService = createDecorator('policy'); export interface IPolicyService { diff --git a/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css b/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css index f4d9378f2357e..9c70d12112ac2 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css +++ b/src/vs/sessions/contrib/policyBlocked/browser/media/sessionsPolicyBlocked.css @@ -67,6 +67,58 @@ text-decoration: underline; } +/* Progress bar for transient loading state */ +.sessions-policy-blocked-card .sessions-policy-blocked-progress-bar { + width: 100%; + height: 3px; + background: color-mix(in srgb, var(--vscode-foreground) 10%, transparent); + border-radius: 2px; + overflow: hidden; + margin-top: 16px; +} + +.sessions-policy-blocked-card .sessions-policy-blocked-progress-bar-fill { + width: 30%; + height: 100%; + background: var(--vscode-progressBar-background, #0078d4); + border-radius: 2px; + animation: sessions-policy-blocked-progress 2s ease-in-out infinite; +} + +@keyframes sessions-policy-blocked-progress { + 0% { transform: translateX(0%); } + 50% { transform: translateX(233%); } + 100% { transform: translateX(0%); } +} + +/* Approved organizations list */ +.sessions-policy-blocked-card .sessions-policy-blocked-orgs { + text-align: center; +} + +.sessions-policy-blocked-card .sessions-policy-blocked-orgs-label { + margin: 0 0 4px 0; + font-size: 12px; + font-weight: 600; + color: var(--vscode-foreground); +} + +.sessions-policy-blocked-card .sessions-policy-blocked-orgs ul { + margin: 0; + padding: 0; + list-style: none; +} + +.sessions-policy-blocked-card .sessions-policy-blocked-orgs li { + font-size: 12px; + color: var(--vscode-descriptionForeground); + line-height: 1.6; +} + +.sessions-policy-blocked-card .sessions-policy-blocked-footer { + font-size: 12px; +} + .sessions-policy-blocked-card .monaco-button { margin-top: 4px; width: auto; diff --git a/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts b/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts index e006d604bf560..b08c349fb411b 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts +++ b/src/vs/sessions/contrib/policyBlocked/browser/policyBlocked.contribution.ts @@ -8,19 +8,24 @@ import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase import { IWorkbenchLayoutService } from '../../../../workbench/services/layout/browser/layoutService.js'; import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { ChatConfiguration } from '../../../../workbench/contrib/chat/common/constants.js'; -import { SessionsPolicyBlockedOverlay } from './sessionsPolicyBlocked.js'; +import { ISessionsBlockedOverlayOptions, SessionsBlockedReason, SessionsPolicyBlockedOverlay } from './sessionsPolicyBlocked.js'; +import { AccountPolicyGateState, AccountPolicyGateUnsatisfiedReason, IAccountPolicyGateService } from '../../../../workbench/services/policies/common/accountPolicyService.js'; export class SessionsPolicyBlockedContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.sessionsPolicyBlocked'; private readonly overlayRef = this._register(new MutableDisposable()); + private currentReason: SessionsBlockedReason | undefined; constructor( @IConfigurationService private readonly configurationService: IConfigurationService, @IWorkbenchLayoutService private readonly layoutService: IWorkbenchLayoutService, @IInstantiationService private readonly instantiationService: IInstantiationService, + @IAccountPolicyGateService private readonly gateService: IAccountPolicyGateService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, ) { super(); @@ -31,21 +36,64 @@ export class SessionsPolicyBlockedContribution extends Disposable implements IWo this.update(); } })); + + this._register(this.gateService.onDidChangeGateInfo(() => this.update())); } private update(): void { - const enabled = this.configurationService.getValue(ChatConfiguration.AgentEnabled); - - if (enabled === false) { - if (!this.overlayRef.value) { - this.overlayRef.value = this.instantiationService.createInstance( - SessionsPolicyBlockedOverlay, - this.layoutService.mainContainer, - ); + const gateInfo = this.gateService.gateInfo; + + // The gate forces chat.agent.enabled = false via restrictedValue when stably + // Restricted. Suppress AgentDisabled in that case so users see the gate-specific + // overlay (or the welcome screen for noAccount/wrongProvider) instead. + const gateForcesAgentDisabled = gateInfo.state === AccountPolicyGateState.Restricted + && gateInfo.reason !== AccountPolicyGateUnsatisfiedReason.PolicyNotResolved; + + const agentEnabled = this.configurationService.getValue(ChatConfiguration.AgentEnabled); + if (agentEnabled === false && !gateForcesAgentDisabled) { + this.showOverlay({ reason: SessionsBlockedReason.AgentDisabled }); + return; + } + + if (gateInfo.state === AccountPolicyGateState.Restricted) { + // Defer to the sessions welcome/walkthrough so the user signs in via the standard flow. + if (gateInfo.reason === AccountPolicyGateUnsatisfiedReason.NoAccount + || gateInfo.reason === AccountPolicyGateUnsatisfiedReason.WrongProvider) { + this.overlayRef.clear(); + this.currentReason = undefined; + return; + } + + if (gateInfo.reason === AccountPolicyGateUnsatisfiedReason.PolicyNotResolved) { + this.showOverlay({ reason: SessionsBlockedReason.Loading }); + } else { + const accountName = this.defaultAccountService.currentDefaultAccount?.accountName; + this.showOverlay({ + reason: SessionsBlockedReason.AccountPolicyGate, + approvedOrganizations: gateInfo.approvedOrganizations, + accountName, + }); } - } else { - this.overlayRef.clear(); + return; + } + + this.overlayRef.clear(); + this.currentReason = undefined; + } + + private showOverlay(options: ISessionsBlockedOverlayOptions): void { + // AccountPolicyGate may need re-render when the account name changes. + if (this.currentReason === options.reason && options.reason !== SessionsBlockedReason.AccountPolicyGate) { + return; } + this.overlayRef.clear(); + this.currentReason = options.reason; + + this.overlayRef.value = this.instantiationService.createInstance( + SessionsPolicyBlockedOverlay, + this.layoutService.mainContainer, + options, + ); } } diff --git a/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts b/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts index 58b6e4d0e3ecd..813e097a5c986 100644 --- a/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts +++ b/src/vs/sessions/contrib/policyBlocked/browser/sessionsPolicyBlocked.ts @@ -12,10 +12,24 @@ import { defaultButtonStyles } from '../../../../platform/theme/browser/defaultS import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IProductService } from '../../../../platform/product/common/productService.js'; import { URI } from '../../../../base/common/uri.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; + +export const enum SessionsBlockedReason { + AgentDisabled = 'agentDisabled', + /** Transient loading state — blocks UI but shows only a progress bar. */ + Loading = 'loading', + /** Signed in but not in an approved org — must switch accounts. */ + AccountPolicyGate = 'accountPolicyGate', +} + +export interface ISessionsBlockedOverlayOptions { + readonly reason: SessionsBlockedReason; + readonly approvedOrganizations?: readonly string[]; + readonly accountName?: string; +} /** - * Full-window impassable overlay shown when the Agents app has been - * disabled via group policy. Blocks all user interaction. + * Full-window impassable overlay shown when the Agents app is blocked. */ export class SessionsPolicyBlockedOverlay extends Disposable { @@ -23,6 +37,8 @@ export class SessionsPolicyBlockedOverlay extends Disposable { constructor( container: HTMLElement, + options: ISessionsBlockedOverlayOptions, + @ICommandService private readonly commandService: ICommandService, @IOpenerService private readonly openerService: IOpenerService, @IProductService private readonly productService: IProductService, ) { @@ -31,15 +47,12 @@ export class SessionsPolicyBlockedOverlay extends Disposable { this.overlay = append(container, $('.sessions-policy-blocked-overlay')); this.overlay.setAttribute('role', 'dialog'); this.overlay.setAttribute('aria-modal', 'true'); - this.overlay.setAttribute('aria-label', localize('policyBlocked.aria', "Agents disabled by organization policy")); this.overlay.tabIndex = -1; this.overlay.focus(); this._register(toDisposable(() => this.overlay.remove())); const card = append(this.overlay, $('.sessions-policy-blocked-card')); - // Block keyboard shortcuts while the overlay is present, but allow - // interaction with focusable elements inside the card. this._register(addDisposableListener(getWindow(this.overlay), EventType.KEY_DOWN, (e: KeyboardEvent) => { if (card.contains(e.target as Node)) { return; @@ -48,8 +61,6 @@ export class SessionsPolicyBlockedOverlay extends Disposable { e.stopPropagation(); }, true)); - // Block mouse interaction on the overlay background, but allow - // clicks through to card children (e.g. the "Open VS Code" button). this._register(addDisposableGenericMouseDownListener(this.overlay, e => { if (e.target === this.overlay) { e.preventDefault(); @@ -57,13 +68,26 @@ export class SessionsPolicyBlockedOverlay extends Disposable { } })); - // Sessions logo append(card, $('div.sessions-policy-blocked-logo')); - // Title + switch (options.reason) { + case SessionsBlockedReason.AgentDisabled: + this._renderAgentDisabled(card); + break; + case SessionsBlockedReason.Loading: + this._renderLoading(card); + break; + case SessionsBlockedReason.AccountPolicyGate: + this._renderAccountPolicyGate(card, options); + break; + } + } + + private _renderAgentDisabled(card: HTMLElement): void { + this.overlay.setAttribute('aria-label', localize('policyBlocked.aria', "Agents disabled by organization policy")); + append(card, $('h2', undefined, localize('policyBlocked.title', "Agents Disabled"))); - // Description const description = append(card, $('p')); append(description, document.createTextNode(localize('policyBlocked.description', "Your organization has disabled Agents via policy."))); append(description, document.createTextNode(' ')); @@ -75,12 +99,65 @@ export class SessionsPolicyBlockedOverlay extends Disposable { this.openerService.open(URI.parse('https://aka.ms/VSCode/Agents/docs')); })); - // Open VS Code button const button = this._register(new Button(card, { ...defaultButtonStyles, secondary: true })); button.label = localize('policyBlocked.openVSCode', "Open VS Code"); this._register(button.onDidClick(() => this._openVSCode())); } + private _renderLoading(card: HTMLElement): void { + this.overlay.setAttribute('aria-label', localize('loading.aria', "Loading")); + append(card, $('div.sessions-policy-blocked-progress-bar', undefined, + $('div.sessions-policy-blocked-progress-bar-fill') + )); + } + + private _renderAccountPolicyGate(card: HTMLElement, options: ISessionsBlockedOverlayOptions): void { + this.overlay.setAttribute('aria-label', localize('accountGate.aria', "Sign-in required by organization policy")); + + append(card, $('h2', undefined, localize('accountGate.title', "Sign-In Required"))); + + const description = append(card, $('p')); + if (options.accountName) { + append(description, document.createTextNode( + localize('accountGate.descriptionWithAccount', "The account \"{0}\" is not a member of an approved organization. Sign into an approved GitHub account to use Agents.", options.accountName) + )); + } else { + append(description, document.createTextNode( + localize('accountGate.descriptionNoAccount', "Sign in with a GitHub account from an approved organization to use Agents.") + )); + } + + const approvedOrgs = options.approvedOrganizations ?? []; + const hasConcreteOrgs = approvedOrgs.length > 0 && !approvedOrgs.includes('*'); + if (hasConcreteOrgs) { + const orgSection = append(card, $('div.sessions-policy-blocked-orgs')); + append(orgSection, $('p.sessions-policy-blocked-orgs-label', undefined, + localize('accountGate.approvedOrgs', "Approved organizations:") + )); + const orgList = append(orgSection, $('ul')); + for (const org of approvedOrgs) { + append(orgList, $('li', undefined, org)); + } + } + + const footer = append(card, $('p.sessions-policy-blocked-footer')); + append(footer, document.createTextNode(localize('accountGate.contactAdmin', "Contact your administrator for more information."))); + append(footer, document.createTextNode(' ')); + const learnMore = append(footer, $('a.sessions-policy-blocked-link')) as HTMLAnchorElement; + learnMore.textContent = localize('accountGate.learnMore', "Learn more"); + learnMore.href = 'https://code.visualstudio.com/docs/enterprise/overview'; + this._register(addDisposableListener(learnMore, EventType.CLICK, (e) => { + e.preventDefault(); + this.openerService.open(URI.parse('https://code.visualstudio.com/docs/enterprise/overview')); + })); + + const signInButton = this._register(new Button(card, { ...defaultButtonStyles })); + signInButton.label = localize('accountGate.signIn', "Sign In"); + this._register(signInButton.onDidClick(() => { + this.commandService.executeCommand('workbench.action.agenticSignIn'); + })); + } + private _openVSCode(): void { const scheme = this.productService.parentPolicyConfig?.urlProtocol ?? this.productService.urlProtocol; this.openerService.open(URI.from({ scheme, query: 'windowId=_blank' }), { openExternal: true }); diff --git a/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts b/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts index d1234fe43e1f4..62e01ff2c5e82 100644 --- a/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts +++ b/src/vs/sessions/contrib/policyBlocked/test/browser/sessionsPolicyBlocked.fixture.ts @@ -6,15 +6,15 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { IProductService } from '../../../../../platform/product/common/productService.js'; import { ComponentFixtureContext, createEditorServices, defineComponentFixture, defineThemedFixtureGroup } from '../../../../../workbench/test/browser/componentFixtures/fixtureUtils.js'; -import { SessionsPolicyBlockedOverlay } from '../../browser/sessionsPolicyBlocked.js'; +import { ISessionsBlockedOverlayOptions, SessionsBlockedReason, SessionsPolicyBlockedOverlay } from '../../browser/sessionsPolicyBlocked.js'; -function renderPolicyBlocked({ container, disposableStore, theme }: ComponentFixtureContext): void { - container.style.width = '600px'; - container.style.height = '400px'; - container.style.position = 'relative'; +function createOverlay(ctx: ComponentFixtureContext, options: ISessionsBlockedOverlayOptions): void { + ctx.container.style.width = '600px'; + ctx.container.style.height = '400px'; + ctx.container.style.position = 'relative'; - const instantiationService = createEditorServices(disposableStore, { - colorTheme: theme, + const instantiationService = createEditorServices(ctx.disposableStore, { + colorTheme: ctx.theme, additionalServices: (reg) => { reg.defineInstance(IProductService, new class extends mock() { override readonly quality = 'insider'; @@ -23,12 +23,30 @@ function renderPolicyBlocked({ container, disposableStore, theme }: ComponentFix }, }); - disposableStore.add(instantiationService.createInstance(SessionsPolicyBlockedOverlay, container)); + ctx.disposableStore.add(instantiationService.createInstance(SessionsPolicyBlockedOverlay, ctx.container, options)); } export default defineThemedFixtureGroup({ path: 'sessions/' }, { PolicyBlocked: defineComponentFixture({ labels: { kind: 'screenshot' }, - render: renderPolicyBlocked, + render: (ctx) => createOverlay(ctx, { reason: SessionsBlockedReason.AgentDisabled }), + }), + Loading: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => createOverlay(ctx, { reason: SessionsBlockedReason.Loading }), + }), + AccountPolicyGate: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => createOverlay(ctx, { + reason: SessionsBlockedReason.AccountPolicyGate, + accountName: 'octocat', + approvedOrganizations: ['github', 'microsoft'], + }), + }), + AccountPolicyGateNoAccount: defineComponentFixture({ + labels: { kind: 'screenshot' }, + render: (ctx) => createOverlay(ctx, { + reason: SessionsBlockedReason.AccountPolicyGate, + }), }), }); diff --git a/src/vs/sessions/electron-browser/sessions.main.ts b/src/vs/sessions/electron-browser/sessions.main.ts index 4cb60de615791..0ce5c5086ddfd 100644 --- a/src/vs/sessions/electron-browser/sessions.main.ts +++ b/src/vs/sessions/electron-browser/sessions.main.ts @@ -60,7 +60,7 @@ import { applyZoom } from '../../platform/window/electron-browser/window.js'; import { mainWindow } from '../../base/browser/window.js'; import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; import { DefaultAccountService } from '../../workbench/services/accounts/browser/defaultAccount.js'; -import { AccountPolicyService } from '../../workbench/services/policies/common/accountPolicyService.js'; +import { AccountPolicyService, IAccountPolicyGateService } from '../../workbench/services/policies/common/accountPolicyService.js'; import { MultiplexPolicyService } from '../../workbench/services/policies/common/multiplexPolicyService.js'; import { Workbench as AgenticWorkbench } from '../browser/workbench.js'; import { NativeMenubarControl } from '../../workbench/electron-browser/parts/titlebar/menubarControl.js'; @@ -216,14 +216,15 @@ export class SessionsMain extends Disposable { // Policies let policyService: IPolicyService; - const accountPolicy = new AccountPolicyService(logService, defaultAccountService); - if (this.configuration.policiesData) { - const policyChannel = new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')); + const policyChannel = this.configuration.policiesData ? new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')) : undefined; + const accountPolicy = new AccountPolicyService(logService, defaultAccountService, policyChannel); + if (policyChannel) { policyService = new MultiplexPolicyService([policyChannel, accountPolicy], logService); } else { policyService = accountPolicy; } serviceCollection.set(IPolicyService, policyService); + serviceCollection.set(IAccountPolicyGateService, accountPolicy); // Shared Process const sharedProcessService = new SharedProcessService(this.configuration.windowId, logService); diff --git a/src/vs/sessions/test/web.test.ts b/src/vs/sessions/test/web.test.ts index 7bfd9191b5866..c2b0c49d7a536 100644 --- a/src/vs/sessions/test/web.test.ts +++ b/src/vs/sessions/test/web.test.ts @@ -108,6 +108,7 @@ class MockChatEntitlementService implements IChatEntitlementService { readonly anonymousObs: IObservable = observableValue('anonymous', false); markAnonymousRateLimited(): void { } + setForceHidden(_hidden: boolean): void { } async update(_token: CancellationToken): Promise { } } @@ -122,6 +123,7 @@ class MockDefaultAccountService implements IDefaultAccountService { readonly onDidChangeDefaultAccount = Event.None; readonly onDidChangePolicyData = Event.None; readonly policyData: IPolicyData | null = null; + readonly currentDefaultAccount: IDefaultAccount | null = MOCK_ACCOUNT; readonly copilotTokenInfo: ICopilotTokenInfo | null = null; readonly onDidChangeCopilotTokenInfo = Event.None; diff --git a/src/vs/workbench/browser/actions/developerActions.ts b/src/vs/workbench/browser/actions/developerActions.ts index ae37d27ad8b6f..c0e1300be4a66 100644 --- a/src/vs/workbench/browser/actions/developerActions.ts +++ b/src/vs/workbench/browser/actions/developerActions.ts @@ -46,6 +46,7 @@ import { IDefaultAccountService } from '../../../platform/defaultAccount/common/ import { IAuthenticationService } from '../../services/authentication/common/authentication.js'; import { IAuthenticationAccessService } from '../../services/authentication/browser/authenticationAccessService.js'; import { IPolicyService } from '../../../platform/policy/common/policy.js'; +import { APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, IAccountPolicyGateService } from '../../services/policies/common/accountPolicyService.js'; class InspectContextKeysAction extends Action2 { @@ -683,6 +684,7 @@ class PolicyDiagnosticsAction extends Action2 { const authenticationService = accessor.get(IAuthenticationService); const authenticationAccessService = accessor.get(IAuthenticationAccessService); const policyService = accessor.get(IPolicyService); + const accountPolicyGateService = accessor.get(IAccountPolicyGateService); const configurationRegistry = Registry.as(ConfigurationExtensions.Configuration); @@ -754,6 +756,30 @@ class PolicyDiagnosticsAction extends Action2 { content += `*Error retrieving account information: ${error}*\n\n`; } + // Account Policy Gate (forces AI features off until an admin-approved + // GitHub account is signed in AND its account-side policy data has resolved). + content += '## Account Policy Gate\n\n'; + try { + const gateInfo = accountPolicyGateService.gateInfo; + const approvedOrgsRaw = policyService.getPolicyValue(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME); + content += '| Property | Value |\n'; + content += '|----------|-------|\n'; + content += `| State | \`${gateInfo.state}\` |\n`; + content += `| Reason | ${gateInfo.reason ? `\`${gateInfo.reason}\`` : '*n/a*'} |\n`; + content += `| ${APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME} | ${approvedOrgsRaw !== undefined ? `\`${String(approvedOrgsRaw)}\`` : '*not set*'} |\n`; + content += '\n'; + content += '**Legend**\n\n'; + content += '- `inactive`: gate disabled (no approved orgs configured) — policies behave as account data dictates.\n'; + content += '- `satisfied`: gate active and approved — account policy values flow normally.\n'; + content += '- `restricted`: gate active and not satisfied — opted-in policies forced to their restricted value.\n'; + content += ' - `noAccount`: no default account signed in.\n'; + content += ' - `wrongProvider`: signed in with a non-GitHub provider.\n'; + content += ' - `orgNotApproved`: signed in but account is not a member of any approved organization.\n'; + content += ' - `policyNotResolved`: signed in to an approved org but account-side policy data has not yet been fetched.\n\n'; + } catch (error) { + content += `*Error retrieving account policy gate info: ${error}*\n\n`; + } + content += '## Policy-Controlled Settings\n\n'; const policyConfigurations = configurationRegistry.getPolicyConfigurations(); diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index a7ba65b88629b..cff8e2c38be4c 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -98,7 +98,7 @@ import { mainWindow } from '../../base/browser/window.js'; import { INotificationService, Severity } from '../../platform/notification/common/notification.js'; import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; import { DefaultAccountService } from '../services/accounts/browser/defaultAccount.js'; -import { AccountPolicyService } from '../services/policies/common/accountPolicyService.js'; +import { AccountPolicyService, IAccountPolicyGateService } from '../services/policies/common/accountPolicyService.js'; export interface IBrowserMainWorkbench { startup(): IInstantiationService; @@ -366,6 +366,7 @@ export class BrowserMain extends Disposable { // Policies const policyService = new AccountPolicyService(logService, defaultAccountService); serviceCollection.set(IPolicyService, policyService); + serviceCollection.set(IAccountPolicyGateService, policyService); // Long running services (workspace, config, storage) const [configurationService, storageService] = await Promise.all([ diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 898b5cf48989d..af8c8ce3c7c56 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -1462,7 +1462,25 @@ configurationRegistry.registerConfiguration({ type: 'boolean', description: nls.localize('chat.disableAIFeatures', "Disable and hide built-in AI features provided by GitHub Copilot, including chat and inline suggestions."), default: false, - scope: ConfigurationScope.WINDOW + scope: ConfigurationScope.WINDOW, + }, + 'chat.approvedAccountOrganizations': { + type: 'array', + items: { type: 'string' }, + description: nls.localize('chat.approvedAccountOrganizations', "List of GitHub organization logins whose members are permitted to use AI features. When set to a non-empty list, AI features are disabled until the user signs into a GitHub account that belongs to one of the specified organizations and account-level policy data has been resolved. Set to '*' to allow any authenticated GitHub or GitHub Enterprise account."), + default: [], + included: false, + policy: { + name: 'ChatApprovedAccountOrganizations', + category: PolicyCategory.InteractiveSession, + minimumVersion: '1.118', + localization: { + description: { + key: 'chat.approvedAccountOrganizations.policy.description', + value: nls.localize('chat.approvedAccountOrganizations.policy.description', "Setting this policy to a non-empty list activates the Approved Account gate: all AI features are disabled until the user signs into a GitHub account whose organizations intersect this list AND the account-side policy data has resolved. Comparison is case-insensitive. Use '*' as a wildcard to accept any signed-in GitHub or GHE account (use this for GHE deployments where the organization list is not surfaced).") + } + } + } }, 'chat.allowAnonymousAccess': { // TODO@bpasero remove me eventually type: 'boolean', diff --git a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts index 8ac8f531427f3..8fb472b3a1b53 100644 --- a/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatParticipant.contribution.ts @@ -68,13 +68,16 @@ const chatViewDescriptor: IViewDescriptor = { order: 1 }, ctorDescriptor: new SyncDescriptor(ChatViewPane), - when: ContextKeyExpr.or( - ContextKeyExpr.and( - ChatContextKeys.Setup.hidden.negate(), - ChatContextKeys.Setup.disabledInWorkspace.negate(), - ), - ChatContextKeys.panelParticipantRegistered, - ChatContextKeys.extensionInvalid + when: ContextKeyExpr.and( + ChatContextKeys.accountPolicyGateActive.negate(), + ContextKeyExpr.or( + ContextKeyExpr.and( + ChatContextKeys.Setup.hidden.negate(), + ChatContextKeys.Setup.disabledInWorkspace.negate(), + ), + ChatContextKeys.panelParticipantRegistered, + ChatContextKeys.extensionInvalid + ) ) }; Registry.as(ViewExtensions.ViewsRegistry).registerViews([chatViewDescriptor], chatViewContainer); diff --git a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts index 5bd2ffa3d1ef9..2f520ecd64f8c 100644 --- a/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts +++ b/src/vs/workbench/contrib/chat/common/actions/chatContextKeys.ts @@ -9,6 +9,7 @@ import { IsWebContext } from '../../../../../platform/contextkey/common/contextk import { RemoteNameContext } from '../../../../common/contextkeys.js'; import { ViewContainerLocation } from '../../../../common/views.js'; import { ChatEntitlementContextKeys } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatAccountPolicyGateActiveContext } from '../../../../services/policies/common/accountPolicyService.js'; import { ChatAgentLocation, ChatModeKind, ChatPermissionLevel } from '../constants.js'; export namespace ChatContextKeys { @@ -55,6 +56,7 @@ export namespace ChatContextKeys { export const supported = ContextKeyExpr.or(IsWebContext.negate(), RemoteNameContext.notEqualsTo(''), ContextKeyExpr.has('config.chat.experimental.serverlessWebEnabled')); export const enabled = new RawContextKey('chatIsEnabled', false, { type: 'boolean', description: localize('chatIsEnabled', "True when chat is enabled because a default chat participant is activated with an implementation.") }); + export const accountPolicyGateActive = ChatAccountPolicyGateActiveContext; /** * True when the chat widget is locked to the coding agent session. diff --git a/src/vs/workbench/electron-browser/desktop.main.ts b/src/vs/workbench/electron-browser/desktop.main.ts index 30c2d80d574b1..3b83b56e41370 100644 --- a/src/vs/workbench/electron-browser/desktop.main.ts +++ b/src/vs/workbench/electron-browser/desktop.main.ts @@ -63,7 +63,7 @@ import { applyZoom } from '../../platform/window/electron-browser/window.js'; import { mainWindow } from '../../base/browser/window.js'; import { IDefaultAccountService } from '../../platform/defaultAccount/common/defaultAccount.js'; import { DefaultAccountService } from '../services/accounts/browser/defaultAccount.js'; -import { AccountPolicyService } from '../services/policies/common/accountPolicyService.js'; +import { AccountPolicyService, IAccountPolicyGateService } from '../services/policies/common/accountPolicyService.js'; import { MultiplexPolicyService } from '../services/policies/common/multiplexPolicyService.js'; export class DesktopMain extends Disposable { @@ -215,14 +215,15 @@ export class DesktopMain extends Disposable { // Policies let policyService: IPolicyService; - const accountPolicy = new AccountPolicyService(logService, defaultAccountService); - if (this.configuration.policiesData) { - const policyChannel = new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')); + const policyChannel = this.configuration.policiesData ? new PolicyChannelClient(this.configuration.policiesData, mainProcessService.getChannel('policy')) : undefined; + const accountPolicy = new AccountPolicyService(logService, defaultAccountService, policyChannel); + if (policyChannel) { policyService = new MultiplexPolicyService([policyChannel, accountPolicy], logService); } else { policyService = accountPolicy; } serviceCollection.set(IPolicyService, policyService); + serviceCollection.set(IAccountPolicyGateService, accountPolicy); // Shared Process const sharedProcessService = new SharedProcessService(this.configuration.windowId, logService); diff --git a/src/vs/workbench/services/accounts/browser/defaultAccount.ts b/src/vs/workbench/services/accounts/browser/defaultAccount.ts index a7a981020976d..f817a62e9a215 100644 --- a/src/vs/workbench/services/accounts/browser/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/browser/defaultAccount.ts @@ -114,6 +114,7 @@ export class DefaultAccountService extends Disposable implements IDefaultAccount declare _serviceBrand: undefined; private defaultAccount: IDefaultAccount | null = null; + get currentDefaultAccount(): IDefaultAccount | null { return this.defaultAccount; } get policyData(): IPolicyData | null { return this.defaultAccountProvider?.policyData ?? null; } get copilotTokenInfo(): ICopilotTokenInfo | null { return this.defaultAccountProvider?.copilotTokenInfo ?? null; } diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index f3fa03bbf6200..aeb47c86f7106 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -186,6 +186,13 @@ export interface IChatEntitlementService { markAnonymousRateLimited(): void; + /** + * Force the hidden state on or off, overriding the normal entitlement logic. + * Used by the account policy gate to hide all AI features when the gate is + * active and unsatisfied. + */ + setForceHidden(hidden: boolean): void; + update(token: CancellationToken): Promise; } @@ -555,6 +562,16 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme this._onDidChangeQuotaExceeded.fire(); } + setForceHidden(hidden: boolean): void { + if (this.context) { + this.context.value.setForceHidden(hidden); + } else { + // No ChatEntitlementContext (e.g. no defaultChatAgent in product.json). + // Set the context key directly as a fallback. + ChatEntitlementContextKeys.Setup.hidden.bindTo(this.contextKeyService).set(hidden); + } + } + async update(token: CancellationToken): Promise { await this.requests?.value.forceResolveEntitlement(token); } @@ -1131,17 +1148,26 @@ export class ChatEntitlementContext extends Disposable { })); } + private _forceHidden = false; + private withConfiguration(state: IChatEntitlementContextState): IChatEntitlementContextState { - if (this.configurationService.getValue(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY) === true) { + if (this._forceHidden || this.configurationService.getValue(ChatEntitlementContext.CHAT_DISABLED_CONFIGURATION_KEY) === true) { return { ...state, - hidden: true // Setting always wins: if AI is disabled, set `hidden: true` + hidden: true }; } return state; } + setForceHidden(hidden: boolean): void { + if (this._forceHidden !== hidden) { + this._forceHidden = hidden; + this.updateContext(); + } + } + update(context: { installed: boolean; disabled: boolean; untrusted: boolean; disabledInWorkspace: boolean }): Promise; update(context: { completed: true }): Promise; update(context: { hidden: false }): Promise; // legacy UI state from before we had a setting to hide, keep around to still support users who used this diff --git a/src/vs/workbench/services/policies/browser/accountPolicyGate.contribution.ts b/src/vs/workbench/services/policies/browser/accountPolicyGate.contribution.ts new file mode 100644 index 0000000000000..013fa015a73ef --- /dev/null +++ b/src/vs/workbench/services/policies/browser/accountPolicyGate.contribution.ts @@ -0,0 +1,9 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; +import { AccountPolicyGateContribution } from './accountPolicyGateContribution.js'; + +registerWorkbenchContribution2(AccountPolicyGateContribution.ID, AccountPolicyGateContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts new file mode 100644 index 0000000000000..3b2786ebc7e78 --- /dev/null +++ b/src/vs/workbench/services/policies/browser/accountPolicyGateContribution.ts @@ -0,0 +1,210 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable, DisposableStore, MutableDisposable } from '../../../../base/common/lifecycle.js'; +import { disposableTimeout } from '../../../../base/common/async.js'; +import { URI } from '../../../../base/common/uri.js'; +import { localize } from '../../../../nls.js'; +import { ICommandService } from '../../../../platform/commands/common/commands.js'; +import { IContextKey, IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { ILogService } from '../../../../platform/log/common/log.js'; +import { INotificationService, Severity } from '../../../../platform/notification/common/notification.js'; +import { IOpenerService } from '../../../../platform/opener/common/opener.js'; +import { IStorageService, StorageScope } from '../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; +import { IWorkbenchContribution } from '../../../common/contributions.js'; +import { DEFAULT_ACCOUNT_SIGN_IN_COMMAND } from '../../accounts/browser/defaultAccount.js'; +import { IChatEntitlementService } from '../../chat/common/chatEntitlementService.js'; +import { AccountPolicyGateState, AccountPolicyGateUnsatisfiedReason, ChatAccountPolicyGateActiveContext, IAccountPolicyGateInfo, IAccountPolicyGateService } from '../common/accountPolicyService.js'; + +const NOTIFICATION_DISMISSED_KEY = 'accountPolicy.gateNotificationDismissed'; + +type AccountPolicyGateStateEvent = { + gateActive: boolean; + gateSatisfied: boolean; + reasonNotSatisfied: string | undefined; +}; + +type AccountPolicyGateStateClassification = { + owner: 'joshspicer'; + comment: 'Tracks the Account Policy gate state for diagnosing account-driven restriction issues.'; + gateActive: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'True if an admin has activated the Approved Account gate (non-empty approved-organization list).' }; + gateSatisfied: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'True if the gate is satisfied (signed-in approved account with resolved policy).' }; + reasonNotSatisfied: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Bucketed reason the gate is unsatisfied: noAccount, wrongProvider, orgNotApproved, policyNotResolved.' }; +}; + +/** + * UX/observability adapter for the Account Policy gate. Mirrors gate state into + * a context key, shows a sign-in notification when restricted, and emits telemetry. + * Does NOT re-evaluate the gate — `AccountPolicyService` owns that. + */ +export class AccountPolicyGateContribution extends Disposable implements IWorkbenchContribution { + + static readonly ID = 'workbench.contrib.accountPolicyGate'; + + private readonly contextKey: IContextKey; + private lastInfo: IAccountPolicyGateInfo; + + private readonly notificationHandle = this._register(new MutableDisposable()); + private dismissedKey: string | undefined; + + private initialised = false; + + constructor( + @IAccountPolicyGateService private readonly gateService: IAccountPolicyGateService, + @IContextKeyService contextKeyService: IContextKeyService, + @IChatEntitlementService private readonly chatEntitlementService: IChatEntitlementService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @ILogService private readonly logService: ILogService, + @INotificationService private readonly notificationService: INotificationService, + @ICommandService private readonly commandService: ICommandService, + @IOpenerService private readonly openerService: IOpenerService, + @IStorageService private readonly storageService: IStorageService, + @ITelemetryService private readonly telemetryService: ITelemetryService, + ) { + super(); + this.contextKey = ChatAccountPolicyGateActiveContext.bindTo(contextKeyService); + this.lastInfo = this.gateService.gateInfo; + + // Apply context key + setForceHidden immediately (fail-closed), but defer the + // notification until either the first onDidChangeGateInfo or a 5s timeout — + // without this, a startup race shows "sign in" before the default account loads. + this.apply(this.lastInfo, /*forceTelemetry*/ true, /*showNotification*/ false); + + this._register(this.gateService.onDidChangeGateInfo(info => { + this.initialised = true; + this.apply(info, /*forceTelemetry*/ false, /*showNotification*/ true); + })); + + this._register(disposableTimeout(() => { + if (!this.initialised) { + this.initialised = true; + this.apply(this.lastInfo, /*forceTelemetry*/ false, /*showNotification*/ true); + } + }, 5000)); + } + + private apply(info: IAccountPolicyGateInfo, forceTelemetry: boolean, showNotification: boolean): void { + const stateChanged = forceTelemetry || info.state !== this.lastInfo.state || info.reason !== this.lastInfo.reason; + this.lastInfo = info; + + // Suppress the context key during the transient `policyNotResolved` state + // (user IS in approved org, just waiting for data) so the UI doesn't flash. + const isRestricted = info.state === AccountPolicyGateState.Restricted + && info.reason !== AccountPolicyGateUnsatisfiedReason.PolicyNotResolved; + this.contextKey.set(isRestricted); + this.chatEntitlementService.setForceHidden(isRestricted); + this.logService.info(`[AccountPolicyGate] apply: state=${info.state}, reason=${info.reason}, isRestricted=${isRestricted}`); + + if (stateChanged) { + this.telemetryService.publicLog2('accountPolicy.gateState', { + gateActive: info.state !== AccountPolicyGateState.Inactive, + gateSatisfied: info.state === AccountPolicyGateState.Satisfied, + reasonNotSatisfied: info.reason, + }); + } + + if (info.state !== AccountPolicyGateState.Restricted) { + this.notificationHandle.clear(); + this.dismissedKey = undefined; + this.storageService.remove(NOTIFICATION_DISMISSED_KEY, StorageScope.APPLICATION); + return; + } + + if (!showNotification) { + return; + } + + if (info.reason === AccountPolicyGateUnsatisfiedReason.PolicyNotResolved) { + return; + } + + // Composite key so swapping accounts (while still blocked) re-shows the notification. + const accountName = this.defaultAccountService.currentDefaultAccount?.accountName; + const notificationKey = `${info.reason ?? ''}:${accountName ?? ''}`; + + if (this.dismissedKey !== undefined && this.dismissedKey !== notificationKey) { + this.notificationHandle.clear(); + this.dismissedKey = undefined; + } + this.maybeShowNotification(info, notificationKey); + } + + private maybeShowNotification(info: IAccountPolicyGateInfo, notificationKey: string): void { + if (this.notificationHandle.value) { + return; + } + if (this.dismissedKey === notificationKey) { + return; + } + const persistedDismissed = this.storageService.get(NOTIFICATION_DISMISSED_KEY, StorageScope.APPLICATION); + if (persistedDismissed === notificationKey) { + return; + } + + const reason = info.reason; + const accountName = this.defaultAccountService.currentDefaultAccount?.accountName; + const approvedOrgs = info.approvedOrganizations ?? []; + const hasConcreteOrgs = approvedOrgs.length > 0 && !approvedOrgs.includes('*'); + + // Notifications render as plain inline text — comma-separate orgs. + const orgList = approvedOrgs.join(', '); + let message: string; + if (reason === AccountPolicyGateUnsatisfiedReason.OrgNotApproved) { + if (accountName && hasConcreteOrgs) { + message = localize( + 'accountPolicy.notification.orgWithAccount', + "The account \"{0}\" is not a member of an approved organization ({1}). Sign into an approved GitHub account to use AI features. Contact your administrator for more information.", + accountName, + orgList + ); + } else if (accountName) { + message = localize( + 'accountPolicy.notification.orgWithAccountNoList', + "The account \"{0}\" is not a member of an approved organization. Sign into an approved GitHub account to use AI features. Contact your administrator for more information.", + accountName + ); + } else { + message = localize('accountPolicy.notification.org', "Sign in with a GitHub account from an approved organization to use AI features. Contact your administrator for more information."); + } + } else { + // noAccount / wrongProvider + if (hasConcreteOrgs) { + message = localize( + 'accountPolicy.notification.signinWithOrgs', + "Sign in with a GitHub account from an approved organization ({0}) to use AI features.", + orgList + ); + } else { + message = localize('accountPolicy.notification.signin', "Sign in with an approved GitHub account to use AI features. Contact your administrator for more information."); + } + } + + const handleDisposables = new DisposableStore(); + const handle = this.notificationService.prompt( + Severity.Warning, + message, + [ + { + label: localize('accountPolicy.notification.signin.action', "Sign In"), + run: () => this.commandService.executeCommand(DEFAULT_ACCOUNT_SIGN_IN_COMMAND), + }, + { + label: localize('accountPolicy.notification.learnMore', "Learn More"), + run: () => this.openerService.open(URI.parse('https://code.visualstudio.com/docs/enterprise/overview')), + }, + ], + { sticky: true } + ); + + handleDisposables.add(handle.onDidClose(() => { + this.dismissedKey = notificationKey; + this.notificationHandle.clear(); + })); + handleDisposables.add({ dispose: () => handle.close() }); + this.notificationHandle.value = handleDisposables; + } +} diff --git a/src/vs/workbench/services/policies/common/accountPolicyService.ts b/src/vs/workbench/services/policies/common/accountPolicyService.ts index ffd32cb2fa613..adfd93b34d9f9 100644 --- a/src/vs/workbench/services/policies/common/accountPolicyService.ts +++ b/src/vs/workbench/services/policies/common/accountPolicyService.ts @@ -4,33 +4,139 @@ *--------------------------------------------------------------------------------------------*/ import { IStringDictionary } from '../../../../base/common/collections.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { localize } from '../../../../nls.js'; +import { RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; +import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { AbstractPolicyService, IPolicyService, PolicyDefinition } from '../../../../platform/policy/common/policy.js'; +import { AbstractPolicyService, getRestrictedPolicyValue, IPolicyService, PolicyDefinition, PolicyValue } from '../../../../platform/policy/common/policy.js'; import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +/** + * Policy name (declared by `chat.approvedAccountOrganizations`) holding the list of + * GitHub organization logins that satisfy the gate. The token `*` is a wildcard. + */ +export const APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME = 'ChatApprovedAccountOrganizations'; -export class AccountPolicyService extends AbstractPolicyService implements IPolicyService { +export const enum AccountPolicyGateState { + Inactive = 'inactive', + Satisfied = 'satisfied', + /** Gate active and NOT satisfied — restricted values are applied to all gated policies. */ + Restricted = 'restricted', +} + +export const enum AccountPolicyGateUnsatisfiedReason { + NoAccount = 'noAccount', + WrongProvider = 'wrongProvider', + OrgNotApproved = 'orgNotApproved', + PolicyNotResolved = 'policyNotResolved', +} + +export interface IAccountPolicyGateInfo { + readonly state: AccountPolicyGateState; + readonly reason?: AccountPolicyGateUnsatisfiedReason; + readonly approvedOrganizations?: readonly string[]; +} + +export const ChatAccountPolicyGateActiveContext = new RawContextKey( + 'chatAccountPolicyGateActive', + false, + { type: 'boolean', description: localize('chatAccountPolicyGateActive', "True when the 'Require Approved Account' policy is in effect and the user is not yet signed into an approved GitHub organization, so all AI features are disabled until they sign in.") } +); + +/** + * Read-only accessor for the Account Policy gate state. Backed by the same + * `AccountPolicyService` instance that drives policy enforcement, so UX consumers + * (notifications, context keys, telemetry) cannot drift from the authoritative + * gate decision. + */ +export const IAccountPolicyGateService = createDecorator('accountPolicyGateService'); +export interface IAccountPolicyGateService { + readonly _serviceBrand: undefined; + readonly gateInfo: IAccountPolicyGateInfo; + readonly onDidChangeGateInfo: Event; +} + +export class AccountPolicyService extends AbstractPolicyService implements IPolicyService, IAccountPolicyGateService { + + declare readonly _serviceBrand: undefined; + + private _gateInfo: IAccountPolicyGateInfo = { state: AccountPolicyGateState.Inactive }; + get gateInfo(): IAccountPolicyGateInfo { return this._gateInfo; } + + private readonly _onDidChangeGateInfo = this._register(new Emitter()); + readonly onDidChangeGateInfo = this._onDidChangeGateInfo.event; + + // Read-only — the MultiplexPolicyService owns calling updatePolicyDefinitions. + private readonly managedPolicyReader?: IPolicyService; constructor( @ILogService private readonly logService: ILogService, - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + managedPolicyService?: IPolicyService, ) { super(); + this.managedPolicyReader = managedPolicyService; + this._updatePolicyDefinitions(this.policyDefinitions); this._register(this.defaultAccountService.onDidChangePolicyData(() => { this._updatePolicyDefinitions(this.policyDefinitions); })); + this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => { + this._updatePolicyDefinitions(this.policyDefinitions); + })); + if (this.managedPolicyReader) { + this._register(this.managedPolicyReader.onDidChange(names => { + if (names.includes(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME)) { + this._updatePolicyDefinitions(this.policyDefinitions); + } + })); + } + + // The initial account load sets `currentDefaultAccount` but does NOT fire + // `onDidChangeDefaultAccount`. Re-evaluate once the account has resolved + // so the gate doesn't stay stuck on `noAccount`. + this.defaultAccountService.getDefaultAccount().then(() => { + this._updatePolicyDefinitions(this.policyDefinitions); + }); } protected async _updatePolicyDefinitions(policyDefinitions: IStringDictionary): Promise { this.logService.trace(`AccountPolicyService#_updatePolicyDefinitions: Got ${Object.keys(policyDefinitions).length} policy definitions`); + const updated: string[] = []; const policyData = this.defaultAccountService.policyData; + const previousInfo = this._gateInfo; + this._gateInfo = this.computeGateInfo(); + const previousApprovedOrgs = previousInfo.approvedOrganizations?.join('\n') ?? ''; + const currentApprovedOrgs = this._gateInfo.approvedOrganizations?.join('\n') ?? ''; + const gateInfoChanged = previousInfo.state !== this._gateInfo.state + || previousInfo.reason !== this._gateInfo.reason + || previousApprovedOrgs !== currentApprovedOrgs; + + // `policyNotResolved` is a transient state where the user IS in an approved + // org but account-side policy data hasn't loaded yet. We don't force restricted + // values here — `policy.value(policyData)` naturally returns undefined when + // `policyData` is null, so no account overrides slip through. Forcing + // `restrictedValue` would transiently flip `chat.disableAIFeatures = true`, + // surfacing confusing "Unable to write" errors and a UI flash. + const gateRestricted = this._gateInfo.state === AccountPolicyGateState.Restricted + && this._gateInfo.reason !== AccountPolicyGateUnsatisfiedReason.PolicyNotResolved; + for (const key in policyDefinitions) { const policy = policyDefinitions[key]; - const policyValue = policyData && policy.value ? policy.value(policyData) : undefined; + + let policyValue: PolicyValue | undefined; + if (gateRestricted && (policy.value !== undefined || policy.restrictedValue !== undefined)) { + // MDM-only policies (no `value`, no `restrictedValue`) — including the policy + // that DRIVES the gate itself — are left untouched so the admin remains in control. + policyValue = getRestrictedPolicyValue(policy); + } else if (policyData && policy.value) { + policyValue = policy.value(policyData); + } + if (policyValue !== undefined) { if (this.policies.get(key) !== policyValue) { this.policies.set(key, policyValue); @@ -46,5 +152,64 @@ export class AccountPolicyService extends AbstractPolicyService implements IPoli if (updated.length) { this._onDidChange.fire(updated); } + if (gateInfoChanged) { + this._onDidChangeGateInfo.fire(this._gateInfo); + } + } + + private computeGateInfo(): IAccountPolicyGateInfo { + if (!this.managedPolicyReader) { + return { state: AccountPolicyGateState.Inactive }; + } + + const approvedRaw = this.managedPolicyReader.getPolicyValue(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME); + const approvedOrgs = parseApprovedOrganizations(approvedRaw); + if (approvedOrgs.length === 0) { + return { state: AccountPolicyGateState.Inactive }; + } + + const account = this.defaultAccountService.currentDefaultAccount; + if (!account) { + return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.NoAccount, approvedOrganizations: approvedOrgs }; + } + + const configuredProvider = this.defaultAccountService.getDefaultAccountAuthenticationProvider(); + if (account.authenticationProvider.id !== configuredProvider.id) { + return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.WrongProvider, approvedOrganizations: approvedOrgs }; + } + + // Org membership is checked BEFORE policy-data resolution so users definitively + // NOT in an approved org are restricted immediately, even while policy data is + // still loading. `policyNotResolved` is reserved for users who ARE in an approved + // org — a transient state that resolves on its own. + if (!approvedOrgs.includes('*')) { + const accountOrgs = (account.entitlementsData?.organization_login_list ?? []).map(o => o.toLowerCase()); + const intersects = accountOrgs.some(org => approvedOrgs.includes(org)); + if (!intersects) { + return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.OrgNotApproved, approvedOrganizations: approvedOrgs }; + } + } + + if (this.defaultAccountService.policyData === null) { + return { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.PolicyNotResolved, approvedOrganizations: approvedOrgs }; + } + + return { state: AccountPolicyGateState.Satisfied, approvedOrganizations: approvedOrgs }; + } +} + +function parseApprovedOrganizations(raw: PolicyValue | undefined): string[] { + // Array-typed policies are delivered as JSON-stringified arrays — see + // `PolicyConfiguration.parse` for the same normalisation. + let value: unknown = raw; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* not JSON */ } + } + if (!Array.isArray(value)) { + return []; } + return value + .filter((v): v is string => typeof v === 'string') + .map(s => s.trim().toLowerCase()) + .filter(s => s.length > 0); } diff --git a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts index a61c00d64aec0..36a021569c1e8 100644 --- a/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/browser/accountPolicyService.test.ts @@ -12,10 +12,11 @@ import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../.. import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; +import { AbstractPolicyService, IPolicyService, PolicyValue } from '../../../../../platform/policy/common/policy.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; import { DefaultAccountService } from '../../../accounts/browser/defaultAccount.js'; -import { AccountPolicyService } from '../../common/accountPolicyService.js'; +import { AccountPolicyGateState, AccountPolicyGateUnsatisfiedReason, AccountPolicyService, APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, IAccountPolicyGateInfo } from '../../common/accountPolicyService.js'; const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { authenticationProvider: { @@ -37,7 +38,7 @@ class DefaultAccountProvider implements IDefaultAccountProvider { constructor( readonly defaultAccount: IDefaultAccount, - readonly policyData: IPolicyData = {}, + readonly policyData: IPolicyData | null = {}, ) { } getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { @@ -203,4 +204,283 @@ suite('AccountPolicyService', () => { assert.strictEqual(D, false); } }); + + // --------------------------------------------------------------------- + // "Require Approved Account" gate + // --------------------------------------------------------------------- + + const APPROVED_ORG_ACCOUNT: IDefaultAccount = { + ...BASE_DEFAULT_ACCOUNT, + entitlementsData: { + access_type_sku: 'sku', + chat_enabled: true, + assigned_date: '', + can_signup_for_limited: false, + copilot_plan: 'pro', + organization_login_list: ['ApprovedOrg'], + analytics_tracking_id: '', + }, + }; + + const UNAPPROVED_ORG_ACCOUNT: IDefaultAccount = { + ...BASE_DEFAULT_ACCOUNT, + entitlementsData: { + access_type_sku: 'sku', + chat_enabled: true, + assigned_date: '', + can_signup_for_limited: false, + copilot_plan: 'pro', + organization_login_list: ['SomeOtherOrg'], + analytics_tracking_id: '', + }, + }; + + class FakeManagedPolicyService extends AbstractPolicyService implements IPolicyService { + private readonly fakePolicies = new Map(); + + setPolicy(name: string, value: PolicyValue | undefined): void { + if (value === undefined) { + if (this.fakePolicies.delete(name)) { + this._onDidChange.fire([name]); + } + } else { + this.fakePolicies.set(name, value); + this._onDidChange.fire([name]); + } + } + + override getPolicyValue(name: string): PolicyValue | undefined { + return this.fakePolicies.get(name); + } + + protected async _updatePolicyDefinitions(): Promise { /* no-op */ } + } + + async function setupGate(opts: { + approvedOrgs?: string[] | string; + account?: IDefaultAccount | null; + policyData?: IPolicyData | null; + }): Promise<{ policyService: AccountPolicyService; managed: FakeManagedPolicyService }> { + const managed = disposables.add(new FakeManagedPolicyService()); + if (opts.approvedOrgs !== undefined) { + // Mirror how the platform delivers array-typed policy values to AbstractPolicyService: + // as a JSON-stringified array. Tests can pass a raw string to exercise edge cases. + const value = typeof opts.approvedOrgs === 'string' ? opts.approvedOrgs : JSON.stringify(opts.approvedOrgs); + managed.setPolicy(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, value); + } + + const accountService = disposables.add(new DefaultAccountService(TestProductService)); + if (opts.account !== null && opts.account !== undefined) { + const policyData = opts.policyData === undefined ? {} : opts.policyData; + accountService.setDefaultAccountProvider(new DefaultAccountProvider(opts.account, policyData)); + await accountService.refresh(); + } + + const service = disposables.add(new AccountPolicyService(logService, accountService, managed)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + const config = disposables.add(new PolicyConfiguration(defaultConfiguration, service, new NullLogService())); + await config.initialize(); + return { policyService: service, managed }; + } + + test('gate inactive (no approved orgs set): behaves identically to today', async () => { + const { policyService } = await setupGate({ account: APPROVED_ORG_ACCOUNT, policyData: { chat_preview_features_enabled: false } }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Inactive); + assert.strictEqual(policyService.getPolicyValue('PolicySettingD'), false); // account policy still flows + }); + + test('gate active, no account signed in: restricted', async () => { + const { policyService } = await setupGate({ approvedOrgs: ['ApprovedOrg'], account: null }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Restricted); + assert.strictEqual(policyService.gateInfo.reason, AccountPolicyGateUnsatisfiedReason.NoAccount); + // Restricted values applied to policies that opt into the gate. + // PolicySettingD has a `value` callback → falls back to type-default `false`. + assert.strictEqual(policyService.getPolicyValue('PolicySettingD'), false); + // PolicySettingA does NOT opt in (no `value`, no `restrictedValue`) → unchanged. + assert.strictEqual(policyService.getPolicyValue('PolicySettingA'), undefined); + }); + + test('gate active, signed in but org not approved: restricted', async () => { + const { policyService } = await setupGate({ approvedOrgs: ['ApprovedOrg'], account: UNAPPROVED_ORG_ACCOUNT, policyData: {} }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Restricted); + assert.strictEqual(policyService.gateInfo.reason, AccountPolicyGateUnsatisfiedReason.OrgNotApproved); + }); + + test('gate active, account in approved org but policyData null (pre-resolution): restricted', async () => { + const { policyService } = await setupGate({ approvedOrgs: ['approvedorg'], account: APPROVED_ORG_ACCOUNT, policyData: null }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Restricted); + assert.strictEqual(policyService.gateInfo.reason, AccountPolicyGateUnsatisfiedReason.PolicyNotResolved); + }); + + test('gate active, satisfied (case-insensitive org match): account policy values flow normally', async () => { + const { policyService } = await setupGate({ approvedOrgs: [' approvedorg ', ' Other '], account: APPROVED_ORG_ACCOUNT, policyData: { chat_preview_features_enabled: false } }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Satisfied); + assert.strictEqual(policyService.getPolicyValue('PolicySettingD'), false); // from account policy data, not restricted + assert.strictEqual(policyService.getPolicyValue('PolicySettingA'), undefined); // not driven by account + }); + + test('gate active, wildcard "*" satisfies any signed-in account', async () => { + const { policyService } = await setupGate({ approvedOrgs: ['*'], account: UNAPPROVED_ORG_ACCOUNT, policyData: {} }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Satisfied); + }); + + test('approved org list empty: gate inactive', async () => { + const { policyService } = await setupGate({ approvedOrgs: [], account: APPROVED_ORG_ACCOUNT, policyData: {} }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Inactive); + }); + + test('approved orgs raw non-array string from policy service: gate inactive (fail-safe)', async () => { + // Defensive: if some platform delivers the policy as a non-JSON string, treat it as no-orgs + // rather than half-parsing CSV. The platform's array-typed policy contract makes this rare. + const { policyService } = await setupGate({ approvedOrgs: 'github', account: APPROVED_ORG_ACCOUNT, policyData: {} }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Inactive); + }); + + test('gate active, signed in with non-GitHub provider: WrongProvider reason', async () => { + // Custom provider whose configured GitHub provider differs from the account's actual provider. + class MismatchedProvider extends DefaultAccountProvider { + override getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + return { id: 'github', name: 'GitHub', enterprise: false }; + } + } + const NON_GITHUB_ACCOUNT: IDefaultAccount = { + ...APPROVED_ORG_ACCOUNT, + authenticationProvider: { id: 'microsoft', name: 'Microsoft', enterprise: false }, + }; + + const managed = disposables.add(new FakeManagedPolicyService()); + managed.setPolicy(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, JSON.stringify(['ApprovedOrg'])); + const accountService = disposables.add(new DefaultAccountService(TestProductService)); + accountService.setDefaultAccountProvider(new MismatchedProvider(NON_GITHUB_ACCOUNT, {})); + await accountService.refresh(); + const service = disposables.add(new AccountPolicyService(logService, accountService, managed)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + const config = disposables.add(new PolicyConfiguration(defaultConfiguration, service, new NullLogService())); + await config.initialize(); + + assert.strictEqual(service.gateInfo.state, AccountPolicyGateState.Restricted); + assert.strictEqual(service.gateInfo.reason, AccountPolicyGateUnsatisfiedReason.WrongProvider); + }); + + test('explicit `restrictedValue` is honored when gate is restricted', async () => { + const node: IConfigurationNode = { + id: 'restrictedValueConfig', + order: 2, + title: 'r', + type: 'object', + properties: { + 'setting.RV': { + type: 'string', + default: 'open', + policy: { + name: 'PolicySettingRV', + category: PolicyCategory.Extensions, + minimumVersion: '1.0.0', + localization: { description: { key: '', value: '' } }, + restrictedValue: 'locked', + } + } + } + }; + Registry.as(Extensions.Configuration).registerConfiguration(node); + try { + const { policyService } = await setupGate({ approvedOrgs: ['ApprovedOrg'], account: null }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Restricted); + assert.strictEqual(policyService.getPolicyValue('PolicySettingRV'), 'locked'); + } finally { + Registry.as(Extensions.Configuration).deregisterConfigurations([node]); + } + }); + + test('onDidChangeGateInfo fires on state/reason transitions', async () => { + const { policyService, managed } = await setupGate({ approvedOrgs: ['ApprovedOrg'], account: APPROVED_ORG_ACCOUNT, policyData: {} }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Satisfied); + + const events: IAccountPolicyGateInfo[] = []; + disposables.add(policyService.onDidChangeGateInfo(info => events.push(info))); + + // Satisfied → Restricted (org no longer approved) + managed.setPolicy(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, JSON.stringify(['OnlyOtherOrg'])); + await new Promise(resolve => setTimeout(resolve, 0)); + // Restricted → Inactive (gate disabled) + managed.setPolicy(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, JSON.stringify([])); + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.deepStrictEqual( + events.map(e => ({ state: e.state, reason: e.reason })), + [ + { state: AccountPolicyGateState.Restricted, reason: AccountPolicyGateUnsatisfiedReason.OrgNotApproved }, + { state: AccountPolicyGateState.Inactive, reason: undefined }, + ] + ); + }); + + test('boot race: gate is fail-closed until async managed policy service resolves', async () => { + // Simulate the IPC boundary: managed service only knows about its policies AFTER + // `updatePolicyDefinitions` has been called by the MultiplexPolicyService. + // Before that, `getPolicyValue` returns undefined. + class AsyncManagedPolicyService extends FakeManagedPolicyService { + private _seeded = false; + private readonly _seedValue: string; + constructor(seedValue: string) { + super(); + this._seedValue = seedValue; + } + override getPolicyValue(name: string): PolicyValue | undefined { + if (!this._seeded) { + return undefined; + } + return super.getPolicyValue(name); + } + async seed(): Promise { + // Simulate the MultiplexPolicyService calling updatePolicyDefinitions, + // which in production triggers the IPC round-trip and then fires onDidChange. + await new Promise(resolve => setTimeout(resolve, 0)); + this._seeded = true; + this.setPolicy(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, this._seedValue); + } + } + + const managed = disposables.add(new AsyncManagedPolicyService(JSON.stringify(['OnlyOtherOrg']))); + const accountService = disposables.add(new DefaultAccountService(TestProductService)); + accountService.setDefaultAccountProvider(new DefaultAccountProvider(APPROVED_ORG_ACCOUNT, {})); + await accountService.refresh(); + + const service = disposables.add(new AccountPolicyService(logService, accountService, managed)); + const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); + await defaultConfiguration.initialize(); + const config = disposables.add(new PolicyConfiguration(defaultConfiguration, service, new NullLogService())); + await config.initialize(); + + // Before managed service resolves, the gate sees no approved-org policy → Inactive. + assert.strictEqual(service.gateInfo.state, AccountPolicyGateState.Inactive); + + // Simulate the multiplex seeding the managed service (IPC completes). + // This fires onDidChange on the managed service, which AccountPolicyService + // listens to and re-evaluates the gate. + await managed.seed(); + + // Gate must now reflect the admin policy; account is NOT in 'OnlyOtherOrg'. + assert.strictEqual(service.gateInfo.state, AccountPolicyGateState.Restricted); + assert.strictEqual(service.gateInfo.reason, AccountPolicyGateUnsatisfiedReason.OrgNotApproved); + }); + + test('managed policy change re-evaluates the gate and fires onDidChange', async () => { + const { policyService, managed } = await setupGate({ approvedOrgs: ['ApprovedOrg'], account: APPROVED_ORG_ACCOUNT, policyData: {} }); + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Satisfied); + + const changes: string[] = []; + disposables.add(policyService.onDidChange(names => changes.push(...names))); + + // Change the approved-org list to one the account is NOT in → flip Satisfied → Restricted, + // which forces restricted values onto opted-in policies and emits onDidChange. + managed.setPolicy(APPROVED_ACCOUNT_ORGANIZATIONS_POLICY_NAME, JSON.stringify(['OnlyOtherOrg'])); + // `_updatePolicyDefinitions` is async — wait one turn for it to resolve. + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.strictEqual(policyService.gateInfo.state, AccountPolicyGateState.Restricted); + assert.ok(changes.length > 0, 'expected onDidChange to fire when gate flips'); + }); }); diff --git a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts index c0be8d3ce37d0..a1a37dca5a3be 100644 --- a/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts +++ b/src/vs/workbench/test/browser/componentFixtures/fixtureUtils.ts @@ -421,6 +421,7 @@ export function createEditorServices(disposables: DisposableStore, options?: Cre onDidChangeDefaultAccount: new Emitter().event, onDidChangePolicyData: new Emitter().event, policyData: null, + currentDefaultAccount: null, copilotTokenInfo: null, onDidChangeCopilotTokenInfo: new Emitter().event, getDefaultAccount: async () => null, diff --git a/src/vs/workbench/test/common/workbenchTestServices.ts b/src/vs/workbench/test/common/workbenchTestServices.ts index c5ee2c49eeb8f..bb27e6fecf459 100644 --- a/src/vs/workbench/test/common/workbenchTestServices.ts +++ b/src/vs/workbench/test/common/workbenchTestServices.ts @@ -812,6 +812,7 @@ export class TestChatEntitlementService implements IChatEntitlementService { readonly anonymousObs = observableValue({}, false); markAnonymousRateLimited(): void { } + setForceHidden(_hidden: boolean): void { } readonly previewFeaturesDisabled = false; readonly clientByokEnabled = false; diff --git a/src/vs/workbench/workbench.common.main.ts b/src/vs/workbench/workbench.common.main.ts index 32c0e5bc34c3b..87861d8b823bd 100644 --- a/src/vs/workbench/workbench.common.main.ts +++ b/src/vs/workbench/workbench.common.main.ts @@ -194,6 +194,9 @@ registerSingleton(IAllowedMcpServersService, AllowedMcpServersService, Instantia // Default Account import './services/accounts/browser/defaultAccount.js'; +// Account Policy Gate +import './services/policies/browser/accountPolicyGate.contribution.js'; + // Telemetry import './contrib/telemetry/browser/telemetry.contribution.js'; From 85a0e01d114116aab7db8290a927024abdd775eb Mon Sep 17 00:00:00 2001 From: Anthony Kim <62267334+anthonykim1@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:25:19 -0700 Subject: [PATCH 28/35] Launch agent host terminals as login shells on macOS (#312057) * Launch agent host terminals as login shells on macOS On macOS, the agent host terminal manager was spawning zsh/bash without --login, resulting in /etc/zprofile and ~/.zprofile not being sourced. This could cause missing PATH entries (e.g. homebrew, nvm, pyenv) since macOS relies on path_helper in /etc/zprofile for standard PATH setup. The regular VS Code terminal already handles this via the profile resolver, but the agent host bypasses that layer and spawns shells directly via node-pty. This adds the same --login logic inline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use regex match and reuse getSystemShell from base layer Address review feedback: - Use regex match /(zsh|bash)/ instead of strict equality to handle versioned shell names like bash-5.2 (matching the profile resolver) - Reuse getSystemShell() from src/vs/base/node/shell.ts instead of a custom _getDefaultShell(), which handles edge cases like /bin/false fallback and userInfo().shell when $SHELL is unset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * remove comment, explained in desc --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/agentHostTerminalManager.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts index a88f738cd64ca..72d83b733f49c 100644 --- a/src/vs/platform/agentHost/node/agentHostTerminalManager.ts +++ b/src/vs/platform/agentHost/node/agentHostTerminalManager.ts @@ -7,8 +7,9 @@ import * as fs from 'fs'; import { DeferredPromise, raceCancellablePromises, timeout } from '../../../base/common/async.js'; import { Emitter } from '../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../base/common/lifecycle.js'; -import { dirname } from '../../../base/common/path.js'; +import { dirname, parse as pathParse } from '../../../base/common/path.js'; import * as platform from '../../../base/common/platform.js'; +import { getSystemShell } from '../../../base/node/shell.js'; import { URI } from '../../../base/common/uri.js'; import { generateUuid } from '../../../base/common/uuid.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; @@ -184,7 +185,7 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe const cols = params.cols ?? 80; const rows = params.rows ?? 24; - const shell = options?.shell ?? this._getDefaultShell(); + const shell = options?.shell ?? await this._getDefaultShell(); const name = platform.isWindows ? 'cmd' : 'xterm-256color'; this._logService.info(`[TerminalManager] Creating terminal ${uri}: shell=${shell}, cwd=${cwd}, cols=${cols}, rows=${rows}`); @@ -212,9 +213,15 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe env['DEBIAN_FRONTEND'] = 'noninteractive'; } let shellArgs: string[] = []; + if (platform.isMacintosh) { + const shellName = pathParse(shell).name; + if (shellName.match(/(zsh|bash)/)) { + shellArgs = ['--login']; + } + } const injection = await getShellIntegrationInjection( - { executable: shell, args: [], forceShellIntegration: true }, + { executable: shell, args: shellArgs, forceShellIntegration: true }, { shellIntegration: { enabled: true, suggestEnabled: false, nonce }, windowsUseConptyDll: false, @@ -659,11 +666,8 @@ export class AgentHostTerminalManager extends Disposable implements IAgentHostTe } } - private _getDefaultShell(): string { - if (platform.isWindows) { - return process.env['COMSPEC'] || 'cmd.exe'; - } - return process.env['SHELL'] || '/bin/sh'; + private _getDefaultShell(): Promise { + return getSystemShell(platform.OS, process.env); } /** From 1a25e306b34bf57970648493d9242d0545363759 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 22 Apr 2026 20:29:33 -0700 Subject: [PATCH 29/35] Distinguish local agent host harness in customizations view (#312064) * Distinguish local agent host harness in customizations view The local agent host registered its customizations harness with the bare agent displayName ('Copilot CLI'), making it visually identical to the extension-host Copilot CLI harness. Append '[Local]' to match the existing convention used by workspace labels and agent session provider names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Localize [Local] suffix in agent host harness label Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/agentHostChatContribution.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts index fa0dc13b603ac..557129199caf2 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -9,6 +9,7 @@ import { observableValue } from '../../../../../../base/common/observable.js'; import { isEqualOrParent } from '../../../../../../base/common/resources.js'; import { ThemeIcon } from '../../../../../../base/common/themables.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { localize } from '../../../../../../nls.js'; import { AgentHostEnabledSettingId, IAgentHostService, type AgentProvider } from '../../../../../../platform/agentHost/common/agentService.js'; import { type ProtectedResourceMetadata, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { type AgentInfo, type CustomizationRef, type RootState } from '../../../../../../platform/agentHost/common/state/sessionState.js'; @@ -168,9 +169,16 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr // Customization sync provider + bundler + observable const syncProvider = store.add(new AgentCustomizationSyncProvider(sessionType, this._storageService)); const bundler = store.add(this._instantiationService.createInstance(SyncedCustomizationBundler, sessionType)); + // Distinguish from the extension-host Copilot CLI harness, which + // registers under the same `Copilot CLI` displayName via the chat + // session customization provider API. Without the `[Local]` suffix + // both harnesses render identically in the customizations view. + // Matches the workspace-label convention from + // `buildAgentHostSessionWorkspace` and the provider-name in + // `getAgentSessionProviderName(AgentHostCopilot)`. store.add(this._customizationHarnessService.registerExternalHarness({ id: sessionType, - label: agent.displayName, + label: localize('agentHostHarnessLabel.local', "{0} [Local]", agent.displayName), icon: ThemeIcon.fromId(Codicon.server.id), hiddenSections: [], hideGenerateButton: true, From 1febee7a53c33f22d7be89bbe22bfc1a3efcbae6 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 22 Apr 2026 20:31:49 -0700 Subject: [PATCH 30/35] Gray out remembered folders for offline agent hosts (#312063) * Gray out remembered folders for offline agent hosts In the session workspace picker, remembered folders belonging to a disconnected remote agent host are now rendered as disabled, matching the offline host row. Selection of these items was already a no-op via _isProviderUnavailable; this just brings the visual state in line. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Use 'Unavailable' instead of 'Offline' for unreachable provider group label '_isProviderUnavailable' returns true for both Disconnected and Connecting states, so '(Offline)' was inaccurate when a host is still connecting. '(Unavailable)' is correct for both states. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Only gray out remembered folders when provider is Disconnected, not Connecting Use the exact connection status rather than the binary isProviderUnavailable check so that: - Disconnected: folders are grayed out + group label shows '(Offline)' - Connecting: folders are still enabled + group label shows '(Connecting)' - Connected: no change to label or disabled state (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/sessionWorkspacePicker.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts index aad022e246614..b95ca7d4746fc 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionWorkspacePicker.ts @@ -343,17 +343,23 @@ export class WorkspacePicker extends Disposable { const recentWorkspaces = [...ownRecentWorkspaces, ...vsCodeRecents]; // Build flat list of workspace entries with their group info - const workspaceEntries: { workspace: ISessionWorkspace; providerId: string; isOwnRecent: boolean; groupTitle: string }[] = []; + const workspaceEntries: { workspace: ISessionWorkspace; providerId: string; isOwnRecent: boolean; groupTitle: string; isDisconnected: boolean }[] = []; const providersWithWorkspaces = allProviders.filter(p => recentWorkspaces.some(w => w.providerId === p.id)); for (const provider of providersWithWorkspaces) { - const isOffline = this._isProviderUnavailable(provider.id); + const connectionStatus = isAgentHostProvider(provider) ? provider.connectionStatus?.get() : undefined; + const isDisconnected = connectionStatus === RemoteAgentHostConnectionStatus.Disconnected; + const isConnecting = connectionStatus === RemoteAgentHostConnectionStatus.Connecting; const providerWorkspaces = recentWorkspaces .map((w, idx) => ({ ...w, isOwnRecent: idx < ownRecentCount })) .filter(w => w.providerId === provider.id); for (const { workspace, providerId, isOwnRecent } of providerWorkspaces) { const groupName = workspace.group ?? provider.label; - const groupTitle = isOffline ? localize('workspacePicker.groupOffline', "{0} (Offline)", groupName) : groupName; - workspaceEntries.push({ workspace, providerId, isOwnRecent, groupTitle }); + const groupTitle = isDisconnected + ? localize('workspacePicker.groupOffline', "{0} (Offline)", groupName) + : isConnecting + ? localize('workspacePicker.groupConnecting', "{0} (Connecting)", groupName) + : groupName; + workspaceEntries.push({ workspace, providerId, isOwnRecent, groupTitle, isDisconnected }); } } @@ -368,7 +374,7 @@ export class WorkspacePicker extends Disposable { // Add items with separators between groups let lastGroupTitle: string | undefined; - for (const { workspace, providerId, isOwnRecent, groupTitle } of workspaceEntries) { + for (const { workspace, providerId, isOwnRecent, groupTitle, isDisconnected } of workspaceEntries) { if (lastGroupTitle !== undefined && lastGroupTitle !== groupTitle) { items.push({ kind: ActionListItemKind.Separator, label: '' }); } @@ -380,6 +386,7 @@ export class WorkspacePicker extends Disposable { label: workspace.label, description: workspace.description, group: { title: groupTitle, icon: workspace.icon }, + disabled: isDisconnected, item: { selection, checked: selected || undefined }, onRemove: isOwnRecent ? () => this._removeRecentWorkspace(selection) : () => this._removeVSCodeRecentWorkspace(selection), }); From 9a4bb7072dcb36e759fcde08b0ed7968a69dfb48 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:33:16 -0700 Subject: [PATCH 31/35] Add 'Collapse All Groups' action to sessions filter menu (#312056) * Add 'Collapse All Groups' action to sessions filter menu Add a new menu action in the sessions view filter submenu that collapses all section groups (time-based or workspace-based). This adds: - collapseAllSections() to ISessionsList interface and SessionsList class - CollapseAllGroupsAction registered in SessionsViewFilterSubMenu Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Batch collapse state persistence to avoid per-section storage churn Suspend the onDidChangeCollapseState listener during collapseAll and persist all section states in a single write via saveBulkCollapseState. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../sessions/browser/views/sessionsList.ts | 25 +++++++++++++++++++ .../browser/views/sessionsViewActions.ts | 18 +++++++++++++ 2 files changed, 43 insertions(+) diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts index 6ce00a897d948..774a30c6a347d 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsList.ts @@ -652,6 +652,7 @@ export interface ISessionsList { resetFilters(): void; setWorkspaceGroupCapped(capped: boolean): void; isWorkspaceGroupCapped(): boolean; + collapseAllSections(): void; } export class SessionsList extends Disposable implements ISessionsList { @@ -675,6 +676,7 @@ export class SessionsList extends Disposable implements ISessionsList { private workspaceGroupCapped: boolean; private readonly expandedWorkspaceGroups = new Set(); private findOpen = false; + private suspendCollapseStatePersistence = false; private readonly _onDidUpdate = this._register(new Emitter()); readonly onDidUpdate: Event = this._onDidUpdate.event; @@ -806,6 +808,9 @@ export class SessionsList extends Disposable implements ISessionsList { this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); this._register(this.tree.onDidChangeCollapseState(e => { + if (this.suspendCollapseStatePersistence) { + return; + } const element = e.node.element; if (element && isSessionSection(element)) { this.saveSectionCollapseState(element.id, e.node.collapsed); @@ -1187,6 +1192,16 @@ export class SessionsList extends Disposable implements ISessionsList { return this.workspaceGroupCapped; } + collapseAllSections(): void { + this.suspendCollapseStatePersistence = true; + try { + this.tree.collapseAll(); + } finally { + this.suspendCollapseStatePersistence = false; + } + this.saveBulkCollapseState(true); + } + // -- Section collapse persistence -- private getSavedCollapseState(sectionId: string): boolean | undefined { @@ -1221,6 +1236,16 @@ export class SessionsList extends Disposable implements ISessionsList { this.storageService.store(SessionsList.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); } + private saveBulkCollapseState(collapsed: boolean): void { + const state: Record = {}; + for (const child of this.tree.getNode(null).children) { + if (child.element && isSessionSection(child.element)) { + state[child.element.id] = collapsed; + } + } + this.storageService.store(SessionsList.SECTION_COLLAPSE_STATE_KEY, JSON.stringify(state), StorageScope.PROFILE, StorageTarget.USER); + } + } //#endregion diff --git a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts index 5618a6f90a0de..d15c9f6b36156 100644 --- a/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts +++ b/src/vs/sessions/contrib/sessions/browser/views/sessionsViewActions.ts @@ -209,6 +209,24 @@ registerAction2(class ShowAllWorkspaceSessionsAction extends Action2 { } }); +// Collapse All Groups + +registerAction2(class CollapseAllGroupsAction extends Action2 { + constructor() { + super({ + id: 'sessionsViewPane.collapseAllGroups', + title: localize2('collapseAllGroups', "Collapse All Groups"), + category: SessionsCategories.Sessions, + menu: [{ id: SessionsViewFilterSubMenu, group: '4_collapse', order: 0 }] + }); + } + override run(accessor: ServicesAccessor) { + const viewsService = accessor.get(IViewsService); + const view = viewsService.getViewWithId(SessionsViewId); + view?.sessionsControl?.collapseAllSections(); + } +}); + // View Toolbar Actions registerAction2(class RefreshSessionsAction extends Action2 { From 54cc74d79d914ada3f3f30b2b126a81ca21a8b8e Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Wed, 22 Apr 2026 20:35:42 -0700 Subject: [PATCH 32/35] chat: don't restore input focus on touch-tap confirmation clicks (#312055) Tapping a tool confirmation button on mobile (iOS) was popping the on-screen keyboard because the click handler unconditionally called focusInput() on the chat widget. Expose isTouchClick on IChatConfirmationButtonClickEvent so callers can skip focus restoration when the click came from a touch tap, while preserving the existing behavior for mouse/keyboard activations. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../chatConfirmationContentPart.ts | 2 +- .../chatConfirmationWidget.ts | 27 +++++++++++++------ .../chatElicitationContentPart.ts | 2 +- .../abstractToolConfirmationSubPart.ts | 6 +++-- .../chatExtensionsInstallToolSubPart.ts | 6 +++-- .../chatModifiedFilesConfirmationSubPart.ts | 6 +++-- .../chatTerminalToolConfirmationSubPart.ts | 6 +++-- 7 files changed, 37 insertions(+), 18 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts index e7e7ba7992198..0844edd70d676 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationContentPart.ts @@ -39,7 +39,7 @@ export class ChatConfirmationContentPart extends Disposable implements IChatCont const confirmationWidget = this._register(this.instantiationService.createInstance(SimpleChatConfirmationWidget, context, { title: confirmation.title, buttons, message: confirmation.message })); confirmationWidget.setShowButtons(!confirmation.isUsed); - this._register(confirmationWidget.onDidClick(async e => { + this._register(confirmationWidget.onDidClick(async ({ button: e }) => { if (isResponseVM(element)) { const prompt = `${e.label}: "${confirmation.title}"`; const options: IChatSendRequestOptions = e.isSecondary ? diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts index 5eaf2de7198c9..b992275f26f58 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatConfirmationWidget.ts @@ -5,6 +5,7 @@ import * as dom from '../../../../../../base/browser/dom.js'; import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; +import { EventType as TouchEventType } from '../../../../../../base/browser/touch.js'; import { Button, ButtonWithDropdown, IButton, IButtonOptions } from '../../../../../../base/browser/ui/button/button.js'; import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; import { Action, Separator } from '../../../../../../base/common/actions.js'; @@ -38,6 +39,16 @@ export interface IChatConfirmationButton { moreActions?: (IChatConfirmationButton | Separator)[]; } +export interface IChatConfirmationButtonClickEvent { + readonly button: IChatConfirmationButton; + /** + * True when the click originated from a touch tap (vs. mouse/keyboard/programmatic). + * Callers that restore focus after confirmation (e.g. to the chat input) should + * skip that behavior when this is true to avoid popping the on-screen keyboard on mobile. + */ + readonly isTouchClick: boolean; +} + export interface IChatConfirmationWidgetOptions { title: string | IMarkdownString; message: string | IMarkdownString; @@ -107,8 +118,8 @@ export class ChatQueryTitlePart extends Disposable { } abstract class BaseSimpleChatConfirmationWidget extends Disposable { - private _onDidClick = this._register(new Emitter>()); - get onDidClick(): Event> { return this._onDidClick.event; } + private _onDidClick = this._register(new Emitter>()); + get onDidClick(): Event> { return this._onDidClick.event; } private _domNode: HTMLElement; get domNode(): HTMLElement { @@ -191,7 +202,7 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { undefined, !action.disabled, () => { - this._onDidClick.fire(action); + this._onDidClick.fire({ button: action, isTouchClick: false }); return Promise.resolve(); }, )); @@ -203,7 +214,7 @@ abstract class BaseSimpleChatConfirmationWidget extends Disposable { this._register(button); button.label = buttonData.label; - this._register(button.onDidClick(() => this._onDidClick.fire(buttonData))); + this._register(button.onDidClick(event => this._onDidClick.fire({ button: buttonData, isTouchClick: !!event && event.type === TouchEventType.Tap }))); if (buttonData.onDidChangeDisablement) { this._register(buttonData.onDidChangeDisablement(disabled => button.enabled = !disabled)); } @@ -277,8 +288,8 @@ export interface IChatConfirmationWidget2Options { } abstract class BaseChatConfirmationWidget extends Disposable { - private _onDidClick = this._register(new Emitter>()); - get onDidClick(): Event> { return this._onDidClick.event; } + private _onDidClick = this._register(new Emitter>()); + get onDidClick(): Event> { return this._onDidClick.event; } private _domNode: HTMLElement; get domNode(): HTMLElement { @@ -403,7 +414,7 @@ abstract class BaseChatConfirmationWidget extends Disposable { undefined, !action.disabled, () => { - this._onDidClick.fire(action); + this._onDidClick.fire({ button: action, isTouchClick: false }); return Promise.resolve(); }, )); @@ -415,7 +426,7 @@ abstract class BaseChatConfirmationWidget extends Disposable { this._register(button); button.label = buttonData.label; - this._register(button.onDidClick(() => this._onDidClick.fire(buttonData))); + this._register(button.onDidClick(event => this._onDidClick.fire({ button: buttonData, isTouchClick: !!event && event.type === TouchEventType.Tap }))); if (buttonData.onDidChangeDisablement) { this._register(buttonData.onDidChangeDisablement(disabled => button.enabled = !disabled)); } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts index 0018491307d4b..fcf0b1ce1ac80 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatElicitationContentPart.ts @@ -84,7 +84,7 @@ export class ChatElicitationContentPart extends Disposable implements IChatConte this._confirmWidget = confirmationWidget; confirmationWidget.setShowButtons(elicitation.kind === 'elicitation2' && elicitation.state.get() === ElicitationState.Pending); - this._register(confirmationWidget.onDidClick(async e => { + this._register(confirmationWidget.onDidClick(async ({ button: e }) => { if (elicitation.kind !== 'elicitation2') { return; } diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts index 823340ab98abd..e27cec0900017 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/abstractToolConfirmationSubPart.ts @@ -134,9 +134,11 @@ export abstract class AbstractToolConfirmationSubPart extends BaseChatToolInvoca const hasToolConfirmation = ChatContextKeys.Editing.hasToolConfirmation.bindTo(this.contextKeyService); hasToolConfirmation.set(true); - this._register(confirmWidget.onDidClick(button => { + this._register(confirmWidget.onDidClick(({ button, isTouchClick }) => { button.data(); - this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + if (!isTouchClick) { + this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + } })); this._register(toDisposable(() => hasToolConfirmation.reset())); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts index efbe69369f715..c718be52179b7 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatExtensionsInstallToolSubPart.ts @@ -90,9 +90,11 @@ export class ExtensionsInstallConfirmationWidgetSubPart extends BaseChatToolInvo )); this._confirmWidget = confirmWidget; dom.append(this.domNode, confirmWidget.domNode); - this._register(confirmWidget.onDidClick(button => { + this._register(confirmWidget.onDidClick(({ button, isTouchClick }) => { IChatToolInvocation.confirmWith(toolInvocation, button.data); - chatWidgetService.getWidgetBySessionResource(context.element.sessionResource)?.focusInput(); + if (!isTouchClick) { + chatWidgetService.getWidgetBySessionResource(context.element.sessionResource)?.focusInput(); + } })); const hasToolConfirmationKey = ChatContextKeys.Editing.hasToolConfirmation.bindTo(contextKeyService); hasToolConfirmationKey.set(true); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts index 7b84ddadeff67..d71f6508afe7a 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatModifiedFilesConfirmationSubPart.ts @@ -73,9 +73,11 @@ export class ChatModifiedFilesConfirmationSubPart extends AbstractToolConfirmati const hasToolConfirmation = ChatContextKeys.Editing.hasToolConfirmation.bindTo(this.contextKeyService); hasToolConfirmation.set(true); - this._register(confirmWidget.onDidClick(button => { + this._register(confirmWidget.onDidClick(({ button, isTouchClick }) => { button.data(); - this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + if (!isTouchClick) { + this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + } })); this._register(toDisposable(() => hasToolConfirmation.reset())); diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts index bd21a635f49d5..2081e6bdf3e8e 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolConfirmationSubPart.ts @@ -212,7 +212,7 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS hasToolConfirmationKey.set(true); this._register(toDisposable(() => hasToolConfirmationKey.reset())); - this._register(confirmWidget.onDidClick(async button => { + this._register(confirmWidget.onDidClick(async ({ button, isTouchClick }) => { let doComplete = true; const data = button.data; let toolConfirmKind: ToolConfirmKind = ToolConfirmKind.Denied; @@ -373,7 +373,9 @@ export class ChatTerminalToolConfirmationSubPart extends BaseChatToolInvocationS if (doComplete) { IChatToolInvocation.confirmWith(toolInvocation, { type: toolConfirmKind }); - this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + if (!isTouchClick) { + this.chatWidgetService.getWidgetBySessionResource(this.context.element.sessionResource)?.focusInput(); + } } })); From ea9b0f172d65cbd8ba530b95383c8ece1da43b76 Mon Sep 17 00:00:00 2001 From: Peng Lyu Date: Wed, 22 Apr 2026 20:38:59 -0700 Subject: [PATCH 33/35] less auto expand on mobile (#312067) --- src/vs/sessions/browser/workbench.ts | 1 + .../layout/browser/layoutController.ts | 83 ++++++++++--------- 2 files changed, 46 insertions(+), 38 deletions(-) diff --git a/src/vs/sessions/browser/workbench.ts b/src/vs/sessions/browser/workbench.ts index e487f96cfaa1e..b49b41647c83b 100644 --- a/src/vs/sessions/browser/workbench.ts +++ b/src/vs/sessions/browser/workbench.ts @@ -789,6 +789,7 @@ export class Workbench extends Disposable implements IAgentWorkbenchLayoutServic // regardless of how narrow the window is resized. if (isWeb && isMobile) { this.partVisibility.sidebar = false; + this.partVisibility.auxiliaryBar = false; } } diff --git a/src/vs/sessions/contrib/layout/browser/layoutController.ts b/src/vs/sessions/contrib/layout/browser/layoutController.ts index bcc77a9a60744..aebd27eaf17cc 100644 --- a/src/vs/sessions/contrib/layout/browser/layoutController.ts +++ b/src/vs/sessions/contrib/layout/browser/layoutController.ts @@ -7,6 +7,7 @@ import { autorun, derived, derivedOpts } from '../../../../base/common/observabl import { isEqual } from '../../../../base/common/resources.js'; import { Disposable } from '../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../base/common/map.js'; +import { isMobile, isWeb } from '../../../../base/common/platform.js'; import { URI } from '../../../../base/common/uri.js'; import { IChatService } from '../../../../workbench/contrib/chat/common/chatService/chatService.js'; import { IWorkbenchLayoutService, Parts } from '../../../../workbench/services/layout/browser/layoutService.js'; @@ -64,14 +65,17 @@ export class LayoutController extends Disposable { return activeSession?.workspace.read(reader)?.repositories?.[0]?.uri !== undefined; }); - // Switch between sessions — sync auxiliary bar - this._register(autorun(reader => { - const isUntitled = activeSessionIsUntitledObs.read(reader); - const activeSessionHasWorkspace = activeSessionHasWorkspaceObs.read(reader); - const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); + // Switch between sessions — sync auxiliary bar (skip on mobile to avoid + // disruptive auto-expand on narrow viewports) + if (!(isWeb && isMobile)) { + this._register(autorun(reader => { + const isUntitled = activeSessionIsUntitledObs.read(reader); + const activeSessionHasWorkspace = activeSessionHasWorkspaceObs.read(reader); + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); - this._syncAuxiliaryBarVisibility(activeSessionHasWorkspace, isUntitled, activeSessionHasChanges); - })); + this._syncAuxiliaryBarVisibility(activeSessionHasWorkspace, isUntitled, activeSessionHasChanges); + })); + } // Switch between sessions — sync panel visibility this._register(autorun(reader => { @@ -82,37 +86,40 @@ export class LayoutController extends Disposable { // When a turn is completed, check if there were changes before the turn and // if there are changes after the turn. If there were no changes before the // turn and there are changes after the turn, show the auxiliary bar. - this._register(autorun((reader) => { - const activeSession = this._sessionManagementService.activeSession.read(reader); - const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); - if (!activeSession) { - return; - } - - const pendingTurnState = this._pendingTurnStateByResource.get(activeSession.resource); - if (!pendingTurnState) { - return; - } - - const lastTurnEnd = activeSession.lastTurnEnd.read(reader); - const turnCompleted = !!lastTurnEnd && lastTurnEnd.getTime() >= pendingTurnState.submittedAt; - if (!turnCompleted) { - return; - } - - if (!pendingTurnState.hadChangesBeforeSend && activeSessionHasChanges) { - this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); - } - - this._pendingTurnStateByResource.delete(activeSession.resource); - })); - - this._register(this._chatService.onDidSubmitRequest(({ chatSessionResource }) => { - this._pendingTurnStateByResource.set(chatSessionResource, { - hadChangesBeforeSend: activeSessionHasChangesObs.get(), - submittedAt: Date.now(), - }); - })); + // Skip on mobile to avoid disruptive auto-expand on narrow viewports. + if (!(isWeb && isMobile)) { + this._register(autorun((reader) => { + const activeSession = this._sessionManagementService.activeSession.read(reader); + const activeSessionHasChanges = activeSessionHasChangesObs.read(reader); + if (!activeSession) { + return; + } + + const pendingTurnState = this._pendingTurnStateByResource.get(activeSession.resource); + if (!pendingTurnState) { + return; + } + + const lastTurnEnd = activeSession.lastTurnEnd.read(reader); + const turnCompleted = !!lastTurnEnd && lastTurnEnd.getTime() >= pendingTurnState.submittedAt; + if (!turnCompleted) { + return; + } + + if (!pendingTurnState.hadChangesBeforeSend && activeSessionHasChanges) { + this._layoutService.setPartHidden(false, Parts.AUXILIARYBAR_PART); + } + + this._pendingTurnStateByResource.delete(activeSession.resource); + })); + + this._register(this._chatService.onDidSubmitRequest(({ chatSessionResource }) => { + this._pendingTurnStateByResource.set(chatSessionResource, { + hadChangesBeforeSend: activeSessionHasChangesObs.get(), + submittedAt: Date.now(), + }); + })); + } // Track panel visibility changes by the user this._register(this._layoutService.onDidChangePartVisibility(e => { From f9eafcd6219d8ade3c5983b169d6bbe96b242e26 Mon Sep 17 00:00:00 2001 From: Rob Lourens Date: Wed, 22 Apr 2026 20:42:49 -0700 Subject: [PATCH 34/35] Show line range in agent host file-read tool display (#312062) * Show line range in agent host file-read tool display When the Copilot CLI's `view` tool is called with a `view_range`, surface the line range in the invocation and past-tense messages so users see e.g. "Reading file.ts, lines 10 to 20" instead of just "Reading file.ts". (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Match Copilot Chat extension's view_range validation Drop the EOF-sentinel handling and require a strictly valid two-element range (`length === 2`, integers, `start >= 0`, `end >= start`); fall back to the path-only display otherwise. Mirrors `formatViewToolInvocation` in the Copilot Chat extension and addresses review feedback on the `-1` end-of-file sentinel and the stale doc comment. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Handle view_range `-1` end-of-file sentinel The Copilot CLI uses `-1` as the documented "to end of file" sentinel for the second element of `view_range`. The Copilot Chat extension doesn't handle this and either drops the range or renders "-1" literally; do better here by rendering "from line {n} to the end". (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tweak EOF view_range wording (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../node/copilot/copilotToolDisplay.ts | 62 +++++++++++++++++-- .../test/node/copilotToolDisplay.test.ts | 51 ++++++++++++++- 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts index aaf96fe302948..b843dd74ac733 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotToolDisplay.ts @@ -68,6 +68,38 @@ interface ICopilotFileToolArgs { path: string; } +/** + * Parameters for the `view` tool. The Copilot CLI accepts an optional + * `view_range: [startLine, endLine]` (1-based, inclusive). `endLine` may be + * `-1` to mean "to end of file". + */ +interface ICopilotViewToolArgs extends ICopilotFileToolArgs { + view_range?: number[]; +} + +/** + * Normalizes a `view_range` array. Returns `undefined` unless the array has + * exactly two integer elements with `startLine >= 0`. `endLine === -1` is + * preserved as the "to end of file" sentinel; otherwise `endLine` must be + * `>= startLine`. + */ +function formatViewRange(view_range: number[] | undefined): { startLine: number; endLine: number } | undefined { + if (!Array.isArray(view_range) || view_range.length !== 2) { + return undefined; + } + const [startLine, endLine] = view_range; + if (!Number.isInteger(startLine) || !Number.isInteger(endLine)) { + return undefined; + } + if (startLine < 0) { + return undefined; + } + if (endLine !== -1 && endLine < startLine) { + return undefined; + } + return { startLine, endLine }; +} + /** Parameters for the `grep` tool. */ interface ICopilotGrepToolArgs { pattern: string; @@ -212,9 +244,20 @@ export function getInvocationMessage(toolName: string, displayName: string, para switch (toolName) { case CopilotToolName.View: { - const args = parameters as ICopilotFileToolArgs | undefined; + const args = parameters as ICopilotViewToolArgs | undefined; if (args?.path) { - return md(localize('toolInvoke.viewFile', "Reading {0}", formatPathAsMarkdownLink(args.path))); + const link = formatPathAsMarkdownLink(args.path); + const range = formatViewRange(args.view_range); + if (range) { + if (range.endLine === -1) { + return md(localize('toolInvoke.viewFileFromLine', "Reading {0}, line {1} to the end", link, range.startLine)); + } + if (range.endLine !== range.startLine) { + return md(localize('toolInvoke.viewFileRange', "Reading {0}, lines {1} to {2}", link, range.startLine, range.endLine)); + } + return md(localize('toolInvoke.viewFileLine', "Reading {0}, line {1}", link, range.startLine)); + } + return md(localize('toolInvoke.viewFile', "Reading {0}", link)); } return localize('toolInvoke.view', "Reading file"); } @@ -267,9 +310,20 @@ export function getPastTenseMessage(toolName: string, displayName: string, param switch (toolName) { case CopilotToolName.View: { - const args = parameters as ICopilotFileToolArgs | undefined; + const args = parameters as ICopilotViewToolArgs | undefined; if (args?.path) { - return md(localize('toolComplete.viewFile', "Read {0}", formatPathAsMarkdownLink(args.path))); + const link = formatPathAsMarkdownLink(args.path); + const range = formatViewRange(args.view_range); + if (range) { + if (range.endLine === -1) { + return md(localize('toolComplete.viewFileFromLine', "Read {0}, line {1} to the end", link, range.startLine)); + } + if (range.endLine !== range.startLine) { + return md(localize('toolComplete.viewFileRange', "Read {0}, lines {1} to {2}", link, range.startLine, range.endLine)); + } + return md(localize('toolComplete.viewFileLine', "Read {0}, line {1}", link, range.startLine)); + } + return md(localize('toolComplete.viewFile', "Read {0}", link)); } return localize('toolComplete.view', "Read file"); } diff --git a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts index 29981cd61fb7e..5aee3be444229 100644 --- a/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotToolDisplay.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { URI } from '../../../../base/common/uri.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; -import { getPermissionDisplay, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; +import { getInvocationMessage, getPastTenseMessage, getPermissionDisplay, type ITypedPermissionRequest } from '../../node/copilot/copilotToolDisplay.js'; suite('getPermissionDisplay — cd-prefix stripping', () => { @@ -75,3 +75,52 @@ suite('getPermissionDisplay — cd-prefix stripping', () => { assert.strictEqual(display.toolInput, 'dir'); }); }); + +suite('view tool — view_range display', () => { + + ensureNoDisposablesAreLeakedInTestSuite(); + + function invocation(parameters: Record | undefined): string { + const result = getInvocationMessage('view', 'View File', parameters); + return typeof result === 'string' ? result : result.markdown; + } + + function pastTense(parameters: Record | undefined): string { + const result = getPastTenseMessage('view', 'View File', parameters, true); + return typeof result === 'string' ? result : result.markdown; + } + + test('renders path-only when view_range is absent', () => { + assert.ok(invocation({ path: '/repo/file.ts' }).startsWith('Reading [')); + assert.ok(pastTense({ path: '/repo/file.ts' }).startsWith('Read [')); + }); + + test('renders "lines X to Y" for a valid two-element range', () => { + assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20')); + assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 20] }).endsWith(', lines 10 to 20')); + }); + + test('renders "line X" when start === end', () => { + assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10')); + assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, 10] }).endsWith(', line 10')); + }); + + test('renders "line X to the end" for the -1 EOF sentinel', () => { + assert.ok(invocation({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end')); + assert.ok(pastTense({ path: '/repo/file.ts', view_range: [10, -1] }).endsWith(', line 10 to the end')); + }); + + test('falls back to path-only for invalid ranges', () => { + // end < start (and not -1) + assert.ok(!invocation({ path: '/repo/file.ts', view_range: [20, 10] }).includes(',')); + // negative start + assert.ok(!invocation({ path: '/repo/file.ts', view_range: [-5, 10] }).includes(',')); + // non-integer + assert.ok(!invocation({ path: '/repo/file.ts', view_range: [1.5, 10] }).includes(',')); + // wrong arity + assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10] }).includes(',')); + assert.ok(!invocation({ path: '/repo/file.ts', view_range: [10, 20, 30] }).includes(',')); + // non-array + assert.ok(!invocation({ path: '/repo/file.ts', view_range: 'whatever' }).includes(',')); + }); +}); From 515c4fb946d9f61845d6a17270882885452f0ec3 Mon Sep 17 00:00:00 2001 From: Josh Spicer <23246594+joshspicer@users.noreply.github.com> Date: Wed, 22 Apr 2026 20:47:28 -0700 Subject: [PATCH 35/35] SSH agent host: add agent forwarding setting & fix encrypted key failures (#312013) * Skip fallback privateKey when SSH agent socket is present When using Agent auth, _connectSSH was loading the first default key file (~/.ssh/id_ed25519, etc.) as a fallback privateKey alongside the agent socket. ssh2 parses privateKey eagerly before attempting agent auth, so if the key is passphrase-encrypted the connection fails immediately with "Cannot parse privateKey: Encrypted private OpenSSH key detected, but no passphrase even though the key is already loaded in thegiven" agent and would work fine. Keep the fallback key logic for cases where no SSH agent is available (SSH_AUTH_SOCK unset), so publickey auth can still be attempted via the raw key file. But skip it when an agent socket is in that casepresent the agent should have the keys loaded, and passing an encrypted key file alongside the agent can only cause problems. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add chat.agentHost.forwardSSHAgent setting for SSH agent forwarding Add a new boolean setting that enables OpenSSH agent forwarding (auth-agent@openssh.com) on SSH agent host connections. When enabled and the connection uses Agent auth, sets agentForward=true in the ssh2 connect config so the remote machine can use the local SSH agent. - Add agentForward field to ISSHAgentHostConfig - Register chat.agentHost.forwardSSHAgent setting (default: false) - Read the setting in the renderer-side _augmentConfig - Apply agentForward in _connectSSH when agent socket is present Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Address code review comments - Add security warning to chat.agentHost.forwardSSHAgent setting description - Pass error object to warn() instead of stringifying it - Prompt for auth method when non-default IdentityFile is configured (so users without an SSH agent can still choose KeyFile) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Refactor _augmentConfig to use if statements instead of spread tricks Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Revert unnecessary changes to remoteAgentHostActions.ts The encrypted key fix is handled server-side in _connectSSH and reconnect. No need to change the UI connect flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/sshRemoteAgentHost.ts | 4 +- .../sshRemoteAgentHostServiceImpl.ts | 21 +++++++-- .../node/sshRemoteAgentHostService.ts | 34 ++++++++++---- .../node/sshRemoteAgentHostService.test.ts | 47 ++++++++++++------- .../browser/remoteAgentHost.contribution.ts | 7 +++ .../browser/remoteAgentHostActions.ts | 9 ++-- 6 files changed, 89 insertions(+), 33 deletions(-) diff --git a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts index 857e30a31220c..2a5f39d0775f5 100644 --- a/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts +++ b/src/vs/platform/agentHost/common/sshRemoteAgentHost.ts @@ -42,6 +42,8 @@ export interface ISSHAgentHostConfig { readonly sshConfigHost?: string; /** Dev override: custom command to start the remote agent host instead of the default CLI. */ readonly remoteAgentHostCommand?: string; + /** When true, enables OpenSSH agent forwarding (auth-agent@openssh.com) for this connection. Requires {@link authMethod} to be Agent. */ + readonly agentForward?: boolean; } /** @@ -208,5 +210,5 @@ export interface ISSHRemoteAgentHostMainService { * Resolves the SSH config alias, connects, and returns fresh * connection info with a new local forwarded port. */ - reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise; + reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise; } diff --git a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts index 644c7bf6f52fa..3b66d6418b155 100644 --- a/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts +++ b/src/vs/platform/agentHost/electron-browser/sshRemoteAgentHostServiceImpl.ts @@ -68,6 +68,7 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA this._onDidChangeConnections.fire(); } })); + } get connections(): readonly ISSHAgentHostConnection[] { @@ -75,8 +76,8 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA } async connect(config: ISSHAgentHostConfig): Promise { - this._logService.info('[SSHRemoteAgentHost] Connecting to ' + config.host); const augmentedConfig = this._augmentConfig(config); + this._logService.info(`[SSHRemoteAgentHost] Connecting to ${config.host}`); const result = await this._mainService.connect(augmentedConfig); this._logService.trace('[SSHRemoteAgentHost] SSH tunnel established, connectionId=' + result.connectionId); return this._setupConnection(result); @@ -96,7 +97,9 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA async reconnect(sshConfigHost: string, name: string): Promise { const commandOverride = this._getRemoteAgentHostCommand(); - const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride); + const agentForward = this._isSSHAgentForwardingEnabled(); + this._logService.info(`[SSHRemoteAgentHost] Reconnecting to ${sshConfigHost}`); + const result = await this._mainService.reconnect(sshConfigHost, name, commandOverride, agentForward); return this._setupConnection(result); } @@ -192,16 +195,26 @@ export class SSHRemoteAgentHostService extends Disposable implements ISSHRemoteA } private _augmentConfig(config: ISSHAgentHostConfig): ISSHAgentHostConfig { + const result = { ...config }; const commandOverride = this._getRemoteAgentHostCommand(); if (commandOverride) { - return { ...config, remoteAgentHostCommand: commandOverride }; + result.remoteAgentHostCommand = commandOverride; + } + // Agent forwarding requires both the global setting (security opt-in) + // and the per-host SSH config `ForwardAgent yes` to be enabled. + if (this._isSSHAgentForwardingEnabled() && config.agentForward) { + result.agentForward = true; } - return config; + return result; } private _getRemoteAgentHostCommand(): string | undefined { return this._configurationService.getValue('chat.sshRemoteAgentHostCommand') || undefined; } + + private _isSSHAgentForwardingEnabled(): boolean | undefined { + return this._configurationService.getValue('chat.agentHost.forwardSSHAgent') || undefined; + } } /** diff --git a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts index 04b1e94cd8dac..4435608777d59 100644 --- a/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts +++ b/src/vs/platform/agentHost/node/sshRemoteAgentHostService.ts @@ -445,7 +445,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem }; } - this._logService.info(`${LOG_PREFIX} Connecting to ${connectionKey}...`); + this._logService.info(`${LOG_PREFIX} ${replaceRelay ? 'Reconnecting' : 'Connecting'} to ${connectionKey}`); let sshClient: SSHClient | undefined; try { @@ -599,16 +599,21 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem } } - async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string): Promise { + async reconnect(sshConfigHost: string, name: string, remoteAgentHostCommand?: string, agentForward?: boolean): Promise { this._logService.info(`${LOG_PREFIX} Reconnecting via SSH config host: ${sshConfigHost}`); const resolved = await this.resolveSSHConfig(sshConfigHost); let authMethod: SSHAuthMethod = SSHAuthMethod.Agent; let privateKeyPath: string | undefined; - if (resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._defaultKeyPaths.includes(resolved.identityFile[0])) { + // Only fall back to KeyFile auth if there's no SSH agent available. + // When an agent is present the key should be loaded in it, and reading + // the key file directly will fail if it's passphrase-encrypted. + const hasAgent = !!process.env['SSH_AUTH_SOCK']; + if (!hasAgent && resolved.identityFile.length > 0 && !SSHRemoteAgentHostMainService._defaultKeyPaths.includes(resolved.identityFile[0])) { authMethod = SSHAuthMethod.KeyFile; privateKeyPath = resolved.identityFile[0]; } + this._logService.info(`${LOG_PREFIX} reconnect: hasAgent=${hasAgent}, identityFiles=${JSON.stringify(resolved.identityFile)}, chose authMethod=${authMethod}`); return this.connect({ host: resolved.hostname, @@ -619,6 +624,7 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem name, sshConfigHost, remoteAgentHostCommand, + agentForward: agentForward && resolved.forwardAgent ? true : undefined, }, /* replaceRelay */ true); } @@ -737,16 +743,21 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem const agentSock = process.env['SSH_AUTH_SOCK']; this._logService.info(`${LOG_PREFIX} Using SSH agent: ${agentSock ?? '(not set)'}`); connectConfig.agent = agentSock; - // Also provide a default key file as fallback so ssh2 can try - // publickey auth if the agent doesn't have the key loaded. - const fallbackKey = await this._findDefaultKeyFile(); - if (fallbackKey) { - this._logService.info(`${LOG_PREFIX} Also using fallback key: ${fallbackKey.path}`); - connectConfig.privateKey = fallbackKey.contents; + // Only load a fallback key file if no SSH agent is available. + // If an agent is present, skip this — ssh2 parses the private key + // eagerly and will fail immediately if the key is passphrase-encrypted, + // before ever trying the agent. + if (!agentSock) { + const fallbackKey = await this._findDefaultKeyFile(); + if (fallbackKey) { + this._logService.info(`${LOG_PREFIX} No SSH agent; using fallback key: ${fallbackKey.path}`); + connectConfig.privateKey = fallbackKey.contents; + } } break; } case SSHAuthMethod.KeyFile: + this._logService.info(`${LOG_PREFIX} Using key file: ${config.privateKeyPath ?? '(none)'}`); if (config.privateKeyPath) { const keyPath = config.privateKeyPath.replace(/^~/, os.homedir()); connectConfig.privateKey = await fsp.readFile(keyPath); @@ -770,6 +781,11 @@ export class SSHRemoteAgentHostMainService extends Disposable implements ISSHRem reject(err); }); + if (config.agentForward && connectConfig.agent) { + connectConfig.agentForward = true; + this._logService.info(`${LOG_PREFIX} SSH agent forwarding enabled`); + } + client.connect(connectConfig); }); } diff --git a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts index 1f46a94d9ff16..da729849056aa 100644 --- a/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/sshRemoteAgentHostService.test.ts @@ -1019,6 +1019,7 @@ class ConnectSSHTestService extends SSHRemoteAgentHostMainService { lastConnectConfig: Record | undefined; fallbackKeyResult: { path: string; contents: Buffer } | undefined; + agentSock: string | undefined = undefined; async testConnectSSH(config: ISSHAgentHostConfig) { return this._connectSSH(config); @@ -1035,10 +1036,12 @@ class ConnectSSHTestService extends SSHRemoteAgentHostMainService { switch (config.authMethod) { case SSHAuthMethod.Agent: { - connectConfig.agent = process.env['SSH_AUTH_SOCK']; - const fallbackKey = await this._findDefaultKeyFile(); - if (fallbackKey) { - connectConfig.privateKey = fallbackKey.contents; + connectConfig.agent = this.agentSock; + if (!this.agentSock) { + const fallbackKey = await this._findDefaultKeyFile(); + if (fallbackKey) { + connectConfig.privateKey = fallbackKey.contents; + } } break; } @@ -1050,6 +1053,10 @@ class ConnectSSHTestService extends SSHRemoteAgentHostMainService { break; } + if (config.agentForward && connectConfig.agent) { + connectConfig.agentForward = true; + } + this.lastConnectConfig = connectConfig; return new MockSSHClient() as never; } @@ -1081,33 +1088,41 @@ suite('SSHRemoteAgentHostMainService - _connectSSH auth config', () => { ensureNoDisposablesAreLeakedInTestSuite(); - test('agent auth includes fallback privateKey when default key exists', async () => { - const keyContents = Buffer.from('fake-key-contents'); - service.fallbackKeyResult = { path: '~/.ssh/id_ed25519', contents: keyContents }; + test('agent auth with agent present does not load fallback key', async () => { + service.agentSock = '/tmp/ssh-agent.sock'; + service.fallbackKeyResult = { path: '~/.ssh/id_ed25519', contents: Buffer.from('encrypted-key') }; await service.testConnectSSH(makeConfig({ authMethod: SSHAuthMethod.Agent })); assert.ok(service.lastConnectConfig, 'connectConfig should be captured'); assert.ok(Object.hasOwn(service.lastConnectConfig, 'agent'), 'should set agent'); - assert.strictEqual(service.lastConnectConfig.privateKey, keyContents, 'should include fallback privateKey'); + assert.strictEqual(service.lastConnectConfig.privateKey, undefined, 'should not load fallback key when agent is present'); }); - test('agent auth omits privateKey when no default key found', async () => { - service.fallbackKeyResult = undefined; + test('agent auth without agent socket loads fallback key', async () => { + service.agentSock = undefined; + const keyContents = Buffer.from('unencrypted-key'); + service.fallbackKeyResult = { path: '~/.ssh/id_ed25519', contents: keyContents }; await service.testConnectSSH(makeConfig({ authMethod: SSHAuthMethod.Agent })); assert.ok(service.lastConnectConfig, 'connectConfig should be captured'); - assert.ok(Object.hasOwn(service.lastConnectConfig, 'agent'), 'should set agent'); - assert.strictEqual(service.lastConnectConfig.privateKey, undefined, 'should not include privateKey'); + assert.strictEqual(service.lastConnectConfig.privateKey, keyContents, 'should load fallback key when no agent'); }); - test('keyfile auth does not use fallback key', async () => { - service.fallbackKeyResult = { path: '~/.ssh/id_ed25519', contents: Buffer.from('should-not-be-used') }; + test('agent auth with agentForward sets agentForward', async () => { + service.agentSock = '/tmp/ssh-agent.sock'; + await service.testConnectSSH(makeConfig({ authMethod: SSHAuthMethod.Agent, agentForward: true })); + + assert.ok(service.lastConnectConfig, 'connectConfig should be captured'); + assert.ok(Object.hasOwn(service.lastConnectConfig, 'agent'), 'should set agent'); + assert.strictEqual(service.lastConnectConfig.agentForward, true, 'should set agentForward'); + }); - await service.testConnectSSH(makeConfig({ authMethod: SSHAuthMethod.KeyFile })); + test('agentForward without agent auth is ignored', async () => { + await service.testConnectSSH(makeConfig({ authMethod: SSHAuthMethod.Password, password: 'pw', agentForward: true })); assert.ok(service.lastConnectConfig, 'connectConfig should be captured'); - assert.strictEqual(service.lastConnectConfig.privateKey, undefined, 'should not include fallback key for KeyFile auth'); + assert.strictEqual(service.lastConnectConfig.agentForward, undefined, 'should not set agentForward without agent'); }); }); diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts index ea2b851240d2d..ca7987600ead1 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -627,6 +627,13 @@ Registry.as(ConfigurationExtensions.Configuration).regis scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], }, + 'chat.agentHost.forwardSSHAgent': { + type: 'boolean', + description: nls.localize('chat.agentHost.forwardSSHAgent', "When enabled, forwards the local SSH agent to the remote machine during SSH agent host connections to hosts whose SSH config has `ForwardAgent yes`. Only enable this for trusted hosts. The remote agent host process must be restarted for this setting to take effect."), + default: false, + scope: ConfigurationScope.APPLICATION, + tags: ['experimental', 'advanced'], + }, [RemoteAgentHostsSettingId]: { type: 'array', items: { diff --git a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts index fa15661c08422..1ea5543af0345 100644 --- a/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts +++ b/src/vs/sessions/contrib/remoteAgentHost/browser/remoteAgentHostActions.ts @@ -151,16 +151,18 @@ async function promptToConnectViaSSH( port = resolvedConfig.port !== 22 ? resolvedConfig.port : undefined; suggestedName = picked.hostAlias; - // Determine auth method from resolved config + // Determine auth method from resolved config. + // Always prefer Agent auth (the SSH agent may already have the key + // loaded). Record a non-default IdentityFile as a fallback path for + // the manual picker only. if (resolvedConfig.identityFile.length > 0) { const firstKey = resolvedConfig.identityFile[0]; const defaultKeys = ['~/.ssh/id_rsa', '~/.ssh/id_ecdsa', '~/.ssh/id_ed25519', '~/.ssh/id_dsa', '~/.ssh/id_xmss']; if (!defaultKeys.includes(firstKey)) { - defaultAuthMethod = SSHAuthMethod.KeyFile; defaultKeyPath = firstKey; } } - // If no explicit key, default to SSH agent + // Default to SSH agent if (!defaultAuthMethod) { defaultAuthMethod = SSHAuthMethod.Agent; } @@ -173,6 +175,7 @@ async function promptToConnectViaSSH( username, authMethod: defaultAuthMethod, privateKeyPath: defaultKeyPath, + agentForward: resolvedConfig.forwardAgent || undefined, name: suggestedName, sshConfigHost: picked.hostAlias, };