From 00e3c8998e3b8e76cdb42a6e838b0e930ced9823 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 18 Aug 2026 21:14:46 -0700 Subject: [PATCH 01/28] sessions: connect to agent hosts through browser tunnels Move Dev Tunnels discovery and relay connections into the browser Agents window. - Builds a lazy browser-only Dev Tunnels ES module beside the sessions code. - Shares tunnel connection, gateway selection, and WebSocket-over-stream logic. - Restores the editor-versus-dedicated host choice and saves the preference. - Keeps desktop tunnel connections on the shared connector implementation. - Adds focused tests for tunnel selection, framing, cleanup, and browser discovery. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .eslint-allowed-javascript-files | 7 + build/gulpfile.vscode.web.ts | 39 +- build/next/devTunnelsShims/buffer.js | 6 + build/next/devTunnelsShims/bufferutil.cjs | 16 + build/next/devTunnelsShims/empty.cjs | 6 + build/next/devTunnelsShims/process.js | 14 + build/next/devTunnelsShims/utf8Validate.cjs | 15 + build/next/devTunnelsShims/vscodeJsonrpc.cjs | 13 + build/next/devTunnelsWeb.ts | 199 +++++ build/next/devTunnelsWebEntry.js | 19 + build/next/index.ts | 19 +- eslint.config.js | 16 +- package.json | 1 + .../agentHost/common/tunnelAgentHost.ts | 15 +- .../common/tunnelAgentHostConnector.ts | 486 ++++++++++++ .../common/tunnelGatewaySelection.ts | 197 +++++ .../agentHost/common/tunnelMessageSocket.ts | 87 +++ .../agentHost/common/webSocketOverDuplex.ts | 388 ++++++++++ .../agentHost/node/tunnelAgentHostService.ts | 713 ++++-------------- .../common/tunnelAgentHostConnector.test.ts | 272 +++++++ .../common/tunnelGatewaySelection.test.ts | 215 ++++++ .../test/common/webSocketOverDuplex.test.ts | 212 ++++++ .../test/node/tunnelAgentHostService.test.ts | 77 +- .../REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md | 4 +- .../browser/browserTunnelAgentHostService.ts | 517 +++++++++++++ .../browser/devTunnelsWebLoader.ts | 90 +++ .../browser/tunnelAgentHost.contribution.ts | 8 +- .../browser/tunnelAgentHostStorage.ts | 107 +++ .../webTunnelAgentHostService.contribution.ts | 116 ++- .../browser/webTunnelAgentHostService.ts | 108 +-- .../tunnelAgentHostServiceImpl.ts | 203 +---- .../browserTunnelAgentHostService.test.ts | 263 +++++++ .../tunnelAgentHost.contribution.test.ts | 41 +- .../tunnelAgentHostServiceImpl.test.ts | 179 ----- src/vs/sessions/sessions.web.main.ts | 3 + 35 files changed, 3625 insertions(+), 1046 deletions(-) create mode 100644 build/next/devTunnelsShims/buffer.js create mode 100644 build/next/devTunnelsShims/bufferutil.cjs create mode 100644 build/next/devTunnelsShims/empty.cjs create mode 100644 build/next/devTunnelsShims/process.js create mode 100644 build/next/devTunnelsShims/utf8Validate.cjs create mode 100644 build/next/devTunnelsShims/vscodeJsonrpc.cjs create mode 100644 build/next/devTunnelsWeb.ts create mode 100644 build/next/devTunnelsWebEntry.js create mode 100644 src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts create mode 100644 src/vs/platform/agentHost/common/tunnelGatewaySelection.ts create mode 100644 src/vs/platform/agentHost/common/tunnelMessageSocket.ts create mode 100644 src/vs/platform/agentHost/common/webSocketOverDuplex.ts create mode 100644 src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts create mode 100644 src/vs/platform/agentHost/test/common/tunnelGatewaySelection.test.ts create mode 100644 src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts create mode 100644 src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts create mode 100644 src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts create mode 100644 src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHostStorage.ts create mode 100644 src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts diff --git a/.eslint-allowed-javascript-files b/.eslint-allowed-javascript-files index aceacc2b96c940..4389715d321fdd 100644 --- a/.eslint-allowed-javascript-files +++ b/.eslint-allowed-javascript-files @@ -16,6 +16,13 @@ build/builtin/browser-main.js build/builtin/main.js build/npm/stubs/sharp/index.js build/codex/generate-protocol.mjs +build/next/devTunnelsWebEntry.js +build/next/devTunnelsShims/buffer.js +build/next/devTunnelsShims/bufferutil.cjs +build/next/devTunnelsShims/empty.cjs +build/next/devTunnelsShims/process.js +build/next/devTunnelsShims/utf8Validate.cjs +build/next/devTunnelsShims/vscodeJsonrpc.cjs eslint.config.js extensions/copilot/.mocha-multi-reporters.js extensions/copilot/.mocharc.js diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index 18b85c9142adc2..af8a4b40f37b6b 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -61,6 +61,34 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, sourceM }); } +function runDevTunnelsWebBundle(outDir: string, minify: boolean): Promise { + return new Promise((resolve, reject) => { + const scriptPath = path.join(REPO_ROOT, 'build/next/devTunnelsWeb.ts'); + const args = [ + scriptPath, + '--out', + path.join(outDir, 'vs', 'sessions', 'contrib', 'providers', 'remoteAgentHost', 'browser'), + ]; + if (minify) { + args.push('--minify'); + } + + const proc = cp.spawn(process.execPath, args, { + cwd: REPO_ROOT, + stdio: 'inherit' + }); + + proc.on('error', reject); + proc.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`Dev Tunnels web bundle failed with exit code ${code} (outDir: ${outDir}, minify: ${minify})`)); + } + }); + }); +} + export const vscodeWebResourceIncludes = [ // NLS @@ -171,8 +199,15 @@ task.task(minifyVSCodeWebTask); // esbuild-based tasks (new) const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; -const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', () => runEsbuildBundle('out-vscode-web', false, true)); -const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true, `${sourceMappingURLBase}/core`)); +const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', async () => { + await runEsbuildBundle('out-vscode-web', false, true); + // Required by the web-target invariant in build/next/index.ts. + await runDevTunnelsWebBundle('out-vscode-web', false); +}); +const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', async () => { + await runEsbuildBundle('out-vscode-web-min', true, true, `${sourceMappingURLBase}/core`); + await runDevTunnelsWebBundle('out-vscode-web-min', true); +}); function packageTask(sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); diff --git a/build/next/devTunnelsShims/buffer.js b/build/next/devTunnelsShims/buffer.js new file mode 100644 index 00000000000000..d271510fda2f49 --- /dev/null +++ b/build/next/devTunnelsShims/buffer.js @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export { Buffer } from 'buffer'; diff --git a/build/next/devTunnelsShims/bufferutil.cjs b/build/next/devTunnelsShims/bufferutil.cjs new file mode 100644 index 00000000000000..92adc2c81a6ce2 --- /dev/null +++ b/build/next/devTunnelsShims/bufferutil.cjs @@ -0,0 +1,16 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +exports.mask = function mask(source, mask, output, offset, length) { + for (let i = 0; i < length; i++) { + output[offset + i] = source[i] ^ mask[i & 3]; + } +}; + +exports.unmask = function unmask(buffer, mask) { + for (let i = 0; i < buffer.length; i++) { + buffer[i] ^= mask[i & 3]; + } +}; diff --git a/build/next/devTunnelsShims/empty.cjs b/build/next/devTunnelsShims/empty.cjs new file mode 100644 index 00000000000000..2ce23df6d607d6 --- /dev/null +++ b/build/next/devTunnelsShims/empty.cjs @@ -0,0 +1,6 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +module.exports = {}; diff --git a/build/next/devTunnelsShims/process.js b/build/next/devTunnelsShims/process.js new file mode 100644 index 00000000000000..5429fe1e6de785 --- /dev/null +++ b/build/next/devTunnelsShims/process.js @@ -0,0 +1,14 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * Minimal browser process shim for the Dev Tunnels dependency graph. + */ +export const process = { + env: Object.create(null), + nextTick(callback, ...args) { + queueMicrotask(() => callback(...args)); + }, +}; diff --git a/build/next/devTunnelsShims/utf8Validate.cjs b/build/next/devTunnelsShims/utf8Validate.cjs new file mode 100644 index 00000000000000..72acbbbaca3129 --- /dev/null +++ b/build/next/devTunnelsShims/utf8Validate.cjs @@ -0,0 +1,15 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const utf8Decoder = new TextDecoder('utf-8', { fatal: true }); + +module.exports = function isValidUTF8(buffer) { + try { + utf8Decoder.decode(buffer); + return true; + } catch { + return false; + } +}; diff --git a/build/next/devTunnelsShims/vscodeJsonrpc.cjs b/build/next/devTunnelsShims/vscodeJsonrpc.cjs new file mode 100644 index 00000000000000..beca4a9562501d --- /dev/null +++ b/build/next/devTunnelsShims/vscodeJsonrpc.cjs @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +const events = require('vscode-jsonrpc/lib/events'); +const cancellation = require('vscode-jsonrpc/lib/cancellation'); + +exports.Disposable = events.Disposable; +exports.Event = events.Event; +exports.Emitter = events.Emitter; +exports.CancellationToken = cancellation.CancellationToken; +exports.CancellationTokenSource = cancellation.CancellationTokenSource; diff --git a/build/next/devTunnelsWeb.ts b/build/next/devTunnelsWeb.ts new file mode 100644 index 00000000000000..d1f258a6df577c --- /dev/null +++ b/build/next/devTunnelsWeb.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 * as esbuild from 'esbuild'; +import * as fs from 'fs'; +import { createRequire } from 'module'; +import * as path from 'path'; + +const REPO_ROOT = path.dirname(path.dirname(import.meta.dirname)); +const ENTRY_POINT = path.join(REPO_ROOT, 'build', 'next', 'devTunnelsWebEntry.js'); +const SHIMS_ROOT = path.join(REPO_ROOT, 'build', 'next', 'devTunnelsShims'); +const NODE_MODULES_ROOT = path.join(REPO_ROOT, 'node_modules'); +const BUFFER_GLOBAL_SHIM = path.join(SHIMS_ROOT, 'buffer.js'); +const VSCODE_JSONRPC_SHIM = path.join(SHIMS_ROOT, 'vscodeJsonrpc.cjs'); +const SSH_ALGORITHMS_ROOT = path.join(REPO_ROOT, 'node_modules', '@microsoft', 'dev-tunnels-ssh', 'algorithms'); +const WEB_MODULE_OUT_DIR = path.join('vs', 'sessions', 'contrib', 'providers', 'remoteAgentHost', 'browser'); +const WEB_DEV_OUT_DIR = path.join('out', WEB_MODULE_OUT_DIR); +const nodeRequire = createRequire(import.meta.url); +const allowedImporterRoots = [ + path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-ssh'), + path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-ssh-tcp'), + path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-connections'), + path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-management'), + path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-contracts'), + path.join(NODE_MODULES_ROOT, 'websocket'), +]; +const nodeBuiltinNames = ['net', 'os', 'path', 'crypto', 'child_process', 'fs', 'http', 'https', 'tls', 'dns', 'zlib']; +const nodeBuiltinFilter = new RegExp(`^(?:node:)?(?:${nodeBuiltinNames.join('|')}|stream|buffer)$`); +const vscodeJsonrpcEventsPath = resolveDevTunnelsJsonRpcModule('events'); +const vscodeJsonrpcCancellationPath = resolveDevTunnelsJsonRpcModule('cancellation'); + +/** + * Builds the browser-only Dev Tunnels SDK bundle loaded through a dynamic ESM import. + */ +export async function bundleDevTunnelsWeb(options: { minify?: boolean; outDir: string }): Promise { + const outDir = path.resolve(REPO_ROOT, options.outDir); + const t1 = Date.now(); + await fs.promises.mkdir(outDir, { recursive: true }); + + console.log(`[dev-tunnels-web] ${path.relative(REPO_ROOT, ENTRY_POINT)} → ${path.relative(REPO_ROOT, outDir) || '.'}${options.minify ? ' (minify)' : ''}`); + await esbuild.build({ + entryPoints: [ENTRY_POINT], + bundle: true, + format: 'esm', + platform: 'browser', + target: ['es2024'], + define: { + // Some browser entry points in the SDK's dependency graph still use + // the Browserify-era `global` alias (for example randombytes). + global: 'globalThis', + }, + inject: [ + BUFFER_GLOBAL_SHIM, + path.join(SHIMS_ROOT, 'process.js'), + ], + minify: options.minify, + sourcemap: 'linked', + outfile: path.join(outDir, 'devTunnelsModule.js'), + plugins: [devTunnelsBrowserShimPlugin()], + logLevel: 'warning', + }); + console.log(`[dev-tunnels-web] Done in ${Date.now() - t1}ms`); +} + +/** + * Resolves Dev Tunnels' browser dependencies without changing unrelated bundle resolution. + */ +export function devTunnelsBrowserShimPlugin(): esbuild.Plugin { + return { + name: 'dev-tunnels-browser-shims', + setup(build) { + build.onResolve({ filter: nodeBuiltinFilter }, args => { + const specifier = args.path.startsWith('node:') ? args.path.slice('node:'.length) : args.path; + if (specifier === 'stream') { + // Stream changes the SDK transport; other Node modules only receive standard browser polyfills. + if (isAllowedImporter(args.importer)) { + return { path: path.join(NODE_MODULES_ROOT, 'readable-stream', 'readable-browser.js') }; + } + if (isNodeModulesImporter(args.importer)) { + return; + } + return nodeBuiltinError(args); + } + + if (!isNodeModulesImporter(args.importer) && args.importer !== BUFFER_GLOBAL_SHIM) { + return nodeBuiltinError(args); + } + + return specifier === 'buffer' + ? { path: path.join(NODE_MODULES_ROOT, 'buffer', 'index.js') } + : { path: path.join(SHIMS_ROOT, 'empty.cjs') }; + }); + + build.onResolve({ filter: /^node-rsa$/ }, args => { + if (!isAllowedImporter(args.importer)) { + return; + } + return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; + }); + + build.onResolve({ filter: /^\.[\\/]node[\\/]/ }, args => { + if (!isSshNodeAlgorithmImport(args)) { + return; + } + return { path: path.join(SHIMS_ROOT, 'empty.cjs') }; + }); + + build.onResolve({ filter: /^bufferutil$/ }, args => { + if (!isAllowedImporter(args.importer)) { + return; + } + return { path: path.join(SHIMS_ROOT, 'bufferutil.cjs') }; + }); + + build.onResolve({ filter: /^utf-8-validate$/ }, args => { + if (!isAllowedImporter(args.importer)) { + return; + } + return { path: path.join(SHIMS_ROOT, 'utf8Validate.cjs') }; + }); + + build.onResolve({ filter: /^vscode-jsonrpc$/ }, args => { + if (!isAllowedImporter(args.importer)) { + return; + } + return { path: path.join(SHIMS_ROOT, 'vscodeJsonrpc.cjs') }; + }); + + build.onResolve({ filter: /^vscode-jsonrpc\/lib\/(?:events|cancellation)$/ }, args => { + if (args.importer !== VSCODE_JSONRPC_SHIM) { + return; + } + return { + path: args.path.endsWith('/events') ? vscodeJsonrpcEventsPath : vscodeJsonrpcCancellationPath + }; + }); + } + }; +} + +function isAllowedImporter(importer: string): boolean { + if (importer === ENTRY_POINT) { + return true; + } + + return allowedImporterRoots.some(root => { + return isPathWithin(root, importer); + }); +} + +function isNodeModulesImporter(importer: string): boolean { + return isPathWithin(NODE_MODULES_ROOT, importer); +} + +function isSshNodeAlgorithmImport(args: esbuild.OnResolveArgs): boolean { + return isPathWithin(SSH_ALGORITHMS_ROOT, args.importer); +} + +function isPathWithin(root: string, filePath: string): boolean { + const relative = path.relative(root, filePath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function nodeBuiltinError(args: esbuild.OnResolveArgs): esbuild.OnResolveResult { + return { + errors: [{ + text: `Refusing to shim Node builtin '${args.path}' requested outside node_modules by '${args.importer || ''}'.` + }] + }; +} + +function resolveDevTunnelsJsonRpcModule(moduleName: 'events' | 'cancellation'): string { + return nodeRequire.resolve(`vscode-jsonrpc/lib/${moduleName}`, { + paths: [path.join(NODE_MODULES_ROOT, '@microsoft', 'dev-tunnels-ssh')] + }); +} + +function getArgValue(name: string): string | undefined { + const index = process.argv.indexOf(name); + return index !== -1 && index + 1 < process.argv.length ? process.argv[index + 1] : undefined; +} + +async function main(): Promise { + try { + await bundleDevTunnelsWeb({ + minify: process.argv.includes('--minify'), + outDir: getArgValue('--out') ?? WEB_DEV_OUT_DIR, + }); + } catch (error) { + console.error('Dev Tunnels web bundle failed:', error); + process.exit(1); + } +} + +if (import.meta.main) { + main(); +} diff --git a/build/next/devTunnelsWebEntry.js b/build/next/devTunnelsWebEntry.js new file mode 100644 index 00000000000000..604f2b82f969f2 --- /dev/null +++ b/build/next/devTunnelsWebEntry.js @@ -0,0 +1,19 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { TunnelManagementHttpClient, ManagementApiVersions } from '@microsoft/dev-tunnels-management'; +import { TunnelRelayTunnelClient } from '@microsoft/dev-tunnels-connections'; +import { TunnelAccessScopes } from '@microsoft/dev-tunnels-contracts'; +// The package root resolves to lib/browser.js, a native-WebSocket wrapper. This deep import provides +// RFC 6455 framing for an existing duplex stream and must not be replaced with the package root. +import WebSocketConnection from 'websocket/lib/WebSocketConnection'; + +export { + TunnelManagementHttpClient, + ManagementApiVersions, + TunnelRelayTunnelClient, + TunnelAccessScopes, + WebSocketConnection, +}; diff --git a/build/next/index.ts b/build/next/index.ts index 5f25aa9f7749e4..38bef410ad9d54 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -57,6 +57,10 @@ const options = { // Build targets type BuildTarget = 'desktop' | 'server' | 'server-web' | 'web'; +const buildTargets: readonly BuildTarget[] = ['desktop', 'server', 'server-web', 'web']; + +// The Dev Tunnels bundle is emitted only by the standalone web build tasks. +const devTunnelsWebBundleTargets: ReadonlySet = new Set(['web']); const SRC_DIR = 'src'; const OUT_DIR = 'out'; @@ -121,8 +125,9 @@ const webEntryPoints = [ ]; // Additional web-only entry points (CDN build only, not in server-web) +const sessionsWebEntryPoint = 'vs/sessions/sessions.web.main.internal'; const webOnlyEntryPoints = [ - 'vs/sessions/sessions.web.main.internal', + sessionsWebEntryPoint, ]; const keyboardMapEntryPoints = [ @@ -188,6 +193,18 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { } } +/** Ensures every Sessions web target emits the Dev Tunnels bundle. */ +function assertDevTunnelsWebBundleTargetInvariant(): void { + for (const target of buildTargets) { + const hasSessionsWebEntryPoint = getEntryPointsForTarget(target).includes(sessionsWebEntryPoint); + if (hasSessionsWebEntryPoint !== devTunnelsWebBundleTargets.has(target)) { + throw new Error(`The Dev Tunnels web bundle and '${sessionsWebEntryPoint}' must target the same builds (mismatch for '${target}').`); + } + } +} + +assertDevTunnelsWebBundleTargetInvariant(); + /** * Get bootstrap entry points for a build target. */ diff --git a/eslint.config.js b/eslint.config.js index 811c0a0303aced..432f521df94c08 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1686,7 +1686,19 @@ export default defineConfig( '@anthropic-ai/claude-agent-sdk', // used by agentHost for Claude Agent SDK session enumeration / queries '@modelcontextprotocol/sdk/**/*', // used by agentHost for Claude client-tool MCP result types (Phase 10) '@github/copilot-sdk', - 'zod' // used by agentHost for Claude client-tool MCP input schemas + 'zod', // used by agentHost for Claude client-tool MCP input schemas + { + 'when': 'test', + 'pattern': 'events' + }, + { + 'when': 'test', + 'pattern': 'module' + }, + { + 'when': 'test', + 'pattern': 'websocket' + } ] }, { @@ -2204,6 +2216,8 @@ export default defineConfig( 'vs/sessions/contrib/*/~', 'vs/sessions/contrib/providers/*/~', 'vs/sessions/services/*/~', + '@microsoft/dev-tunnels-connections', // type-only browser bundle conformance check + '@microsoft/dev-tunnels-management', // type-only browser bundle conformance check ] }, { diff --git a/package.json b/package.json index d19766a08bbd34..717ab54bb55bd8 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "watch-clientd": "deemon npm run watch-client", "kill-watch-clientd": "deemon --kill npm run watch-client", "transpile-client": "node build/next/index.ts transpile", + "bundle-dev-tunnels-web": "node build/next/devTunnelsWeb.ts", "watch-client-transpile": "node build/next/index.ts transpile --watch", "watch-client-transpiled": "deemon npm run watch-client-transpile", "kill-watch-client-transpiled": "deemon --kill npm run watch-client-transpile", diff --git a/src/vs/platform/agentHost/common/tunnelAgentHost.ts b/src/vs/platform/agentHost/common/tunnelAgentHost.ts index 5342dfee4e0dda..9032d122ba79e6 100644 --- a/src/vs/platform/agentHost/common/tunnelAgentHost.ts +++ b/src/vs/platform/agentHost/common/tunnelAgentHost.ts @@ -100,6 +100,9 @@ export interface ITunnelInfo { readonly hostConnectionCount: number; } +/** How startup auto-connect should establish a tunnel connection. */ +export type TunnelAutoConnectMode = 'background' | 'prompt'; + /** Kind of process that owns a gateway-reported endpoint. Mirrors `AgentHostServerType` in the CLI's agent-host registry (`cli/src/tunnels/agent_host_registry.rs`). */ export type TunnelGatewayServerType = 'editor' | 'standalone'; @@ -444,6 +447,12 @@ export interface ITunnelAgentHostService { */ listTunnels(options?: { silent?: boolean }): Promise; + /** + * Determine whether startup auto-connect can run silently or must first ask + * the user to choose an agent-host location. + */ + getAutoConnectMode(tunnel: ITunnelInfo): TunnelAutoConnectMode; + /** * Connect to a tunnel's agent host and register the connection * with {@link IRemoteAgentHostService}. @@ -452,9 +461,9 @@ export interface ITunnelAgentHostService { * @param authProvider Optional auth provider to use. If omitted, uses cached/last known. * @param options.userInitiated Whether this connection was explicitly * requested by the user (default `true`). When `false` (background/auto - * connect), a protocol-v6 gateway selection must never prompt via - * {@link IQuickInputService} and must never choose an `editor` endpoint — - * it deterministically reuses a standalone or spawns `newDedicated`. + * connect), a protocol-v6 gateway selection must never prompt. Background + * connections may prompt only when {@link getAutoConnectMode} returns + * `'prompt'`; otherwise they reuse the saved preference silently. */ connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft', options?: { readonly userInitiated?: boolean }): Promise; diff --git a/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts b/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts new file mode 100644 index 00000000000000..f8c6d2c4212052 --- /dev/null +++ b/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts @@ -0,0 +1,486 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { raceTimeout } from '../../../base/common/async.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; +import { generateUuid } from '../../../base/common/uuid.js'; +import { + createTunnelGatewaySelectionRejectedError, + parseTunnelGatewayInventory, + parseTunnelGatewaySelectionResponse, + TUNNEL_ADDRESS_PREFIX, + TUNNEL_AGENT_HOST_PORT, + TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION, + TUNNEL_GATEWAY_SELECT_PATH, + TUNNEL_MIN_PROTOCOL_VERSION, + TunnelTags, + type ITunnelConnectResult, + type ITunnelGatewaySelection, + type ITunnelGatewaySelectionSession, + type ITunnelInfo, + type ITunnelRelayMessage, +} from './tunnelAgentHost.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent } from './tunnelMessageSocket.js'; + +const LOG_PREFIX = '[TunnelAgentHost]'; +const BASE64_URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +/** + * Per-step timeout for dev-tunnel relay and socket operations. + */ +export const TUNNEL_STEP_TIMEOUT_MS = 30_000; + +/** + * A minimal tunnel descriptor that is independent of the dev-tunnels SDK. + */ +export interface ITunnelDescriptor { + readonly tunnelId?: string; + readonly clusterId?: string; + readonly name?: string; + readonly labels?: readonly string[]; + readonly status?: { + readonly hostConnectionCount?: number | { readonly current?: number }; + }; +} + +/** + * The relay client used to connect a resolved dev tunnel to its agent-host port. + */ +export interface ITunnelRelayClient extends IDisposable { + connect(): Promise; + waitForForwardedPort(port: number): Promise; + connectToForwardedPort(port: number): Promise; +} + +/** + * A resolved tunnel and its configured relay client. + */ +export interface ITunnelRelayClientSession { + readonly tunnel: ITunnelDescriptor; + createRelayClient(): Promise; +} + +/** + * Resolves a dev tunnel before its relay client is created. + */ +export interface ITunnelRelayClientFactory { + getTunnel(tunnelId: string, clusterId: string, authProvider: 'github' | 'microsoft', token: string): Promise; +} + +/** + * Opens a message socket over a tunnel relay stream. + */ +export interface ITunnelSocketFactory { + open(stream: ITunnelDuplexStream, path: string): Promise; +} + +/** + * Logging methods required by the connector. + */ +export interface ITunnelAgentHostConnectorLogService { + info(message: string): void; + warn(message: string): void; +} + +/** + * Runs an operation with a named deadline. + */ +export async function withTimeout( + op: () => Promise, + timeoutMs: number, + stepName: string, +): Promise { + let timedOut = false; + const result = await raceTimeout(op(), timeoutMs, () => { timedOut = true; }); + if (timedOut) { + throw new Error(`${LOG_PREFIX} ${stepName} timed out after ${timeoutMs}ms`); + } + return result as T; +} + +/** + * Derives the tunnel connection token used by the CLI and desktop clients. + */ +export async function deriveConnectionToken(tunnelId: string): Promise { + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(tunnelId))); + let result = ''; + + for (let index = 0; index < hash.length; index += 3) { + const first = hash[index]; + const second = hash[index + 1]; + const third = hash[index + 2]; + + result += BASE64_URL_ALPHABET[first >> 2]; + result += BASE64_URL_ALPHABET[(first & 0b00000011) << 4 | (second ?? 0) >> 4]; + if (second !== undefined) { + result += BASE64_URL_ALPHABET[(second & 0b00001111) << 2 | (third ?? 0) >> 6]; + } + if (third !== undefined) { + result += BASE64_URL_ALPHABET[third & 0b00111111]; + } + } + + return result.startsWith('-') ? `a${result}` : result; +} + +/** + * Maps a dev-tunnels descriptor to the tunnel information shared with clients. + */ +export function parseTunnelInfo(tunnel: ITunnelDescriptor): ITunnelInfo | undefined { + const labels = tunnel.labels ?? []; + const tags = new TunnelTags(labels); + if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) { + return undefined; + } + + const { tunnelId, clusterId } = tunnel; + if (!tunnelId || !clusterId) { + return undefined; + } + + const rawCount = tunnel.status?.hostConnectionCount; + return { + tunnelId, + clusterId, + name: tags.name || tunnel.name || tunnelId, + tags: labels, + protocolVersion: tags.protocolVersion, + hostConnectionCount: typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0), + }; +} + +/** + * A gateway selection that owns its socket and relay until completed or cancelled. + */ +export class PendingGatewaySelection implements IDisposable { + private _disposed = false; + private readonly _onSocketClosedListener: IDisposable; + + constructor( + readonly address: string, + readonly name: string, + readonly connectionToken: string, + readonly socket: ITunnelMessageSocket, + readonly relayClient: ITunnelRelayClient, + private readonly _onUnexpectedClose: () => void, + ) { + this._onSocketClosedListener = this.socket.onDidClose(() => { + if (!this._disposed) { + this._onUnexpectedClose(); + } + }); + } + + /** Transfers socket ownership to an active connection. */ + detach(): void { + this._onSocketClosedListener.dispose(); + } + + dispose(): void { + if (!this._disposed) { + this._disposed = true; + this._onSocketClosedListener.dispose(); + try { + this.socket.close(); + } catch { + // ignore — best-effort cleanup + } + try { + this.socket.dispose(); + } catch { + // ignore — best-effort cleanup + } + try { + this.relayClient.dispose(); + } catch { + // ignore — best-effort cleanup + } + } + } +} + +class TunnelConnection extends Disposable { + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose = this._onDidClose.event; + private _closed = false; + + constructor( + readonly connectionId: string, + readonly address: string, + readonly name: string, + readonly connectionToken: string, + private readonly _socket: ITunnelMessageSocket, + private readonly _relayClient: ITunnelRelayClient, + onMessage: (message: string) => void, + onSocketClose: (event: ITunnelSocketCloseEvent) => void, + ) { + super(); + this._register(this._socket.onDidReceiveMessage(onMessage)); + this._register(this._socket.onDidClose(event => { + onSocketClose(event); + this.dispose(); + })); + } + + relaySend(data: string): void { + this._socket.send(data); + } + + override dispose(): void { + if (!this._closed) { + this._closed = true; + this._socket.close(); + this._socket.dispose(); + this._relayClient.dispose(); + this._onDidClose.fire(); + } + super.dispose(); + } +} + +/** + * Coordinates dev-tunnel relay connection and gateway selection over injected transports. + */ +export class TunnelAgentHostConnector extends Disposable { + private readonly _onDidRelayMessage = this._register(new Emitter()); + readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; + + private readonly _onDidRelayClose = this._register(new Emitter()); + readonly onDidRelayClose: Event = this._onDidRelayClose.event; + + private readonly _connections = new Map(); + private readonly _pendingSelections = this._register(new DisposableMap()); + + constructor( + private readonly _relayClientFactory: ITunnelRelayClientFactory, + private readonly _socketFactory: ITunnelSocketFactory, + private readonly _logService: ITunnelAgentHostConnectorLogService, + ) { + super(); + } + + async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { + this.closeTunnelConnections(tunnelId, 'reconnecting'); + this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`); + + const session = await this._relayClientFactory.getTunnel(tunnelId, clusterId, authProvider, token); + if (!session) { + throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); + } + + const { tunnel } = session; + const relayClient = await session.createRelayClient(); + let portStream: ITunnelDuplexStream; + try { + await withTimeout(() => relayClient.connect(), TUNNEL_STEP_TIMEOUT_MS, 'tunnel relay connect'); + this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`); + await withTimeout(() => relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `wait for forwarded port ${TUNNEL_AGENT_HOST_PORT}`); + portStream = await withTimeout(() => relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `connect to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); + this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); + } catch (err) { + this._disposeRelayClient(relayClient); + throw err; + } + + const connectionToken = await deriveConnectionToken(tunnelId); + const name = new TunnelTags(tunnel.labels).name || tunnel.name || tunnelId; + const connectionId = generateUuid(); + let socket: ITunnelMessageSocket; + try { + socket = await withTimeout( + () => this._socketFactory.open(portStream, `/?tkn=${encodeURIComponent(connectionToken)}`), + TUNNEL_STEP_TIMEOUT_MS, + 'WebSocket relay open', + ); + this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`); + } catch (err) { + this._disposeRelayClient(relayClient); + throw err; + } + + this._createConnection(connectionId, `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`, name, connectionToken, socket, relayClient); + return { + connectionId, + address: `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`, + name, + connectionToken, + selected: { serverType: 'unknown', instanceId: '', role: 'primary', lifecycle: 'external' }, + }; + } + + async prepareSelection(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { + const session = await this._relayClientFactory.getTunnel(tunnelId, clusterId, authProvider, token); + if (!session) { + throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); + } + + const { tunnel } = session; + const tags = new TunnelTags(tunnel.labels); + if (tags.protocolVersion < TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION) { + return undefined; + } + + this._logService.info(`${LOG_PREFIX} Preparing gateway selection for tunnel ${tunnelId} in cluster ${clusterId}...`); + const relayClient = await session.createRelayClient(); + let socket: ITunnelMessageSocket; + try { + await withTimeout(() => relayClient.connect(), TUNNEL_STEP_TIMEOUT_MS, 'tunnel relay connect'); + await withTimeout(() => relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `wait for forwarded port ${TUNNEL_AGENT_HOST_PORT}`); + const portStream = await withTimeout(() => relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `connect to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); + socket = await withTimeout(() => this._socketFactory.open(portStream, TUNNEL_GATEWAY_SELECT_PATH), TUNNEL_STEP_TIMEOUT_MS, 'gateway selection WebSocket open'); + } catch (err) { + this._disposeRelayClient(relayClient); + throw err; + } + + let inventoryText: string; + try { + inventoryText = await withTimeout(() => this._readNextGatewayMessage(socket), TUNNEL_STEP_TIMEOUT_MS, 'gateway inventory message'); + } catch (err) { + this._disposeSocket(socket); + this._disposeRelayClient(relayClient); + throw err; + } + + const selectionId = generateUuid(); + this._pendingSelections.set(selectionId, new PendingGatewaySelection( + `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`, + tags.name || tunnel.name || tunnelId, + await deriveConnectionToken(tunnelId), + socket, + relayClient, + () => { + this._logService.warn(`${LOG_PREFIX} Gateway selection WebSocket for ${selectionId} closed before a selection was made`); + this._pendingSelections.deleteAndDispose(selectionId); + }, + )); + return { selectionId, inventory: parseTunnelGatewayInventory(inventoryText) }; + } + + async completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise { + const pending = this._pendingSelections.deleteAndLeak(selectionId); + if (!pending) { + throw new Error(`${LOG_PREFIX} No pending gateway selection with id ${selectionId}`); + } + pending.detach(); + + let responseText: string; + try { + pending.socket.send(JSON.stringify(selection)); + responseText = await withTimeout(() => this._readNextGatewayMessage(pending.socket), TUNNEL_STEP_TIMEOUT_MS, 'gateway selection acknowledgement'); + } catch (err) { + this._disposeSocket(pending.socket); + this._disposeRelayClient(pending.relayClient); + throw err; + } + + const response = parseTunnelGatewaySelectionResponse(responseText); + if (!response.ok) { + this._disposeSocket(pending.socket); + this._disposeRelayClient(pending.relayClient); + throw createTunnelGatewaySelectionRejectedError(`${LOG_PREFIX} ${response.error}`); + } + + const connectionId = generateUuid(); + this._createConnection(connectionId, pending.address, pending.name, pending.connectionToken, pending.socket, pending.relayClient); + this._logService.info(`${LOG_PREFIX} Gateway selection ${selectionId} completed: selected ${response.selected.serverType} ${response.selected.instanceId}`); + return { connectionId, address: pending.address, name: pending.name, connectionToken: pending.connectionToken, selected: response.selected }; + } + + async cancelSelection(selectionId: string): Promise { + this._pendingSelections.deleteAndDispose(selectionId); + } + + async relaySend(connectionId: string, message: string): Promise { + this._connections.get(connectionId)?.relaySend(message); + } + + async disconnect(connectionId: string): Promise { + this._connections.get(connectionId)?.dispose(); + } + + closeTunnelConnections(tunnelId: string, operation: 'deleting' | 'reconnecting'): void { + const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; + for (const [connectionId, connection] of this._connections) { + if (connection.address === address) { + this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before ${operation}`); + this._connections.delete(connectionId); + connection.dispose(); + } + } + } + + /** Registers a pending selection without creating a relay for unit tests. */ + setPendingGatewaySelectionForTests(selectionId: string, pending: PendingGatewaySelection): void { + this._pendingSelections.set(selectionId, pending); + } + + /** Removes and disposes a pending selection for unit tests. */ + deletePendingGatewaySelectionForTests(selectionId: string): void { + this._pendingSelections.deleteAndDispose(selectionId); + } + + private _createConnection(connectionId: string, address: string, name: string, connectionToken: string, socket: ITunnelMessageSocket, relayClient: ITunnelRelayClient): void { + const connection = new TunnelConnection( + connectionId, + address, + name, + connectionToken, + socket, + relayClient, + data => this._onDidRelayMessage.fire({ connectionId, data }), + event => this._logService.info(`${LOG_PREFIX} WebSocket relay closed for connection ${connectionId}; code=${event.code}, reason=${event.reason || '(empty)'}`), + ); + const onConnectionClose = connection.onDidClose(() => { + onConnectionClose.dispose(); + this._connections.delete(connectionId); + this._onDidRelayClose.fire(connectionId); + }); + this._connections.set(connectionId, connection); + } + + private _readNextGatewayMessage(socket: ITunnelMessageSocket): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => { + onMessage?.dispose(); + onClose?.dispose(); + }; + const onMessage = socket.onDidReceiveMessage(message => { + cleanup(); + resolve(message); + }); + const onClose = socket.onDidClose(event => { + cleanup(); + if (event.error) { + reject(event.error); + } else { + reject(new Error(`${LOG_PREFIX} Gateway WebSocket closed before expected message; code=${event.code}, reason=${event.reason || '(empty)'}`)); + } + }); + }); + } + + private _disposeSocket(socket: ITunnelMessageSocket): void { + try { + socket.close(); + } catch { + // ignore — best-effort cleanup + } + try { + socket.dispose(); + } catch { + // ignore — best-effort cleanup + } + } + + private _disposeRelayClient(relayClient: ITunnelRelayClient): void { + try { + relayClient.dispose(); + } catch { + // ignore — best-effort cleanup + } + } +} diff --git a/src/vs/platform/agentHost/common/tunnelGatewaySelection.ts b/src/vs/platform/agentHost/common/tunnelGatewaySelection.ts new file mode 100644 index 00000000000000..ae52add775513d --- /dev/null +++ b/src/vs/platform/agentHost/common/tunnelGatewaySelection.ts @@ -0,0 +1,197 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { hasKey } from '../../../base/common/types.js'; +import { type IDialogService } from '../../dialogs/common/dialogs.js'; +import { type IProductService } from '../../product/common/productService.js'; +import { type IRemoteAgentHostLocationPreferenceService } from './remoteAgentHostLocationPreference.js'; +import { promptRemoteAgentHostLocationPreference } from './remoteAgentHostLocationPreferenceDialog.js'; +import { type IRemoteAgentHostService } from './remoteAgentHostService.js'; +import { type ITunnelGatewayEndpoint, type ITunnelGatewayInventory, type ITunnelGatewaySelection, type TunnelGatewayServerType } from './tunnelAgentHost.js'; + +/** Endpoints of `type`, sorted deterministically by `instanceId`. */ +function sortedGatewayEndpoints(inventory: ITunnelGatewayInventory, type: TunnelGatewayServerType): ITunnelGatewayEndpoint[] { + return inventory.endpoints + .filter(endpoint => endpoint.type === type) + .sort((a, b) => a.instanceId.localeCompare(b.instanceId)); +} + +/** The live `editor` endpoint to use, chosen deterministically when several exist. */ +export function selectEditorGatewayEndpoint(inventory: ITunnelGatewayInventory): ITunnelGatewayEndpoint | undefined { + return sortedGatewayEndpoints(inventory, 'editor')[0]; +} + +/** + * Deterministic dedicated-agent-host selection: reuse the first live + * standalone instance if one exists, otherwise request a new dedicated one. + * + * Callers must not reach this on a delegated tunnel — {@link resolveGatewaySelection} + * short-circuits before any dedicated fallback, since a dedicated host behind + * an editor-bound tunnel would outlive the tunnel and be unreachable. + */ +export function selectDedicatedGatewayFallback(inventory: ITunnelGatewayInventory): ITunnelGatewaySelection { + const standalone = sortedGatewayEndpoints(inventory, 'standalone')[0]; + return standalone ? { instanceId: standalone.instanceId } : { newDedicated: true }; +} + +/** + * The selection to retry with after the gateway *rejected* `rejected` (see + * {@link isTunnelGatewaySelectionRejectedError}) — the tunnel is up and only + * the endpoint we asked for is gone, typically an `editor` endpoint whose + * agent host exited while its registry entry lingered. Picks a dedicated + * host exactly like {@link selectDedicatedGatewayFallback}, but never the + * instance that was just rejected. A delegated tunnel instead retries only + * its bound endpoint: it must never select or spawn a dedicated host. + * + * Returns `undefined` when there is nothing meaningful left to try: the + * rejected selection was itself a request for a brand new dedicated + * instance, so the gateway failed to *spawn* a host rather than failing to + * reach an existing one, and retrying would just fail the same way. + */ +export function selectGatewayFallbackAfterRejection(rejected: ITunnelGatewaySelection, inventory: ITunnelGatewayInventory): ITunnelGatewaySelection | undefined { + if (inventory.delegatedInstanceId) { + return { instanceId: inventory.delegatedInstanceId }; + } + if (!hasKey(rejected, { instanceId: true })) { + return undefined; + } + const standalone = sortedGatewayEndpoints(inventory, 'standalone').find(endpoint => endpoint.instanceId !== rejected.instanceId); + return standalone ? { instanceId: standalone.instanceId } : { newDedicated: true }; +} + +/** Inputs needed to resolve a protocol-v6 gateway endpoint selection. See {@link resolveGatewaySelection}. */ +export interface IGatewaySelectionRequest { + /** Stable {@link IRemoteAgentHostLocationPreferenceService} key, e.g. `tunnel:`. */ + readonly hostKey: string; + /** User-facing tunnel name shown in the location-preference modal. */ + readonly hostLabel: string; + /** Product name (typically {@link IProductService.nameShort}) substituted into the modal's editor-option detail text. */ + readonly productName: string; + readonly inventory: ITunnelGatewayInventory; + readonly userInitiated: boolean; +} + +/** + * Resolve which agent host endpoint to select for a protocol-v6 gateway + * session, driven by the user's saved {@link IRemoteAgentHostLocationPreferenceService} + * preference for the host rather than an endpoint picker: + * + * - A saved `'editor'` preference selects the live editor endpoint if one + * exists, or falls back to a dedicated endpoint (without changing the + * preference) if it doesn't — a stored editor preference is explicit + * consent, so this applies even for a background reconnect. + * - A saved `'dedicated'` preference always falls back to a dedicated + * endpoint and never prompts. + * - With no saved preference and no editor endpoint: selects dedicated and + * persists that only available location after a user-initiated connection. + * - With no saved preference and a live editor: background connections defer + * until the user connects manually; user-initiated connections prompt with + * {@link promptRemoteAgentHostLocationPreference} and persist the choice. + * + * Returns `undefined` only when the user cancels that modal. + */ +export async function resolveGatewaySelection( + locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + dialogService: IDialogService, + request: IGatewaySelectionRequest, +): Promise { + const { hostKey, hostLabel, productName, inventory, userInitiated } = request; + const preference = locationPreferenceService.getPreference(hostKey); + // A dedicated host behind an editor-bound tunnel would be orphaned when + // that editor exits, so this tunnel may only use its delegated endpoint. + if (inventory.delegatedInstanceId) { + if (!preference && userInitiated) { + locationPreferenceService.setPreference(hostKey, 'editor'); + } + return { instanceId: inventory.delegatedInstanceId }; + } + const editor = selectEditorGatewayEndpoint(inventory); + + if (preference === 'editor') { + return editor ? { instanceId: editor.instanceId } : selectDedicatedGatewayFallback(inventory); + } + if (preference === 'dedicated') { + return selectDedicatedGatewayFallback(inventory); + } + if (!editor) { + if (userInitiated) { + locationPreferenceService.setPreference(hostKey, 'dedicated'); + } + return selectDedicatedGatewayFallback(inventory); + } + if (!userInitiated) { + return undefined; + } + + const chosen = await promptRemoteAgentHostLocationPreference(dialogService, hostLabel, productName); + if (!chosen) { + return undefined; + } + locationPreferenceService.setPreference(hostKey, chosen); + return chosen === 'editor' ? { instanceId: editor.instanceId } : selectDedicatedGatewayFallback(inventory); +} + +/** + * Decide whether a tunnel-failover notification should be shown after a + * connection attempt's {@link IRemoteAgentHostService.addManagedConnection} + * has already succeeded. Fires in two cases, both of which mean the editor + * process that used to host the connection is gone and a dedicated agent + * host silently took its place: + * + * - `editorFallback`: this very attempt asked the gateway for a live-looking + * `editor` endpoint, was rejected because it is not actually reachable, + * and transparently retried against a dedicated host. The substitution + * happened inside a single connect, so there is no earlier registration to + * compare against — and it is equally surprising for a user-initiated + * connect, which explicitly asked for the editor host. A stale `editor` + * entry can linger in the remote registry for as long as its PID does, so + * every later reconnect repeats the same fallback; those must stay quiet + * once the address is already known to be on a `standalone` host, or the + * user would be notified again on every reconnect. + * - An automatic/background reconnect (never a user-initiated one) that + * moved a previously `editor`-owned endpoint to a `standalone` one for the + * same stable tunnel address. + * + * Exported so the decision can be unit tested without constructing the full + * service. + */ +export function shouldNotifyTunnelFailover( + previousServerType: TunnelGatewayServerType | 'unknown' | undefined, + newServerType: TunnelGatewayServerType | 'unknown', + userInitiated: boolean, + editorFallback = false, +): boolean { + if (editorFallback) { + return newServerType === 'standalone' && previousServerType !== 'standalone'; + } + return !userInitiated && previousServerType === 'editor' && newServerType === 'standalone'; +} + +/** + * Retains the last successfully registered endpoint's server type per + * stable tunnel address (`tunnel:`) so a later automatic + * reconnect for the same tunnel can detect a silent editor → standalone + * failover via {@link shouldNotifyTunnelFailover}. Entries are only ever + * written after a successful {@link IRemoteAgentHostService.addManagedConnection} + * registration and are deliberately never cleared on relay closure, so the + * comparison survives disconnect/reconnect cycles for the tunnel's + * lifetime. Exported (and kept free of any IPC/protocol dependencies) so + * the retention + decision behavior can be unit tested in isolation. + */ +export class TunnelFailoverTracker { + private readonly _lastSelectedServerType = new Map(); + + /** + * Record a successful registration for `address` and report whether it + * should trigger a failover notification. Always updates the retained + * metadata, regardless of the returned value. + */ + recordAndShouldNotify(address: string, newServerType: TunnelGatewayServerType | 'unknown', userInitiated: boolean, editorFallback = false): boolean { + const previousServerType = this._lastSelectedServerType.get(address); + const notify = shouldNotifyTunnelFailover(previousServerType, newServerType, userInitiated, editorFallback); + this._lastSelectedServerType.set(address, newServerType); + return notify; + } +} diff --git a/src/vs/platform/agentHost/common/tunnelMessageSocket.ts b/src/vs/platform/agentHost/common/tunnelMessageSocket.ts new file mode 100644 index 00000000000000..c733b75e3ad1c5 --- /dev/null +++ b/src/vs/platform/agentHost/common/tunnelMessageSocket.ts @@ -0,0 +1,87 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from '../../../base/common/event.js'; +import { IDisposable } from '../../../base/common/lifecycle.js'; + +/** A minimal bidirectional text-message socket over a tunnel byte stream. */ +export interface ITunnelMessageSocket extends IDisposable { + /** Send a text message. */ + send(data: string): void; + /** Fires for each complete text message received. */ + readonly onDidReceiveMessage: Event; + /** Fires once when the socket closes, for any reason. */ + readonly onDidClose: Event; + /** Initiate a clean close. */ + close(): void; +} + +/** Describes why a tunnel message socket closed. */ +export interface ITunnelSocketCloseEvent { + readonly code?: number; + readonly reason?: string; + readonly error?: Error; +} + +/** The subset of a tunnel relay duplex stream used to perform an HTTP upgrade. */ +export interface ITunnelDuplexStream { + readonly remoteAddress?: string; + on(event: 'data', listener: (chunk: Uint8Array) => void): void; + on(event: 'error', listener: (err: Error) => void): void; + on(event: 'close', listener: (hadError?: boolean) => void): void; + on(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; + removeListener(event: 'data', listener: (chunk: Uint8Array) => void): void; + removeListener(event: 'error', listener: (err: Error) => void): void; + removeListener(event: 'close', listener: (hadError?: boolean) => void): void; + removeListener(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; + removeAllListeners(event: 'error'): void; + write(chunk: Uint8Array | string): boolean; + end(): void; + destroy(): void; + pause(): void; + resume(): void; +} + +/** A socket-shaped view that supplies TCP methods expected by the framing implementation. */ +export interface IWebSocketDuplexStream extends ITunnelDuplexStream { + write(chunk: Uint8Array | string, callback?: (error?: Error) => void): boolean; + setNoDelay(enable: boolean): void; + setTimeout(timeout: number): void; + setKeepAlive(enable: boolean, initialDelay?: number): void; +} + +/** Configuration consumed by the bundled `WebSocketConnection` framing implementation. */ +export interface IWebSocketConnectionConfig { + readonly maxReceivedFrameSize: number; + readonly maxReceivedMessageSize: number; + readonly fragmentOutgoingMessages: boolean; + readonly fragmentationThreshold: number; + readonly webSocketVersion: 13; + readonly assembleFragments: boolean; + readonly disableNagleAlgorithm: boolean; + readonly closeTimeout: number; +} + +/** A message emitted by the bundled `WebSocketConnection` framing implementation. */ +export type WebSocketConnectionMessage = { readonly type: 'utf8'; readonly utf8Data: string } | { readonly type: 'binary'; readonly binaryData: Uint8Array }; + +/** The event-emitter surface used by the WebSocket-over-duplex adapter. */ +export interface IWebSocketConnection { + _addSocketEventListeners(): void; + handleSocketData(data: Uint8Array): void; + on(event: 'message', listener: (message: WebSocketConnectionMessage) => void): void; + on(event: 'close', listener: (code: number, reason: string) => void): void; + on(event: 'error', listener: (error: Error) => void): void; + removeListener(event: 'message', listener: (message: WebSocketConnectionMessage) => void): void; + removeListener(event: 'close', listener: (code: number, reason: string) => void): void; + removeListener(event: 'error', listener: (error: Error) => void): void; + send(data: string): void; + close(): void; +} + +/** Constructs the bundled `WebSocketConnection` framing implementation. */ +export interface WebSocketConnectionCtor { + new(stream: IWebSocketDuplexStream, extensions: [], protocol: string | null, maskOutgoingPackets: boolean, config: IWebSocketConnectionConfig): IWebSocketConnection; +} diff --git a/src/vs/platform/agentHost/common/webSocketOverDuplex.ts b/src/vs/platform/agentHost/common/webSocketOverDuplex.ts new file mode 100644 index 00000000000000..2c662f4378582a --- /dev/null +++ b/src/vs/platform/agentHost/common/webSocketOverDuplex.ts @@ -0,0 +1,388 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { encodeBase64, VSBuffer } from '../../../base/common/buffer.js'; +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent, IWebSocketConnection, IWebSocketConnectionConfig, IWebSocketDuplexStream, WebSocketConnectionCtor, WebSocketConnectionMessage } from './tunnelMessageSocket.js'; + +const websocketAcceptGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; +const headerTerminator = VSBuffer.fromString('\r\n\r\n').buffer; +const websocketConnectionConfig: IWebSocketConnectionConfig = { + maxReceivedFrameSize: 0x100000, + maxReceivedMessageSize: 0x800000, + fragmentOutgoingMessages: true, + fragmentationThreshold: 0x4000, + webSocketVersion: 13, + assembleFragments: true, + disableNagleAlgorithm: true, + closeTimeout: 5000, +}; + +/** Options used to establish a WebSocket connection over an existing tunnel stream. */ +export interface IWebSocketOverDuplexOptions { + /** Request path, e.g. '/agent-host/select' or '/?tkn=abc'. */ + readonly path: string; + /** Host header value; the tunnel stream is already pointed at the right port. */ + readonly host?: string; + /** Injected WebSocketConnection constructor from the lazily-loaded browser bundle. */ + readonly webSocketConnectionCtor: WebSocketConnectionCtor; +} + +/** Opens a framed WebSocket connection over an already-connected tunnel stream. */ +export async function connectWebSocketOverDuplex( + stream: ITunnelDuplexStream, + options: IWebSocketOverDuplexOptions, +): Promise { + validateRequestPath(options.path); + + const keyBytes = crypto.getRandomValues(new Uint8Array(16)); + const key = encodeBase64(VSBuffer.wrap(keyBytes)); + stream.write(createUpgradeRequest(options.path, options.host ?? 'localhost', key)); + + const responseReader = new UpgradeResponseReader(stream); + try { + const headerEnd = await responseReader.waitForHeaders(); + const response = parseUpgradeResponse(responseReader.bytes.slice(0, headerEnd)); + if (response.status !== 101) { + throw new Error(`WebSocket upgrade expected status 101 but received ${response.status}.`); + } + + const expectedAccept = await createWebSocketAccept(key); + if (!response.headers.get('sec-websocket-accept')) { + throw new Error('WebSocket upgrade response did not include a Sec-WebSocket-Accept header.'); + } + if (response.headers.get('sec-websocket-accept') !== expectedAccept) { + throw new Error('WebSocket upgrade response Sec-WebSocket-Accept header did not match the expected value.'); + } + if (responseReader.failure) { + throw responseReader.failure; + } + + responseReader.detach(); + const connection = new options.webSocketConnectionCtor(new WebSocketDuplexStreamAdapter(stream), [], null, true, websocketConnectionConfig); + const socket = new TunnelMessageSocket(stream, connection); + connection._addSocketEventListeners(); + for (const chunk of responseReader.remainingChunks(headerEnd)) { + connection.handleSocketData(chunk); + } + return socket; + } catch (error) { + responseReader.detach(); + stream.end(); + throw error; + } +} + +function validateRequestPath(path: string): void { + if (!path.startsWith('/') || path.includes('\r') || path.includes('\n')) { + throw new Error('WebSocket upgrade path must start with "/" and cannot contain line breaks.'); + } +} + +function createUpgradeRequest(path: string, host: string, key: string): string { + return [ + `GET ${path} HTTP/1.1`, + `Host: ${host}`, + 'Connection: Upgrade', + 'Upgrade: websocket', + 'Sec-WebSocket-Version: 13', + `Sec-WebSocket-Key: ${key}`, + '', + '', + ].join('\r\n'); +} + +/** Computes the RFC 6455 `Sec-WebSocket-Accept` value for a client key. */ +export async function createWebSocketAccept(key: string): Promise { + const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(key + websocketAcceptGuid)); + return encodeBase64(VSBuffer.wrap(new Uint8Array(digest))); +} + +/** Adapts a tunnel duplex stream to the TCP-like socket surface required by `WebSocketConnection`. */ +class WebSocketDuplexStreamAdapter implements IWebSocketDuplexStream { + private _ended = false; + private _destroyed = false; + + constructor(private readonly _stream: ITunnelDuplexStream) { + } + + get remoteAddress(): string | undefined { + return this._stream.remoteAddress; + } + + on(event: 'data', listener: (chunk: Uint8Array) => void): void; + on(event: 'error', listener: (err: Error) => void): void; + on(event: 'close', listener: (hadError?: boolean) => void): void; + on(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; + on(event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', listener: ((chunk: Uint8Array) => void) | ((err: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { + switch (event) { + case 'data': + this._stream.on(event, listener as (chunk: Uint8Array) => void); + break; + case 'error': + this._stream.on(event, listener as (err: Error) => void); + break; + case 'close': + this._stream.on(event, listener as (hadError?: boolean) => void); + break; + default: + this._stream.on(event, listener as () => void); + } + } + + removeListener(event: 'data', listener: (chunk: Uint8Array) => void): void; + removeListener(event: 'error', listener: (err: Error) => void): void; + removeListener(event: 'close', listener: (hadError?: boolean) => void): void; + removeListener(event: 'end' | 'drain' | 'pause' | 'resume', listener: () => void): void; + removeListener(event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', listener: ((chunk: Uint8Array) => void) | ((err: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { + switch (event) { + case 'data': + this._stream.removeListener(event, listener as (chunk: Uint8Array) => void); + break; + case 'error': + this._stream.removeListener(event, listener as (err: Error) => void); + break; + case 'close': + this._stream.removeListener(event, listener as (hadError?: boolean) => void); + break; + default: + this._stream.removeListener(event, listener as () => void); + } + } + + removeAllListeners(event: 'error'): void { + this._stream.removeAllListeners(event); + } + + write(chunk: Uint8Array | string, callback?: (error?: Error) => void): boolean { + const written = this._stream.write(chunk); + callback?.(); + return written; + } + + end(): void { + if (!this._ended) { + this._ended = true; + this._stream.end(); + } + } + + destroy(): void { + if (!this._destroyed) { + this._destroyed = true; + this._stream.destroy(); + } + } + + pause(): void { + this._stream.pause(); + } + + resume(): void { + this._stream.resume(); + } + + setNoDelay(_enable: boolean): void { + } + + setTimeout(_timeout: number): void { + } + + setKeepAlive(_enable: boolean, _initialDelay?: number): void { + } +} + +/** A parsed HTTP WebSocket upgrade response. */ +interface IUpgradeResponse { + readonly status: number; + readonly headers: Map; +} + +function parseUpgradeResponse(headerBytes: Uint8Array): IUpgradeResponse { + const lines = VSBuffer.wrap(headerBytes).toString().split('\r\n'); + const statusMatch = /^HTTP\/\d\.\d\s+(\d{3})(?:\s|$)/.exec(lines[0]); + if (!statusMatch) { + throw new Error('WebSocket upgrade response did not contain a valid HTTP status line.'); + } + + const headers = new Map(); + for (const line of lines.slice(1)) { + if (!line) { + continue; + } + const separator = line.indexOf(':'); + if (separator <= 0) { + throw new Error(`WebSocket upgrade response contained an invalid header: ${line}`); + } + headers.set(line.slice(0, separator).trim().toLowerCase(), line.slice(separator + 1).trim()); + } + return { status: Number(statusMatch[1]), headers }; +} + +/** Buffers an HTTP upgrade response while preserving original frame chunks. */ +class UpgradeResponseReader { + private _bytes = new Uint8Array(0); + private readonly _chunks: Uint8Array[] = []; + private _headerEnd: number | undefined; + private _settled = false; + private _failure: Error | undefined; + private readonly _onData = (chunk: Uint8Array) => this.acceptData(chunk); + private readonly _onError = (error: Error) => this.reject(error); + private readonly _onEnd = () => this.reject(new Error('Tunnel stream ended before the WebSocket upgrade response was received.')); + private readonly _onClose = () => this.reject(new Error('Tunnel stream closed before the WebSocket upgrade response was received.')); + private _resolve: ((headerEnd: number) => void) | undefined; + private _reject: ((error: Error) => void) | undefined; + + constructor(private readonly _stream: ITunnelDuplexStream) { + this._stream.on('data', this._onData); + this._stream.on('error', this._onError); + this._stream.on('end', this._onEnd); + this._stream.on('close', this._onClose); + } + + get bytes(): Uint8Array { + return this._bytes; + } + + get failure(): Error | undefined { + return this._failure; + } + + waitForHeaders(): Promise { + return new Promise((resolve, reject) => { + this._resolve = resolve; + this._reject = reject; + if (this._headerEnd !== undefined) { + resolve(this._headerEnd); + } + }); + } + + detach(): void { + this._stream.removeListener('data', this._onData); + this._stream.removeListener('error', this._onError); + this._stream.removeListener('end', this._onEnd); + this._stream.removeListener('close', this._onClose); + } + + remainingChunks(offset: number): Uint8Array[] { + const remainingChunks: Uint8Array[] = []; + for (const chunk of this._chunks) { + if (offset >= chunk.byteLength) { + offset -= chunk.byteLength; + } else { + remainingChunks.push(chunk.slice(offset)); + offset = 0; + } + } + return remainingChunks; + } + + private acceptData(chunk: Uint8Array): void { + this._chunks.push(chunk); + const bytes = new Uint8Array(this._bytes.byteLength + chunk.byteLength); + bytes.set(this._bytes); + bytes.set(chunk, this._bytes.byteLength); + this._bytes = bytes; + + if (this._headerEnd === undefined) { + const terminatorOffset = findSequence(this._bytes, headerTerminator); + if (terminatorOffset !== -1) { + this._headerEnd = terminatorOffset + headerTerminator.byteLength; + this._settled = true; + this._resolve?.(this._headerEnd); + } + } + } + + private reject(error: Error): void { + this._failure = error; + if (!this._settled) { + this._settled = true; + this._reject?.(error); + } + } +} + +function findSequence(bytes: Uint8Array, sequence: Uint8Array): number { + for (let offset = 0; offset <= bytes.byteLength - sequence.byteLength; offset++) { + let matches = true; + for (let index = 0; index < sequence.byteLength; index++) { + if (bytes[offset + index] !== sequence[index]) { + matches = false; + break; + } + } + if (matches) { + return offset; + } + } + return -1; +} + +/** Adapts the bundled WebSocket framing implementation to the tunnel socket contract. */ +class TunnelMessageSocket extends Disposable implements ITunnelMessageSocket { + private readonly _onDidReceiveMessage = this._register(new Emitter({ + onDidAddFirstListener: () => this.flushPendingMessages(), + })); + readonly onDidReceiveMessage: Event = this._onDidReceiveMessage.event; + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose: Event = this._onDidClose.event; + private readonly _pendingMessages: string[] = []; + private _closed = false; + + constructor( + private readonly _stream: ITunnelDuplexStream, + private readonly _connection: IWebSocketConnection, + ) { + super(); + const onMessage = (message: WebSocketConnectionMessage) => this.acceptMessage(message); + const onClose = (code: number, reason: string) => this.finishClose({ code, reason }); + const onError = (error: Error) => this.finishClose({ error }); + this._connection.on('message', onMessage); + this._connection.on('close', onClose); + this._connection.on('error', onError); + this._register(toDisposable(() => this._connection.removeListener('message', onMessage))); + this._register(toDisposable(() => this._connection.removeListener('close', onClose))); + this._register(toDisposable(() => this._connection.removeListener('error', onError))); + } + + send(data: string): void { + this._connection.send(data); + } + + close(): void { + this._connection.close(); + } + + override dispose(): void { + this._connection.close(); + this._stream.destroy(); + super.dispose(); + } + + private acceptMessage(message: WebSocketConnectionMessage): void { + const data = message.type === 'utf8' ? message.utf8Data : new TextDecoder().decode(message.binaryData); + if (this._onDidReceiveMessage.hasListeners()) { + this._onDidReceiveMessage.fire(data); + } else { + this._pendingMessages.push(data); + } + } + + private flushPendingMessages(): void { + while (this._pendingMessages.length > 0) { + this._onDidReceiveMessage.fire(this._pendingMessages.shift()!); + } + } + + private finishClose(event: ITunnelSocketCloseEvent): void { + if (!this._closed) { + this._closed = true; + this._onDidClose.fire(event); + } + } +} diff --git a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts index 3ae1cfcfbf3246..86b96ce40607ed 100644 --- a/src/vs/platform/agentHost/node/tunnelAgentHostService.ts +++ b/src/vs/platform/agentHost/node/tunnelAgentHostService.ts @@ -5,185 +5,186 @@ import type { Tunnel } from '@microsoft/dev-tunnels-contracts'; import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; -import { createHash } from 'crypto'; import type WebSocket from 'ws'; import { Emitter, Event } from '../../../base/common/event.js'; -import { Disposable, DisposableMap, IDisposable } from '../../../base/common/lifecycle.js'; -import { raceTimeout } from '../../../base/common/async.js'; -import { generateUuid } from '../../../base/common/uuid.js'; +import { Disposable, toDisposable } from '../../../base/common/lifecycle.js'; import { ILogService } from '../../log/common/log.js'; import { - createTunnelGatewaySelectionRejectedError, + PendingGatewaySelection, + TunnelAgentHostConnector, + parseTunnelInfo, + type ITunnelRelayClient, + type ITunnelRelayClientFactory, + type ITunnelRelayClientSession, + type ITunnelSocketFactory, +} from '../common/tunnelAgentHostConnector.js'; +import { ITunnelAgentHostMainService, - parseTunnelGatewayInventory, - parseTunnelGatewaySelectionResponse, - TUNNEL_ADDRESS_PREFIX, TUNNEL_AGENT_HOST_PORT, - TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION, - TUNNEL_GATEWAY_SELECT_PATH, TUNNEL_LAUNCHER_LABEL, TUNNEL_MIN_PROTOCOL_VERSION, - TunnelTags, type ITunnelConnectResult, type ITunnelGatewaySelection, type ITunnelGatewaySelectionSession, type ITunnelInfo, type ITunnelRelayMessage, } from '../common/tunnelAgentHost.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent } from '../common/tunnelMessageSocket.js'; const LOG_PREFIX = '[TunnelAgentHost]'; -/** - * Per-step timeout for the dev-tunnels SDK calls inside {@link TunnelAgentHostMainService.connect}. - * - * Without this, a silently dropped network (TCP half-open, host gone but relay still - * accepting our messages) can leave `relayClient.connect()`, - * `waitForForwardedPort()`, `connectToForwardedPort()`, or the WebSocket `'open'` - * event pending forever — which in turn hangs the renderer's - * `_tunnelService.connect(...)` await, leaving the per-host `_pendingConnects` - * flag set and effectively disabling auto-reconnect for the lifetime of the - * shared process. - */ -export const TUNNEL_STEP_TIMEOUT_MS = 30_000; - -export async function withTimeout( - op: () => Promise, - timeoutMs: number, - stepName: string, -): Promise { - // Use raceTimeout so the timer is cleared in `finally` once `op` settles - // (avoids stray timers across frequent reconnect attempts). The void-return - // disambiguation is handled by the onTimeout callback flag below. - let timedOut = false; - const result = await raceTimeout(op(), timeoutMs, () => { timedOut = true; }); - if (timedOut) { - throw new Error(`${LOG_PREFIX} ${stepName} timed out after ${timeoutMs}ms`); - } - return result as T; +export { PendingGatewaySelection, TUNNEL_STEP_TIMEOUT_MS, withTimeout } from '../common/tunnelAgentHostConnector.js'; + +interface INodeTunnelRelayClient { + acceptLocalConnectionsForForwardedPorts: boolean; + endpoints?: Tunnel['endpoints']; + connect(tunnel: Tunnel): Promise; + waitForForwardedPort(port: number): Promise; + connectToForwardedPort(port: number): Promise; + dispose(): void; } -/** - * Derive a connection token from a tunnel ID using the same convention - * as the VS Code CLI (see `get_connection_token` in cli/src/commands/tunnels.rs). - */ -function deriveConnectionToken(tunnelId: string): string { - const hash = createHash('sha256'); - hash.update(tunnelId); - let result = hash.digest('base64url'); - if (result.startsWith('-')) { - result = `a${result}`; +class NodeTunnelRelayClient implements ITunnelRelayClient { + constructor( + private readonly _relayClient: INodeTunnelRelayClient, + private readonly _tunnel: Tunnel, + ) { } - return result; -} -function rawGatewayDataToString(data: WebSocket.RawData): string { - if (Array.isArray(data)) { - return Buffer.concat(data).toString(); - } else if (data instanceof ArrayBuffer) { - return Buffer.from(new Uint8Array(data)).toString(); + connect(): Promise { + return this._relayClient.connect(this._tunnel); } - return data.toString(); -} -/** State for a single active tunnel relay connection. */ -class TunnelConnection extends Disposable { - private readonly _onDidClose = this._register(new Emitter()); - readonly onDidClose = this._onDidClose.event; + waitForForwardedPort(port: number): Promise { + return this._relayClient.waitForForwardedPort(port); + } - private _closed = false; + async connectToForwardedPort(port: number): Promise { + return await this._relayClient.connectToForwardedPort(port) as unknown as ITunnelDuplexStream; + } + dispose(): void { + this._relayClient.dispose(); + } +} + +class NodeTunnelRelayClientFactory implements ITunnelRelayClientFactory { constructor( - readonly connectionId: string, - readonly address: string, - readonly name: string, - readonly connectionToken: string, - private readonly _relay: { send: (data: string) => void; close: () => void }, - private readonly _relayClient: { dispose(): void }, + private readonly _createManagementClient: (token: string, authProvider: 'github' | 'microsoft') => Promise, ) { - super(); } - override dispose(): void { - if (!this._closed) { - this._closed = true; - this._relay.close(); - this._relayClient.dispose(); - this._onDidClose.fire(); + async getTunnel(tunnelId: string, clusterId: string, authProvider: 'github' | 'microsoft', token: string): Promise { + const managementClient = await this._createManagementClient(token, authProvider); + const resolved = await managementClient.getTunnel({ tunnelId, clusterId }, { + includePorts: true, + tokenScopes: ['connect'], + }); + if (!resolved) { + return undefined; } - super.dispose(); - } - relaySend(data: string): void { - this._relay.send(data); + return { + tunnel: resolved, + createRelayClient: async () => { + const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections'); + const relayClient = new TunnelRelayTunnelClient(managementClient) as INodeTunnelRelayClient; + relayClient.acceptLocalConnectionsForForwardedPorts = false; + if (resolved.endpoints) { + relayClient.endpoints = resolved.endpoints; + } + return new NodeTunnelRelayClient(relayClient, resolved); + }, + }; } } -/** - * A protocol-v6 gateway selection that has been prepared (relay connected, - * selection WebSocket open, inventory received) but not yet completed. Owns - * the gateway WebSocket and relay client until either - * {@link TunnelAgentHostMainService.completeSelection} takes over ownership - * via {@link detach}, or this is disposed (cancellation, or the socket - * closing unexpectedly before a selection was made). - */ -export class PendingGatewaySelection implements IDisposable { - private _disposed = false; - private readonly _onSocketClosed = () => { - if (!this._disposed) { - this._onUnexpectedClose(); +class NodeTunnelMessageSocket extends Disposable implements ITunnelMessageSocket { + private readonly _onDidReceiveMessage = this._register(new Emitter()); + readonly onDidReceiveMessage: Event = this._onDidReceiveMessage.event; + + private readonly _onDidClose = this._register(new Emitter()); + readonly onDidClose: Event = this._onDidClose.event; + + constructor(private readonly _socket: WebSocket) { + super(); + const onMessage = (data: WebSocket.RawData) => this._onDidReceiveMessage.fire(rawGatewayDataToString(data)); + const onClose = (code: number, reason: Buffer) => this._onDidClose.fire({ code, reason: reason?.toString() || undefined }); + const onError = (error: Error) => this._onDidClose.fire({ error }); + this._socket.on('message', onMessage); + this._socket.on('close', onClose); + this._socket.on('error', onError); + this._register(toDisposable(() => { + this._socket.off('message', onMessage); + this._socket.off('close', onClose); + this._socket.off('error', onError); + })); + } + + send(data: string): void { + if (this._socket.readyState === this._socket.OPEN) { + this._socket.send(data); } - }; + } - constructor( - readonly address: string, - readonly name: string, - readonly connectionToken: string, - readonly ws: WebSocket, - readonly relayClient: { dispose(): void }, - private readonly _onUnexpectedClose: () => void, - ) { - this.ws.once('close', this._onSocketClosed); + close(): void { + this._socket.close(); } - /** Detach the auto-cleanup listener so ownership of the socket can transfer to a live {@link TunnelConnection}. */ - detach(): void { - this.ws.off('close', this._onSocketClosed); + override dispose(): void { + super.dispose(); } +} - dispose(): void { - if (!this._disposed) { - this._disposed = true; - this.ws.off('close', this._onSocketClosed); - try { - this.ws.close(); - } catch { - // ignore — best-effort cleanup - } - try { - this.relayClient.dispose(); - } catch { - // ignore — best-effort cleanup - } - } +class NodeTunnelSocketFactory implements ITunnelSocketFactory { + async open(stream: ITunnelDuplexStream, path: string): Promise { + const WS = await import('ws'); + return new Promise((resolve, reject) => { + const socket = new WS.WebSocket(`ws://localhost:${TUNNEL_AGENT_HOST_PORT}${path}`, { + createConnection: (() => stream) as unknown as WebSocket.ClientOptions['createConnection'], + }); + const onError = (error: Error) => { + socket.off('open', onOpen); + reject(error); + }; + const onOpen = () => { + socket.off('error', onError); + resolve(new NodeTunnelMessageSocket(socket)); + }; + socket.once('open', onOpen); + socket.once('error', onError); + }); + } +} + +function rawGatewayDataToString(data: WebSocket.RawData): string { + if (Array.isArray(data)) { + return Buffer.concat(data).toString(); + } else if (data instanceof ArrayBuffer) { + return Buffer.from(new Uint8Array(data)).toString(); } + return data.toString(); } export class TunnelAgentHostMainService extends Disposable implements ITunnelAgentHostMainService { declare readonly _serviceBrand: undefined; - private readonly _onDidRelayMessage = this._register(new Emitter()); - readonly onDidRelayMessage: Event = this._onDidRelayMessage.event; - - private readonly _onDidRelayClose = this._register(new Emitter()); - readonly onDidRelayClose: Event = this._onDidRelayClose.event; + private readonly _connector: TunnelAgentHostConnector; - private readonly _connections = new Map(); - private readonly _pendingSelections = this._register(new DisposableMap()); + readonly onDidRelayMessage: Event; + readonly onDidRelayClose: Event; constructor( @ILogService private readonly _logService: ILogService, ) { super(); + this._connector = this._register(new TunnelAgentHostConnector( + new NodeTunnelRelayClientFactory((token, authProvider) => this._createManagementClient(token, authProvider)), + new NodeTunnelSocketFactory(), + this._logService, + )); + this.onDidRelayMessage = this._connector.onDidRelayMessage; + this.onDidRelayClose = this._connector.onDidRelayClose; } async listTunnels(token: string, authProvider: 'github' | 'microsoft', additionalTunnelNames?: string[]): Promise { @@ -192,16 +193,14 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge const seen = new Set(); try { - // Enumerate all tunnels with the vscode-server-launcher label const tunnels = await client.listTunnels(undefined, undefined, { labels: [TUNNEL_LAUNCHER_LABEL], requireAllLabels: true, includePorts: true, tokenScopes: ['connect'], }); - for (const tunnel of tunnels) { - const info = this._parseTunnelInfo(tunnel); + const info = parseTunnelInfo(tunnel); if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION) { results.push(info); seen.add(info.tunnelId); @@ -211,7 +210,6 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, err); } - // Look up additional tunnels by name if (additionalTunnelNames) { for (const tunnelName of additionalTunnelNames) { try { @@ -223,7 +221,7 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge limit: 1, }); if (tunnel) { - const info = this._parseTunnelInfo(tunnel); + const info = parseTunnelInfo(tunnel); if (info && info.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION && !seen.has(info.tunnelId)) { results.push(info); seen.add(info.tunnelId); @@ -241,473 +239,64 @@ export class TunnelAgentHostMainService extends Disposable implements ITunnelAge async deleteTunnel(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { const client = await this._createManagementClient(token, authProvider); - const tunnel: Tunnel = { tunnelId, clusterId }; this._logService.info(`${LOG_PREFIX} Deleting tunnel ${tunnelId} in cluster ${clusterId}...`); - await client.deleteTunnel(tunnel); - - // Tear the relays down only once the tunnel is actually gone. Closing - // them first reports a disconnect while the tunnel is still cached, - // which lets an auto-reconnect be scheduled against a tunnel that is - // midway through being deleted — and needlessly drops a live - // connection if the delete then fails. - this._closeTunnelConnections(tunnelId, 'deleting'); + await client.deleteTunnel({ tunnelId, clusterId }); + this._connector.closeTunnelConnections(tunnelId, 'deleting'); this._logService.info(`${LOG_PREFIX} Deleted tunnel ${tunnelId}`); } - async connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { - this._closeTunnelConnections(tunnelId, 'reconnecting'); - - const client = await this._createManagementClient(token, authProvider); - const connectionId = generateUuid(); - const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; - - this._logService.info(`${LOG_PREFIX} Connecting to tunnel ${tunnelId} in cluster ${clusterId}...`); - - // Get the full tunnel with endpoints and access tokens - const tunnel: Tunnel = { tunnelId, clusterId }; - const resolved = await client.getTunnel(tunnel, { - includePorts: true, - tokenScopes: ['connect'], - }); - - if (!resolved) { - throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); - } - - // Connect to the tunnel relay - const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections'); - const relayClient = new TunnelRelayTunnelClient(client); - relayClient.acceptLocalConnectionsForForwardedPorts = false; - if (resolved.endpoints) { - relayClient.endpoints = resolved.endpoints; - } - - // Bound each SDK step. A silently dead network can leave any of these - // pending forever, which would hang the renderer's - // `_tunnelService.connect(...)` await and prevent auto-reconnect from - // re-arming until the app is restarted. - let portStream: NodeJS.ReadWriteStream; - try { - await withTimeout(() => relayClient.connect(resolved), TUNNEL_STEP_TIMEOUT_MS, 'tunnel relay connect'); - this._logService.info(`${LOG_PREFIX} Tunnel relay connected, waiting for port ${TUNNEL_AGENT_HOST_PORT}...`); - - // Wait for the agent host port to become available - await withTimeout(() => relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `wait for forwarded port ${TUNNEL_AGENT_HOST_PORT}`); - - // Connect to the forwarded port — returns a Duplex stream - portStream = await withTimeout(() => relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `connect to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); - this._logService.info(`${LOG_PREFIX} Connected to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); - } catch (err) { - // Clean up the dev-tunnels relay client so we don't leak an - // orphan client when the SDK call hangs or fails. - try { - relayClient.dispose(); - } catch { - // ignore — best-effort cleanup - } - throw err; - } - - // Derive connection token from tunnel ID (matches CLI convention) - const connectionToken = deriveConnectionToken(tunnelId); - - // Parse display name from tags - const tags = new TunnelTags(resolved.labels); - const name = tags.name || resolved.name || tunnelId; - - // Create WebSocket over the port stream - let relay: { send: (data: string) => void; close: () => void }; - try { - relay = await withTimeout( - () => this._createWebSocketRelay(portStream, connectionToken, connectionId), - TUNNEL_STEP_TIMEOUT_MS, - 'WebSocket relay open', - ); - } catch (err) { - try { - relayClient.dispose(); - } catch { - // ignore - } - throw err; - } - - const conn = new TunnelConnection( - connectionId, - address, - name, - connectionToken, - relay, - relayClient, - ); - - // Self-disposing: Emitter.dispose() clears listeners without marking - // previously returned subscription handles as disposed, so this must - // dispose its own handle once it fires to avoid tripping the - // disposable leak tracker in tests that exercise a full connection. - const onConnClose = conn.onDidClose(() => { - onConnClose.dispose(); - this._connections.delete(connectionId); - this._onDidRelayClose.fire(connectionId); - }); - - this._connections.set(connectionId, conn); - return { - connectionId, address, name, connectionToken, - // Legacy v5 tunnels have no gateway inventory, so `connect` always - // reuses a single deterministic target with no picker involved. - selected: { serverType: 'unknown', instanceId: '', role: 'primary', lifecycle: 'external' }, - }; + connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { + return this._connector.connect(token, authProvider, tunnelId, clusterId); } - async prepareSelection(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { - const client = await this._createManagementClient(token, authProvider); - const tunnel: Tunnel = { tunnelId, clusterId }; - const resolved = await client.getTunnel(tunnel, { - includePorts: true, - tokenScopes: ['connect'], - }); - if (!resolved) { - throw new Error(`${LOG_PREFIX} Tunnel ${tunnelId} not found`); - } - - const tags = new TunnelTags(resolved.labels); - if (tags.protocolVersion < TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION) { - // Caller must fall back to the legacy `connect()`, which - // preserves the v5 direct-reuse behavior with no picker. - return undefined; - } - - this._logService.info(`${LOG_PREFIX} Preparing gateway selection for tunnel ${tunnelId} in cluster ${clusterId}...`); - - const { TunnelRelayTunnelClient } = await import('@microsoft/dev-tunnels-connections'); - const relayClient = new TunnelRelayTunnelClient(client); - relayClient.acceptLocalConnectionsForForwardedPorts = false; - if (resolved.endpoints) { - relayClient.endpoints = resolved.endpoints; - } - - let ws: WebSocket; - try { - await withTimeout(() => relayClient.connect(resolved), TUNNEL_STEP_TIMEOUT_MS, 'tunnel relay connect'); - await withTimeout(() => relayClient.waitForForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `wait for forwarded port ${TUNNEL_AGENT_HOST_PORT}`); - const portStream = await withTimeout(() => relayClient.connectToForwardedPort(TUNNEL_AGENT_HOST_PORT), TUNNEL_STEP_TIMEOUT_MS, `connect to forwarded port ${TUNNEL_AGENT_HOST_PORT}`); - ws = await withTimeout(() => this._openGatewaySelectSocket(portStream), TUNNEL_STEP_TIMEOUT_MS, 'gateway selection WebSocket open'); - } catch (err) { - try { - relayClient.dispose(); - } catch { - // ignore — best-effort cleanup - } - throw err; - } - - let inventoryText: string; - try { - inventoryText = await withTimeout(() => this._readNextGatewayMessage(ws), TUNNEL_STEP_TIMEOUT_MS, 'gateway inventory message'); - } catch (err) { - try { - ws.close(); - } catch { - // ignore — best-effort cleanup - } - try { - relayClient.dispose(); - } catch { - // ignore — best-effort cleanup - } - throw err; - } - - const inventory = parseTunnelGatewayInventory(inventoryText); - const connectionToken = deriveConnectionToken(tunnelId); - const name = tags.name || resolved.name || tunnelId; - const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; - const selectionId = generateUuid(); - - this._pendingSelections.set(selectionId, new PendingGatewaySelection( - address, name, connectionToken, ws, relayClient, - () => { - this._logService.warn(`${LOG_PREFIX} Gateway selection WebSocket for ${selectionId} closed before a selection was made`); - this._pendingSelections.deleteAndDispose(selectionId); - }, - )); - - return { selectionId, inventory }; + prepareSelection(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise { + return this._connector.prepareSelection(token, authProvider, tunnelId, clusterId); } - async completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise { - const pending = this._pendingSelections.deleteAndLeak(selectionId); - if (!pending) { - throw new Error(`${LOG_PREFIX} No pending gateway selection with id ${selectionId}`); - } - // Ownership of the WebSocket/relay client has transferred to us: stop - // treating an unexpected close as "cancelled before selecting". - pending.detach(); - - const { ws, relayClient, address, name, connectionToken } = pending; - - let responseText: string; - try { - ws.send(JSON.stringify(selection)); - responseText = await withTimeout(() => this._readNextGatewayMessage(ws), TUNNEL_STEP_TIMEOUT_MS, 'gateway selection acknowledgement'); - } catch (err) { - try { - ws.close(); - } catch { - // ignore — best-effort cleanup - } - try { - relayClient.dispose(); - } catch { - // ignore — best-effort cleanup - } - throw err; - } - - const response = parseTunnelGatewaySelectionResponse(responseText); - if (!response.ok) { - // The selected entry disappeared, or the CLI otherwise rejected - // the selection (e.g. its socket was already gone). Close - // everything rather than silently substituting another target — - // but tag the error so the caller can tell this apart from an - // unreachable tunnel and pick a different endpoint itself. - try { - ws.close(); - } catch { - // ignore — best-effort cleanup - } - try { - relayClient.dispose(); - } catch { - // ignore — best-effort cleanup - } - throw createTunnelGatewaySelectionRejectedError(`${LOG_PREFIX} ${response.error}`); - } - - const connectionId = generateUuid(); - const relay = this._attachRelaySteadyStateHandlers(ws, connectionId); - const conn = new TunnelConnection(connectionId, address, name, connectionToken, relay, relayClient); - - // Self-disposing: see the matching comment in connect(). - const onConnClose = conn.onDidClose(() => { - onConnClose.dispose(); - this._connections.delete(connectionId); - this._onDidRelayClose.fire(connectionId); - }); - - this._connections.set(connectionId, conn); - this._logService.info(`${LOG_PREFIX} Gateway selection ${selectionId} completed: selected ${response.selected.serverType} ${response.selected.instanceId}`); - - return { connectionId, address, name, connectionToken, selected: response.selected }; + completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise { + return this._connector.completeSelection(selectionId, selection); } - async cancelSelection(selectionId: string): Promise { - this._pendingSelections.deleteAndDispose(selectionId); + cancelSelection(selectionId: string): Promise { + return this._connector.cancelSelection(selectionId); } - async relaySend(connectionId: string, message: string): Promise { - const conn = this._connections.get(connectionId); - if (conn) { - conn.relaySend(message); - } + relaySend(connectionId: string, message: string): Promise { + return this._connector.relaySend(connectionId, message); } - async disconnect(connectionId: string): Promise { - const conn = this._connections.get(connectionId); - if (conn) { - conn.dispose(); - } + disconnect(connectionId: string): Promise { + return this._connector.disconnect(connectionId); } private async _createManagementClient(token: string, authProvider: 'github' | 'microsoft'): Promise { - const mgmt = await import('@microsoft/dev-tunnels-management'); + const management = await import('@microsoft/dev-tunnels-management'); const authHeader = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; - - return new mgmt.TunnelManagementHttpClient( + return new management.TunnelManagementHttpClient( 'vscode-sessions', - mgmt.ManagementApiVersions.Version20230927preview, + management.ManagementApiVersions.Version20230927preview, async () => authHeader, ); } - - private _closeTunnelConnections(tunnelId: string, operation: 'deleting' | 'reconnecting'): void { - const address = `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`; - for (const [connectionId, connection] of this._connections) { - if (connection.address === address) { - this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before ${operation}`); - this._connections.delete(connectionId); - connection.dispose(); - } - } - } - - private _parseTunnelInfo(tunnel: Tunnel): ITunnelInfo | undefined { - const labels = tunnel.labels ?? []; - const tags = new TunnelTags(labels); - - if (tags.protocolVersion < TUNNEL_MIN_PROTOCOL_VERSION) { - return undefined; - } - - const tunnelId = tunnel.tunnelId; - const clusterId = tunnel.clusterId; - if (!tunnelId || !clusterId) { - return undefined; - } - - const name = tags.name || tunnel.name || tunnelId; - const rawCount = tunnel.status?.hostConnectionCount; - const hostConnectionCount = typeof rawCount === 'number' ? rawCount : (rawCount?.current ?? 0); - return { - tunnelId, - clusterId, - name, - tags: labels, - protocolVersion: tags.protocolVersion, - hostConnectionCount, - }; - } - - private async _createWebSocketRelay( - portStream: NodeJS.ReadWriteStream, - connectionToken: string, - connectionId: string, - ): Promise<{ send: (data: string) => void; close: () => void }> { - const WS = await import('ws'); - - return new Promise((resolve, reject) => { - // Construct WebSocket URL — the stream is already connected to the right port - let url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}`; - if (connectionToken) { - url += `?tkn=${encodeURIComponent(connectionToken)}`; - } - - // Create WebSocket over the existing stream from the tunnel relay - const ws = new WS.WebSocket(url, { - createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'], - }); - - ws.on('open', () => { - this._logService.info(`${LOG_PREFIX} WebSocket relay connected to agent host via tunnel`); - resolve(this._attachRelaySteadyStateHandlers(ws, connectionId)); - }); - - ws.on('error', (wsErr: unknown) => { - this._logService.warn(`${LOG_PREFIX} WebSocket relay error: ${wsErr instanceof Error ? wsErr.message : String(wsErr)}`); - reject(wsErr); - }); - }); - } - - /** - * Attach the steady-state message-pump handlers ('message'/'close') to an - * already-open agent host WebSocket, shared between the legacy - * direct-reuse relay and the protocol-v6 gateway relay (which reuses the - * same WebSocket used for inventory/selection once a selection succeeds). - */ - private _attachRelaySteadyStateHandlers(ws: WebSocket, connectionId: string): { send: (data: string) => void; close: () => void } { - ws.on('message', (data: WebSocket.RawData) => { - this._onDidRelayMessage.fire({ connectionId, data: rawGatewayDataToString(data) }); - }); - - ws.on('close', (code: number, reason: Buffer) => { - this._logService.info(`${LOG_PREFIX} WebSocket relay closed for connection ${connectionId}; code=${code}, reason=${reason?.toString() || '(empty)'}`); - const conn = this._connections.get(connectionId); - if (conn) { - conn.dispose(); - } - }); - - return { - send: (data: string) => { - if (ws.readyState === ws.OPEN) { - ws.send(data); - } - }, - close: () => ws.close(), - }; - } - - /** - * Open the protocol-v6 gateway's selection WebSocket route over an - * already-connected tunnel port stream. No `?tkn=` query parameter is - * needed: connections arriving through the tunnel relay bypass the - * gateway's loopback per-request token check entirely (only used for - * the local, non-tunneled accept loop on the CLI side). - */ - private async _openGatewaySelectSocket(portStream: NodeJS.ReadWriteStream): Promise { - const WS = await import('ws'); - - return new Promise((resolve, reject) => { - const url = `ws://localhost:${TUNNEL_AGENT_HOST_PORT}${TUNNEL_GATEWAY_SELECT_PATH}`; - const ws = new WS.WebSocket(url, { - createConnection: (() => portStream) as unknown as WebSocket.ClientOptions['createConnection'], - }); - - const onError = (wsErr: unknown) => reject(wsErr); - ws.once('open', () => { - ws.off('error', onError); - resolve(ws); - }); - ws.once('error', onError); - }); - } - - /** - * Await exactly one message on a gateway WebSocket — used to read the - * one-time inventory message and, later, the one-time selection - * acknowledgement, both of which precede the raw AHP frame-proxying - * phase that reuses the same socket. - */ - private _readNextGatewayMessage(ws: WebSocket): Promise { - return new Promise((resolve, reject) => { - const cleanup = () => { - ws.off('message', onMessage); - ws.off('close', onClose); - ws.off('error', onError); - }; - const onMessage = (data: WebSocket.RawData) => { - cleanup(); - resolve(rawGatewayDataToString(data)); - }; - const onClose = (code: number, reason: Buffer) => { - cleanup(); - reject(new Error(`${LOG_PREFIX} Gateway WebSocket closed before expected message; code=${code}, reason=${reason?.toString() || '(empty)'}`)); - }; - const onError = (wsErr: unknown) => { - cleanup(); - reject(wsErr); - }; - ws.once('message', onMessage); - ws.once('close', onClose); - ws.once('error', onError); - }); - } } /** - * Test-only seam: register a pending gateway selection directly, bypassing - * the dev-tunnels SDK connection steps in {@link TunnelAgentHostMainService.prepareSelection}, - * so {@link TunnelAgentHostMainService.completeSelection} and {@link TunnelAgentHostMainService.cancelSelection} - * can be unit tested against fake WebSocket-like streams. + * Registers a pending gateway selection directly for node service tests. */ export function setPendingGatewaySelectionForTests( service: TunnelAgentHostMainService, selectionId: string, pending: PendingGatewaySelection, ): void { - (service as unknown as { _pendingSelections: DisposableMap })._pendingSelections.set(selectionId, pending); + (service as unknown as { _connector: TunnelAgentHostConnector })._connector.setPendingGatewaySelectionForTests(selectionId, pending); } /** - * Test-only seam: remove (and dispose) a pending gateway selection directly, - * mirroring what the real unexpected-close handler in {@link TunnelAgentHostMainService.prepareSelection} - * does, so tests can simulate that wiring without depending on the dev-tunnels SDK. + * Removes and disposes a pending gateway selection directly for node service tests. */ export function deletePendingGatewaySelectionForTests( service: TunnelAgentHostMainService, selectionId: string, ): void { - (service as unknown as { _pendingSelections: DisposableMap })._pendingSelections.deleteAndDispose(selectionId); + (service as unknown as { _connector: TunnelAgentHostConnector })._connector.deletePendingGatewaySelectionForTests(selectionId); } diff --git a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts new file mode 100644 index 00000000000000..b86403cdf4b78e --- /dev/null +++ b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts @@ -0,0 +1,272 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; +import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { + TUNNEL_STEP_TIMEOUT_MS, + TunnelAgentHostConnector, + deriveConnectionToken, + type ITunnelAgentHostConnectorLogService, + type ITunnelDescriptor, + type ITunnelRelayClient, + type ITunnelRelayClientFactory, + type ITunnelRelayClientSession, + type ITunnelSocketFactory, +} from '../../common/tunnelAgentHostConnector.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket, ITunnelSocketCloseEvent } from '../../common/tunnelMessageSocket.js'; + +class FakeStream implements ITunnelDuplexStream { + on(_event: 'data', _listener: (data: Uint8Array) => void): this; + on(_event: 'error', _listener: (error: Error) => void): this; + on(_event: 'close', _listener: (hadError?: boolean) => void): this; + on(_event: 'end' | 'drain' | 'pause' | 'resume', _listener: () => void): this; + on(_event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): this { + return this; + } + + removeListener(_event: 'data', _listener: (data: Uint8Array) => void): void; + removeListener(_event: 'error', _listener: (error: Error) => void): void; + removeListener(_event: 'close', _listener: (hadError?: boolean) => void): void; + removeListener(_event: 'end' | 'drain' | 'pause' | 'resume', _listener: () => void): void; + removeListener(_event: 'data' | 'error' | 'end' | 'close' | 'drain' | 'pause' | 'resume', _listener: ((data: Uint8Array) => void) | ((error: Error) => void) | ((hadError?: boolean) => void) | (() => void)): void { + } + + removeAllListeners(_event: 'error'): void { + } + + write(_data: string | Uint8Array): boolean { + return true; + } + + end(): void { + } + + destroy(): void { + } + + pause(): void { + } + + resume(): void { + } + +} + +class FakeRelayClient implements ITunnelRelayClient { + disposeCalls = 0; + readonly stream = new FakeStream(); + + constructor(private readonly _connectResult: Promise = Promise.resolve()) { + } + + connect(): Promise { + return this._connectResult; + } + + async waitForForwardedPort(_port: number): Promise { + } + + async connectToForwardedPort(_port: number): Promise { + return this.stream; + } + + dispose(): void { + this.disposeCalls++; + } +} + +class FakeSocket implements ITunnelMessageSocket { + private readonly _onDidReceiveMessage = new Emitter(); + private readonly _onDidClose = new Emitter(); + private readonly _queuedMessages: string[]; + + readonly onDidReceiveMessage: Event = (listener, thisArgs, disposables) => { + const disposable = this._onDidReceiveMessage.event(listener, thisArgs, disposables); + const message = this._queuedMessages.shift(); + if (message !== undefined) { + queueMicrotask(() => this._onDidReceiveMessage.fire(message)); + } + return disposable; + }; + readonly onDidClose = this._onDidClose.event; + closeCalls = 0; + + constructor(messages: string[] = []) { + this._queuedMessages = messages; + } + + send(_data: string): void { + } + + close(): void { + this.closeCalls++; + } + + dispose(): void { + this._onDidReceiveMessage.dispose(); + this._onDidClose.dispose(); + } +} + +class FakeRelayClientFactory implements ITunnelRelayClientFactory { + getTunnelCalls = 0; + createRelayClientCalls = 0; + + constructor( + private readonly _tunnel: ITunnelDescriptor, + private readonly _relayClient: FakeRelayClient, + ) { + } + + async getTunnel(_tunnelId: string, _clusterId: string, _authProvider: 'github' | 'microsoft', _token: string): Promise { + this.getTunnelCalls++; + return { + tunnel: this._tunnel, + createRelayClient: async () => { + this.createRelayClientCalls++; + return this._relayClient; + }, + }; + } +} + +class FakeSocketFactory implements ITunnelSocketFactory { + readonly paths: string[] = []; + + constructor(private readonly _result: FakeSocket | Error) { + } + + async open(_stream: ITunnelDuplexStream, path: string): Promise { + this.paths.push(path); + if (this._result instanceof Error) { + throw this._result; + } + return this._result; + } +} + +class FakeLogService implements ITunnelAgentHostConnectorLogService { + info(_message: string): void { + } + + warn(_message: string): void { + } +} + +function createConnector(tunnel: ITunnelDescriptor, relayClient: FakeRelayClient, socketFactory: FakeSocketFactory): { connector: TunnelAgentHostConnector; relayClientFactory: FakeRelayClientFactory } { + const relayClientFactory = new FakeRelayClientFactory(tunnel, relayClient); + return { + connector: new TunnelAgentHostConnector(relayClientFactory, socketFactory, new FakeLogService()), + relayClientFactory, + }; +} + +suite('TunnelAgentHostConnector', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('derives the same base64url tokens as Node crypto, including a leading dash', async () => { + const inputs = ['tunnel-1', 'hello', 'leading-dash-58']; + assert.deepStrictEqual(await Promise.all(inputs.map(deriveConnectionToken)), [ + '2mxIRS3JlBYT5m8W60ZoVokDhMuN2H6YCF0ABgYB5U8', + 'LPJNul-wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ', + 'a-Vv_dDaSd407TSoKmBuY8Jrx1w_cDjpHarRcBiCPpxc', + ]); + }); + + test('uses the legacy root route for v5 and the gateway route for v6', async () => { + const legacyRelay = new FakeRelayClient(); + const legacySocketFactory = new FakeSocketFactory(new FakeSocket()); + const { connector: legacyConnector, relayClientFactory: legacyFactory } = createConnector( + { tunnelId: 'legacy', clusterId: 'cluster', labels: ['protocolv5'] }, + legacyRelay, + legacySocketFactory, + ); + const gatewayRelay = new FakeRelayClient(); + const gatewaySocketFactory = new FakeSocketFactory(new FakeSocket([ + JSON.stringify({ userDataPath: '/data', endpoints: [] }), + ])); + const { connector: gatewayConnector, relayClientFactory: gatewayFactory } = createConnector( + { tunnelId: 'gateway', clusterId: 'cluster', labels: ['protocolv6'] }, + gatewayRelay, + gatewaySocketFactory, + ); + + try { + const [legacy, gateway, legacyConnection] = await Promise.all([ + legacyConnector.prepareSelection('token', 'github', 'legacy', 'cluster'), + gatewayConnector.prepareSelection('token', 'github', 'gateway', 'cluster'), + legacyConnector.connect('token', 'github', 'legacy', 'cluster'), + ]); + assert.deepStrictEqual({ + legacy, + legacyRelayCreations: legacyFactory.createRelayClientCalls, + legacySocketPaths: legacySocketFactory.paths, + gatewayInventory: gateway?.inventory, + gatewayRelayCreations: gatewayFactory.createRelayClientCalls, + gatewaySocketPaths: gatewaySocketFactory.paths, + }, { + legacy: undefined, + legacyRelayCreations: 1, + legacySocketPaths: ['/?tkn=xJ_qdCX6f4aZiXqXwVnGaQJn2QA7t4xT-vqPwVwyXYQ'], + gatewayInventory: { userDataPath: '/data', endpoints: [] }, + gatewayRelayCreations: 1, + gatewaySocketPaths: ['/agent-host/select'], + }); + await legacyConnector.disconnect(legacyConnection.connectionId); + } finally { + legacyConnector.dispose(); + gatewayConnector.dispose(); + } + }); + + test('times out a relay step and disposes the relay client', async () => { + return runWithFakedTimers({ useFakeTimers: true, maxTaskCount: 10_000 }, async () => { + const hangingConnect = new DeferredPromise(); + const relayClient = new FakeRelayClient(hangingConnect.p); + const { connector } = createConnector( + { tunnelId: 'timed-out', clusterId: 'cluster', labels: ['protocolv5'] }, + relayClient, + new FakeSocketFactory(new FakeSocket()), + ); + try { + const rejected = connector.connect('token', 'github', 'timed-out', 'cluster').catch(error => error); + await timeout(TUNNEL_STEP_TIMEOUT_MS + 1); + const error = await rejected; + assert.deepStrictEqual({ + isTimeout: error instanceof Error && /tunnel relay connect timed out/.test(error.message), + disposeCalls: relayClient.disposeCalls, + }, { + isTimeout: true, + disposeCalls: 1, + }); + hangingConnect.complete(); + } finally { + connector.dispose(); + } + }); + }); + + test('disposes the relay client when opening the legacy socket fails', async () => { + const relayClient = new FakeRelayClient(); + const { connector } = createConnector( + { tunnelId: 'socket-failure', clusterId: 'cluster', labels: ['protocolv5'] }, + relayClient, + new FakeSocketFactory(new Error('socket open failed')), + ); + try { + await assert.rejects( + () => connector.connect('token', 'github', 'socket-failure', 'cluster'), + /socket open failed/, + ); + assert.strictEqual(relayClient.disposeCalls, 1); + } finally { + connector.dispose(); + } + }); +}); diff --git a/src/vs/platform/agentHost/test/common/tunnelGatewaySelection.test.ts b/src/vs/platform/agentHost/test/common/tunnelGatewaySelection.test.ts new file mode 100644 index 00000000000000..173ca463a03279 --- /dev/null +++ b/src/vs/platform/agentHost/test/common/tunnelGatewaySelection.test.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { IDialogService, IPrompt } from '../../../dialogs/common/dialogs.js'; +import { IRemoteAgentHostLocationPreferenceService, RemoteAgentHostLocationPreference } from '../../common/remoteAgentHostLocationPreference.js'; +import { ITunnelGatewayInventory } from '../../common/tunnelAgentHost.js'; +import { resolveGatewaySelection } from '../../common/tunnelGatewaySelection.js'; + +function inventory(endpoints: ITunnelGatewayInventory['endpoints']): ITunnelGatewayInventory { + return { userDataPath: '/data', endpoints }; +} + +const editorEndpoint = { type: 'editor', pid: 111, instanceId: 'editor-1', quality: 'insiders', endpointKind: 'socket', endpointLabel: '/tmp/editor-1.sock' } as const; +const standaloneEndpoint = { type: 'standalone', pid: 222, instanceId: 'standalone-2', tunnelName: 'my-tunnel', endpointKind: 'tcp', endpointLabel: '127.0.0.1:9001' } as const; + +interface IPreferenceServiceFixture { + readonly service: IRemoteAgentHostLocationPreferenceService; + readonly setCalls: { hostKey: string; preference: RemoteAgentHostLocationPreference }[]; +} + +function stubLocationPreferenceService(initial?: RemoteAgentHostLocationPreference): IPreferenceServiceFixture { + const store = new Map(); + if (initial) { + store.set('tunnel:abc', initial); + } + const setCalls: { hostKey: string; preference: RemoteAgentHostLocationPreference }[] = []; + const service: IRemoteAgentHostLocationPreferenceService = { + _serviceBrand: undefined, + onDidChangePreference: Event.None, + getPreference: hostKey => store.get(hostKey), + setPreference: (hostKey, preference) => { + store.set(hostKey, preference); + setCalls.push({ hostKey, preference }); + }, + }; + return { service, setCalls }; +} + +interface IDialogServiceFixture { + readonly dialogService: IDialogService; + readonly promptCalls: IPrompt[]; +} + +function stubDialogService(result: RemoteAgentHostLocationPreference | undefined): IDialogServiceFixture { + const promptCalls: IPrompt[] = []; + const dialogService = { + prompt: async (options: IPrompt) => { + promptCalls.push(options); + return { result }; + }, + } as unknown as IDialogService; + return { dialogService, promptCalls }; +} + +suite('resolveGatewaySelection', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('a delegated instance short-circuits saved preferences and prompts', async () => { + const { service, setCalls } = stubLocationPreferenceService('dedicated'); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', + inventory: { userDataPath: '/data', delegatedInstanceId: 'editor-1', endpoints: [editorEndpoint] }, + userInitiated: true, + }); + + assert.deepStrictEqual({ selection, promptCalls, setCalls }, { + selection: { instanceId: 'editor-1' }, + promptCalls: [], + setCalls: [], + }); + }); + + test('a first user-initiated delegated connection persists its editor location for future auto-connect', async () => { + const { service, setCalls } = stubLocationPreferenceService(); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', + inventory: { userDataPath: '/data', delegatedInstanceId: 'editor-1', endpoints: [editorEndpoint] }, + userInitiated: true, + }); + + assert.deepStrictEqual({ selection, promptCalls, setCalls }, { + selection: { instanceId: 'editor-1' }, + promptCalls: [], + setCalls: [{ hostKey: 'tunnel:abc', preference: 'editor' }], + }); + }); + + test('saved "editor" preference + a live editor selects that editor without prompting or re-persisting', async () => { + const { service, setCalls } = stubLocationPreferenceService('editor'); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, + }); + + assert.deepStrictEqual(selection, { instanceId: 'editor-1' }); + assert.strictEqual(promptCalls.length, 0); + assert.strictEqual(setCalls.length, 0); + }); + + test('saved "editor" preference + a background (non-user-initiated) reconnect still selects the live editor (explicit consent)', async () => { + const { service, setCalls } = stubLocationPreferenceService('editor'); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint]), userInitiated: false, + }); + + assert.deepStrictEqual(selection, { instanceId: 'editor-1' }); + assert.strictEqual(promptCalls.length, 0); + assert.strictEqual(setCalls.length, 0); + }); + + test('saved "editor" preference + no live editor falls back to dedicated without changing the preference', async () => { + const { service, setCalls } = stubLocationPreferenceService('editor'); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([standaloneEndpoint]), userInitiated: true, + }); + + assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); + assert.strictEqual(promptCalls.length, 0); + assert.strictEqual(setCalls.length, 0, 'an unavailable editor preference must not be overwritten'); + }); + + test('saved "dedicated" preference never prompts, even when a live editor exists', async () => { + const { service, setCalls } = stubLocationPreferenceService('dedicated'); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, + }); + + assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); + assert.strictEqual(promptCalls.length, 0); + assert.strictEqual(setCalls.length, 0); + }); + + test('no saved preference + no live editor persists the only available location after a manual connection', async () => { + const { service, setCalls } = stubLocationPreferenceService(); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([standaloneEndpoint]), userInitiated: true, + }); + + assert.deepStrictEqual({ selection, promptCalls, setCalls }, { + selection: { instanceId: 'standalone-2' }, + promptCalls: [], + setCalls: [{ hostKey: 'tunnel:abc', preference: 'dedicated' }], + }); + }); + + test('no saved preference + a live editor + a background connection defers until a manual selection', async () => { + const { service, setCalls } = stubLocationPreferenceService(); + const { dialogService, promptCalls } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: false, + }); + + assert.strictEqual(selection, undefined); + assert.strictEqual(promptCalls.length, 0); + assert.strictEqual(setCalls.length, 0); + }); + + test('no saved preference + a live editor + a user-initiated connection prompts the shared modal with the tunnel name and persists an "editor" choice', async () => { + const { service, setCalls } = stubLocationPreferenceService(); + const { dialogService, promptCalls } = stubDialogService('editor'); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, + }); + + assert.deepStrictEqual(selection, { instanceId: 'editor-1' }); + assert.strictEqual(promptCalls.length, 1); + assert.match(promptCalls[0].message, /My Tunnel/); + assert.deepStrictEqual((promptCalls[0] as unknown as { custom: { buttonDetails: string[] } }).custom.buttonDetails[1], 'Agents are available only while the remote Test Product window is open.'); + assert.deepStrictEqual(setCalls, [{ hostKey: 'tunnel:abc', preference: 'editor' }]); + }); + + test('no saved preference + a live editor + a user-initiated connection persists a "dedicated" choice and translates it to a concrete selection', async () => { + const { service, setCalls } = stubLocationPreferenceService(); + const { dialogService } = stubDialogService('dedicated'); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, + }); + + assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); + assert.deepStrictEqual(setCalls, [{ hostKey: 'tunnel:abc', preference: 'dedicated' }]); + }); + + test('cancelling the modal returns undefined and persists nothing', async () => { + const { service, setCalls } = stubLocationPreferenceService(); + const { dialogService } = stubDialogService(undefined); + + const selection = await resolveGatewaySelection(service, dialogService, { + hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, + }); + + assert.strictEqual(selection, undefined); + assert.strictEqual(setCalls.length, 0); + }); +}); diff --git a/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts b/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts new file mode 100644 index 00000000000000..eb282f943666af --- /dev/null +++ b/src/vs/platform/agentHost/test/common/webSocketOverDuplex.test.ts @@ -0,0 +1,212 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { EventEmitter } from 'events'; +import { createRequire } from 'module'; + +import { Event } from '../../../../base/common/event.js'; +import { hasKey } from '../../../../base/common/types.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { connectWebSocketOverDuplex, createWebSocketAccept } from '../../common/webSocketOverDuplex.js'; +import type { ITunnelDuplexStream, IWebSocketDuplexStream, WebSocketConnectionCtor } from '../../common/tunnelMessageSocket.js'; + +const WebSocketConnection = createRequire(import.meta.url)('websocket/lib/WebSocketConnection') as WebSocketConnectionCtor; +const websocketAcceptGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; + +suite('connectWebSocketOverDuplex', () => { + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('sends a WebSocket upgrade request and accepts a valid response', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream, '/agent-host/select', 'gateway.example'); + const request = stream.request; + stream.push(await createUpgradeResponse(request)); + store.add(await socketPromise); + + assert.deepStrictEqual(request.replace(requestKey(request), ''), [ + 'GET /agent-host/select HTTP/1.1', + 'Host: gateway.example', + 'Connection: Upgrade', + 'Upgrade: websocket', + 'Sec-WebSocket-Version: 13', + 'Sec-WebSocket-Key: ', + '', + '', + ].join('\r\n')); + }); + + test('computes the RFC WebSocket accept value', async () => { + assert.strictEqual( + await createWebSocketAccept('dGhlIHNhbXBsZSBub25jZQ=='), + 's3pPLMBiTxaQ9kYGzzhZRbK+xOo=', + ); + }); + + test('adapts a bare tunnel duplex stream without TCP socket methods', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = await socketPromise; + const socketLike = stream as Partial; + assert.deepStrictEqual({ + socketCreated: !!socket, + hasSetNoDelay: hasKey(socketLike, { setNoDelay: true }), + hasSetTimeout: hasKey(socketLike, { setTimeout: true }), + hasSetKeepAlive: hasKey(socketLike, { setKeepAlive: true }), + }, { + socketCreated: true, + hasSetNoDelay: false, + hasSetTimeout: false, + hasSetKeepAlive: false, + }); + store.add(socket); + }); + + test('does not recurse when ending a re-entrant tunnel stream', async () => { + const stream = new ReentrantEndDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + stream.end(); + + assert.deepStrictEqual({ endCalls: stream.endCalls, socketCreated: !!socket }, { endCalls: 2, socketCreated: true }); + }); + + test('rejects a non-101 upgrade response', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push('HTTP/1.1 403 Forbidden\r\n\r\n'); + + await assert.rejects(socketPromise, /expected status 101 but received 403/); + }); + + test('rejects missing or invalid WebSocket accept headers', async () => { + for (const response of [ + 'HTTP/1.1 101 Switching Protocols\r\n\r\n', + 'HTTP/1.1 101 Switching Protocols\r\nSec-WebSocket-Accept: invalid\r\n\r\n', + ]) { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(response); + + await assert.rejects(socketPromise, /Sec-WebSocket-Accept/); + } + }); + + test('delivers a coalesced first WebSocket frame', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + const response = await createUpgradeResponse(stream.request); + stream.push(concat(response, createTextFrame('coalesced'))); + const socket = store.add(await socketPromise); + const message = Event.toPromise(socket.onDidReceiveMessage); + + assert.deepStrictEqual([await message], ['coalesced']); + }); + + test('delivers a text frame received after the upgrade', async () => { + const stream = new FakeDuplexStream(); + const socketPromise = connect(stream); + stream.push(await createUpgradeResponse(stream.request)); + const socket = store.add(await socketPromise); + const message = Event.toPromise(socket.onDidReceiveMessage); + stream.push(createTextFrame('round trip')); + + assert.deepStrictEqual([await message], ['round trip']); + }); +}); + +function connect(stream: FakeDuplexStream, path = '/', host?: string) { + return connectWebSocketOverDuplex(stream, { + path, + host, + webSocketConnectionCtor: WebSocketConnection, + }); +} + +async function createUpgradeResponse(request: string): Promise { + const digest = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(requestKey(request) + websocketAcceptGuid)); + const accept = Buffer.from(digest).toString('base64'); + return new TextEncoder().encode([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '', + '', + ].join('\r\n')); +} + +function requestKey(request: string): string { + const match = /^Sec-WebSocket-Key: (.+)$/m.exec(request); + if (!match) { + throw new Error('WebSocket upgrade request did not include Sec-WebSocket-Key.'); + } + return match[1]; +} + +function createTextFrame(message: string): Uint8Array { + const data = new TextEncoder().encode(message); + return Uint8Array.from([0x81, data.byteLength, ...data]); +} + +function concat(...chunks: Uint8Array[]): Uint8Array { + const result = new Uint8Array(chunks.reduce((length, chunk) => length + chunk.byteLength, 0)); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +class FakeDuplexStream extends EventEmitter implements ITunnelDuplexStream { + readonly writes: (Uint8Array | string)[] = []; + private _ended = false; + private _destroyed = false; + + get request(): string { + return this.writes.map(write => typeof write === 'string' ? write : new TextDecoder().decode(write)).join(''); + } + + write(chunk: Uint8Array | string): boolean { + this.writes.push(chunk); + return true; + } + + end(): void { + if (!this._ended) { + this._ended = true; + this.emit('end'); + } + } + + destroy(): void { + if (!this._destroyed) { + this._destroyed = true; + this.emit('close'); + } + } + + pause(): void { + } + + resume(): void { + } + + push(chunk: Uint8Array | string): void { + this.emit('data', Buffer.from(chunk)); + } +} + +class ReentrantEndDuplexStream extends FakeDuplexStream { + endCalls = 0; + + override end(): void { + this.endCalls++; + this.emit('end'); + } +} diff --git a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts index 3ef2d280d2a15b..a56934ca8ebe0d 100644 --- a/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts +++ b/src/vs/platform/agentHost/test/node/tunnelAgentHostService.test.ts @@ -4,13 +4,14 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { EventEmitter } from 'events'; -import type WebSocket from 'ws'; import { DeferredPromise, timeout } from '../../../../base/common/async.js'; +import { Emitter, Event } from '../../../../base/common/event.js'; import { NullLogService } from '../../../log/common/log.js'; import { runWithFakedTimers } from '../../../../base/test/common/timeTravelScheduler.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { isTunnelGatewaySelectionRejectedError, TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME } from '../../common/tunnelAgentHost.js'; +import type { ITunnelRelayClient } from '../../common/tunnelAgentHostConnector.js'; +import type { ITunnelMessageSocket } from '../../common/tunnelMessageSocket.js'; import { PendingGatewaySelection, deletePendingGatewaySelectionForTests, @@ -21,17 +22,16 @@ import { } from '../../node/tunnelAgentHostService.js'; /** - * Minimal EventEmitter-based double for the `ws` package's `WebSocket`, - * exposing just the surface {@link TunnelAgentHostMainService} relies on - * (`send`/`close`/`readyState`/`OPEN` plus the inherited `on`/`once`/`off`/`emit`). - * Cast to `WebSocket` at call sites, matching this repo's convention of using - * typed test doubles instead of `any`. + * Minimal message-socket double for gateway selection tests. */ -class FakeGatewaySocket extends EventEmitter { +class FakeGatewaySocket implements ITunnelMessageSocket { + private readonly _onDidReceiveMessage = new Emitter(); + readonly onDidReceiveMessage: Event = this._onDidReceiveMessage.event; + private readonly _onDidClose = new Emitter<{ code?: number; reason?: string; error?: Error }>(); + readonly onDidClose: Event<{ code?: number; reason?: string; error?: Error }> = this._onDidClose.event; + readonly sent: string[] = []; closeCalls = 0; - readyState = 1; - readonly OPEN = 1; send(data: string): void { this.sent.push(data); @@ -40,10 +40,38 @@ class FakeGatewaySocket extends EventEmitter { close(): void { this.closeCalls++; } + + emitMessage(data: string): void { + this._onDidReceiveMessage.fire(data); + } + + emitClose(code: number, reason: string): void { + this._onDidClose.fire({ code, reason }); + } + + emitError(error: Error): void { + this._onDidClose.fire({ error }); + } + + dispose(): void { + this._onDidReceiveMessage.dispose(); + this._onDidClose.dispose(); + } } -class FakeRelayClient { +class FakeRelayClient implements ITunnelRelayClient { disposeCalls = 0; + + async connect(): Promise { + } + + async waitForForwardedPort(_port: number): Promise { + } + + async connectToForwardedPort(_port: number): Promise { + throw new Error('Not implemented in selection tests'); + } + dispose(): void { this.disposeCalls++; } @@ -104,7 +132,7 @@ suite('TunnelAgentHostService - withTimeout', () => { function createPending(onUnexpectedClose: () => void = () => { }) { const ws = new FakeGatewaySocket(); const relayClient = new FakeRelayClient(); - const pending = new PendingGatewaySelection('tunnel:t1', 'My Tunnel', 'tok123', ws as unknown as WebSocket, relayClient, onUnexpectedClose); + const pending = new PendingGatewaySelection('tunnel:t1', 'My Tunnel', 'tok123', ws, relayClient, onUnexpectedClose); return { ws, relayClient, pending }; } @@ -125,10 +153,10 @@ suite('TunnelAgentHostService - gateway selection', () => { // for a microtask/timeout to assert on this. assert.deepStrictEqual(ws.sent, [JSON.stringify({ instanceId: 'abc-123' })]); - ws.emit('message', Buffer.from(JSON.stringify({ + ws.emitMessage(JSON.stringify({ ok: true, selected: { type: 'editor', instanceId: 'abc-123', role: 'primary', lifecycle: 'external' }, - }))); + })); const result = await resultPromise; assert.strictEqual(result.address, 'tunnel:t1'); @@ -139,12 +167,12 @@ suite('TunnelAgentHostService - gateway selection', () => { // Steady-state: the same socket now proxies subsequent AHP frames. const relayed: string[] = []; const relayListener = service.onDidRelayMessage(m => relayed.push(m.data)); - ws.emit('message', Buffer.from('{"hello":"world"}')); + ws.emitMessage('{"hello":"world"}'); assert.deepStrictEqual(relayed, ['{"hello":"world"}']); relayListener.dispose(); // Simulate the socket closing to dispose the resulting TunnelConnection. - ws.emit('close', 1000, Buffer.from('')); + ws.emitClose(1000, ''); } finally { service.dispose(); } @@ -169,7 +197,7 @@ suite('TunnelAgentHostService - gateway selection', () => { setPendingGatewaySelectionForTests(service, 'sel1', pending); const resultPromise = service.completeSelection('sel1', { instanceId: 'gone' }); - ws.emit('message', Buffer.from(JSON.stringify({ ok: false, error: 'instance no longer live' }))); + ws.emitMessage(JSON.stringify({ ok: false, error: 'instance no longer live' })); const error = await resultPromise.then(() => undefined, (err: Error) => err); assert.deepStrictEqual({ @@ -197,7 +225,7 @@ suite('TunnelAgentHostService - gateway selection', () => { setPendingGatewaySelectionForTests(service, 'sel1', pending); const resultPromise = service.completeSelection('sel1', { instanceId: 'editor-1' }); - ws.emit('error', new Error('socket hang up')); + ws.emitError(new Error('socket hang up')); const error = await resultPromise.then(() => undefined, (err: Error) => err); assert.strictEqual(isTunnelGatewaySelectionRejectedError(error), false); @@ -237,7 +265,7 @@ suite('TunnelAgentHostService - gateway selection', () => { // Simulate the gateway socket dropping before a selection was made: // the close listener above removes it from the pending map, so a // later completeSelection sees no pending entry. - ws.emit('close', 1000, Buffer.from('network drop')); + ws.emitClose(1000, 'network drop'); await assert.rejects( () => service.completeSelection('sel1', { instanceId: 'abc' }), @@ -256,9 +284,9 @@ suite('PendingGatewaySelection', () => { const ws = new FakeGatewaySocket(); const relayClient = new FakeRelayClient(); let closedCount = 0; - const pending = new PendingGatewaySelection('addr', 'name', 'tok', ws as unknown as WebSocket, relayClient, () => { closedCount++; }); + const pending = new PendingGatewaySelection('addr', 'name', 'tok', ws, relayClient, () => { closedCount++; }); - ws.emit('close', 1000, Buffer.from('')); + ws.emitClose(1000, ''); assert.strictEqual(closedCount, 1); pending.dispose(); }); @@ -267,17 +295,18 @@ suite('PendingGatewaySelection', () => { const ws = new FakeGatewaySocket(); const relayClient = new FakeRelayClient(); let closedCount = 0; - const pending = new PendingGatewaySelection('addr', 'name', 'tok', ws as unknown as WebSocket, relayClient, () => { closedCount++; }); + const pending = new PendingGatewaySelection('addr', 'name', 'tok', ws, relayClient, () => { closedCount++; }); pending.detach(); - ws.emit('close', 1000, Buffer.from('')); + ws.emitClose(1000, ''); assert.strictEqual(closedCount, 0); + pending.dispose(); }); test('dispose() closes the socket and disposes the relay client exactly once even if called twice', () => { const ws = new FakeGatewaySocket(); const relayClient = new FakeRelayClient(); - const pending = new PendingGatewaySelection('addr', 'name', 'tok', ws as unknown as WebSocket, relayClient, () => { }); + const pending = new PendingGatewaySelection('addr', 'name', 'tok', ws, relayClient, () => { }); pending.dispose(); pending.dispose(); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md index bb5646bfea745f..3fdd35e316c689 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/REMOTE_AGENT_HOST_SESSIONS_PROVIDER.md @@ -109,9 +109,9 @@ A shared, provider-agnostic per-host "run agents on a dedicated agent host, or i - **Storage** — `RemoteAgentHostLocationPreferenceService` (`src/vs/platform/agentHost/browser/remoteAgentHostLocationPreferenceService.ts`) persists one JSON map under the single storage key `remoteAgentHost.locationPreferences` (`StorageScope.APPLICATION` / `StorageTarget.USER`). Parsing is defensive: malformed JSON, a non-object shape, or any individual entry with an unrecognized value is dropped without discarding the rest of the map. Registered as a desktop singleton in `sessions.desktop.main.ts`. - **Modal** — `promptRemoteAgentHostLocationPreference()` (`src/vs/platform/agentHost/common/remoteAgentHostLocationPreferenceDialog.ts`) is a reusable `IDialogService.prompt` using the standard `custom.buttonDetails` two-choice pattern (no custom DOM/CSS), offering "Dedicated Agent Host" / "VS Code Editor" with descriptive details and a Cancel button. `orderRemoteAgentHostLocationOptions()` puts the host's current preference first when there is one; because button order alone communicates nothing to screen readers, the current option's detail is additionally suffixed with a localized `" (Current)"` marker (`withCurrentPreferenceMarker()`) so the saved choice is visible and announced regardless of position, with no marker at all when there is no saved preference. - **Command** — `workbench.action.sessions.changeRemoteAgentHostLocationPreference` (`src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/remoteAgentHostLocationPreferenceCommand.ts`), F1 title **"Chat: Change Preferred Remote Agent Location"**, category `CHAT_CATEGORY`, gated on `ChatContextKeys.enabled` + `config.chat.remoteAgentHostsEnabled`. It enumerates SSH hosts from `IRemoteAgentHostService.configuredEntries` and tunnels from `ITunnelAgentHostService.getCachedTunnels()` via `collectRemoteAgentHostLocationTargets()`, which computes each SSH entry's stable `preferenceKey` with `computeSSHConnectionKey()` (deduplicating by that key) while separately recording its live `address` (`getEntryAddress()`) for provider lookup — tunnels use the same value for both. It quick-picks among the resulting `{ preferenceKey, address, label }` targets when there is more than one (`pickRemoteAgentHostLocationTarget`), resolves the matching live `IAgentHostSessionsProvider` by exact `remoteAddress` equality against the target's `address` (the pure `findAgentHostProviderForTarget()` helper, filtering `ISessionsProvidersService.getProviders()` through `isAgentHostProvider`), and delegates to the shared `changeRemoteAgentHostLocationPreference()` helper below — passing the target's `preferenceKey` — to open the modal, persist, and reconnect. -- **Per-host Options item** — the same preference can be changed directly from a single host's own "Options for {0}" quickpick (`showRemoteHostOptions()` in `src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts`, used by both Manage Remote Agent Hosts and host context menus). Each provider exposes `remoteLocationPreferenceKey` (`IAgentHostSessionsProvider`, defaulting to `remoteAddress` when a subclass has no separate stable identity) alongside its live `remoteAddress`; `showRemoteHostOptions()` reads that field into a local `preferenceKey` and passes it to `buildRemoteHostOptionItems()`. A **"Change Preferred Agent Location"** item appears only when `supportsRemoteAgentHostLocationPreference(preferenceKey ?? address)` — i.e. an `ssh:`/`tunnel:`-keyed *preference key* on desktop, never a live forwarded SSH address — and delegates to the same shared helper directly with the resolved provider, passing `preferenceKey` (not `address`). Since `IRemoteAgentHostLocationPreferenceService` is a desktop-only singleton and the web tunnel service does not consult a preference at all, the item (and the underlying service lookup) is unconditionally suppressed on web via an `isWebPlatform` parameter defaulting to the ambient `isWeb` constant. +- **Per-host Options item** — the same preference can be changed directly from a single host's own "Options for {0}" quickpick (`showRemoteHostOptions()` in `src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts`, used by both Manage Remote Agent Hosts and host context menus). Each provider exposes `remoteLocationPreferenceKey` (`IAgentHostSessionsProvider`, defaulting to `remoteAddress` when a subclass has no separate stable identity) alongside its live `remoteAddress`; `showRemoteHostOptions()` reads that field into a local `preferenceKey` and passes it to `buildRemoteHostOptionItems()`. A **"Change Preferred Agent Location"** item appears only when `supportsRemoteAgentHostLocationPreference(preferenceKey ?? address)` — i.e. an `ssh:`/`tunnel:`-keyed *preference key* on desktop, never a live forwarded SSH address — and delegates to the same shared helper directly with the resolved provider, passing `preferenceKey` (not `address`). The browser Agents window now registers the same location-preference service for direct Dev Tunnels connections, but this management item remains suppressed on web via an `isWebPlatform` parameter defaulting to the ambient `isWeb` constant; first-time web selection happens through the connection flow described below. - **Shared prompt/persist/reconnect helper** — `changeRemoteAgentHostLocationPreference()` (`src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteHostOptions.ts`) is the single implementation both surfaces above call, so they can't drift. It takes the stable `preferenceKey` (not a live address) and opens the modal seeded with the host's current preference under that key; on cancel it persists nothing and reconnects nothing. On confirm it **persists first under `preferenceKey`**, then — when a live provider was resolved — reconnects that host via the shared `reconnectRemoteHost(provider, remoteAgentHostService)` helper (which respects a provider's own SSH/tunnel `connect()` callback, falling back to `IRemoteAgentHostService.reconnect(provider.remoteAddress)` — the live address, independent of `preferenceKey`) under an `IProgressService.withProgress` notification titled "Reconnecting to {0}...". A successful reconnect shows a concise "Preference updated for {0}." confirmation; a failed reconnect keeps the already-persisted preference and surfaces a "Preference saved for {0}, but reconnection failed: {1}" error — it never silently swallows the failure. Interrupting the host's current session this way is intentional: the user just asked to change where its agents run. If no provider can be resolved for an otherwise-known target (an exceptional race, e.g. the host listed but not currently live), the preference is still saved and a warning explains it will apply the next time that host connects, instead of falsely claiming immediate effect. - **Provider wiring for `remoteLocationPreferenceKey`** — `RemoteAgentHostSessionsProvider` (`src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHostSessionsProvider.ts`) accepts an optional `preferenceKey` on its config and exposes it as `remoteLocationPreferenceKey`, defaulting to `address` when omitted (tunnels, WSL, cloud sandbox — hosts with no separate stable identity). `RemoteAgentHost.contribution.ts`'s `_createProvider()` computes this key for SSH entries with `computeSSHConnectionKey()` — the same helper `collectRemoteAgentHostLocationTargets()` uses — so the F1 command and the per-host Options item always agree on which key a given SSH host's preference is stored under, regardless of its current forwarded address. -- **Tunnel wiring** — `TunnelAgentHostService.connect()` (`tunnelAgentHostServiceImpl.ts`) resolves a protocol-v6 gateway selection via `resolveGatewaySelection()` instead of an endpoint `IQuickInputService` picker: it reads `IRemoteAgentHostLocationPreferenceService.getPreference('tunnel:')`, selects the live `editor` endpoint for a saved `'editor'` preference (falling back to a dedicated endpoint, without changing the preference, if none is live — this still applies to a background/non-user-initiated reconnect, since a stored editor preference is explicit consent), always falls back to dedicated for a saved `'dedicated'` preference, and — only with no saved preference, a live editor, and a user-initiated connect — prompts `promptRemoteAgentHostLocationPreference()` and persists the choice. A background connect or a host with no live editor never prompts. `selectEditorGatewayEndpoint`/`selectDedicatedGatewayFallback` pick deterministically (sorted by `instanceId`) among several live endpoints of the same type. Modal cancellation cancels the pending gateway selection (`ITunnelAgentHostMainService.cancelSelection`) exactly as an endpoint-picker cancellation used to, and persists nothing. Protocol-v5 tunnels (no gateway inventory) are unaffected and never prompt. +- **Tunnel wiring** — desktop `TunnelAgentHostService.connect()` and web `BrowserTunnelAgentHostService.connect()` resolve protocol-v6 gateway selection through the shared `resolveGatewaySelection()` policy. Startup auto-connect consults `getAutoConnectMode()`: a protocol-v6 tunnel with no saved `IRemoteAgentHostLocationPreferenceService` value opens the one-time location prompt automatically (with `IDialogService` sequencing concurrent prompts), while subsequent starts/reconnects use `'background'` mode and the persisted choice without prompting. When only one location is possible, delegated editor and dedicated-only selections persist that effective location without showing a redundant dialog. A saved `'editor'` preference selects the live editor endpoint (falling back to dedicated without overwriting the preference if none is live), while saved `'dedicated'` always selects dedicated. `selectEditorGatewayEndpoint`/`selectDedicatedGatewayFallback` pick deterministically (sorted by `instanceId`) among several live endpoints of the same type. Modal cancellation cancels the pending gateway selection and persists nothing. Protocol-v5 tunnels auto-connect immediately and never prompt. - **Rejected-selection failover** — a registry entry can outlive the agent host that published it (entries are only pruned once the owning PID dies), so the gateway inventory can advertise an `editor` endpoint whose socket is already gone. `completeSelection` then rejects with an error named `TUNNEL_GATEWAY_SELECTION_REJECTED_ERROR_NAME`, which is the one failure that proves the tunnel itself is healthy and only the chosen endpoint is dead. `TunnelAgentHostService._completeSelectionWithFallback()` treats it as exactly that: it re-runs `prepareSelection` and retries once with `selectGatewayFallbackAfterRejection()` (a dedicated host, never the instance just rejected), so the failover happens inside a single connect attempt instead of after the whole reconnect backoff window. Every other failure means the tunnel is unreachable and is rethrown unchanged, leaving the contribution to keep retrying the same destination and selection. A fallback never mutates the stored preference, so the editor host is preferred again as soon as it is back, and an `editor` → `standalone` substitution always notifies (see `shouldNotifyTunnelFailover`), including on a user-initiated connect that explicitly asked for the editor host. - **SSH wiring** — `SSHRemoteAgentHostService._resolveEndpointSelection()` (`sshRemoteAgentHostServiceImpl.ts`) applies the same preference-resolution rules to `onDidRequestEndpointSelection` candidates, keyed by `getPreference(request.connectionKey)` — `request.connectionKey` is computed with the same `computeSSHConnectionKey()` helper described above, so it always matches what the command/Options item persisted under — replacing its former endpoint `IQuickInputService` picker. Candidate selection is deterministic by `instanceId`; a dedicated fallback spawns a new host when none is live. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts new file mode 100644 index 00000000000000..8e442598556b32 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -0,0 +1,517 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; +import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; +import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; +import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; +import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; +import type { AhpServerNotification, JsonRpcResponse, ProtocolMessage } from '../../../../../platform/agentHost/common/state/sessionProtocol.js'; +import type { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; +import { + TunnelAgentHostConnector, + parseTunnelInfo, + type ITunnelRelayClient, + type ITunnelRelayClientFactory, + type ITunnelRelayClientSession, + type ITunnelSocketFactory, +} from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; +import { + isTunnelGatewaySelectionRejectedError, + TUNNEL_ADDRESS_PREFIX, + TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION, + TUNNEL_LAUNCHER_LABEL, + TUNNEL_MIN_PROTOCOL_VERSION, + type ICachedTunnel, + type ITunnelConnectResult, + type ITunnelGatewaySelection, + type ITunnelGatewaySelectionSession, + type ITunnelInfo, + ITunnelAgentHostService, + type TunnelAutoConnectMode, +} from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import type { ITunnelDuplexStream, ITunnelMessageSocket } from '../../../../../platform/agentHost/common/tunnelMessageSocket.js'; +import { connectWebSocketOverDuplex } from '../../../../../platform/agentHost/common/webSocketOverDuplex.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; +import { resolveGatewaySelection, selectGatewayFallbackAfterRejection } from '../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; +import { type IDevTunnelsWeb, type IDevTunnelsWebManagementClient, type IDevTunnelsWebRelayClient, type IDevTunnelsWebTunnel, loadDevTunnelsWeb } from './devTunnelsWebLoader.js'; +import { TunnelAgentHostStorage } from './tunnelAgentHostStorage.js'; +import { MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD, MALFORMED_FRAMES_LOG_CAP } from '../../../../../platform/agentHost/common/transportConstants.js'; + +const LOG_PREFIX = '[BrowserTunnelAgentHost]'; + +/** Creates relay clients directly from the lazily-loaded Dev Tunnels browser SDK. */ +export class BrowserTunnelRelayClientFactory implements ITunnelRelayClientFactory { + constructor( + private readonly _loadDevTunnelsWeb: () => Promise, + ) { + } + + async getTunnel(tunnelId: string, clusterId: string, authProvider: 'github' | 'microsoft', token: string): Promise { + const devTunnels = await this._loadDevTunnelsWeb(); + const managementClient = createManagementClient(devTunnels, token, authProvider); + const tunnel = await managementClient.getTunnel({ tunnelId, clusterId }, { + includePorts: true, + tokenScopes: ['connect'], + }); + if (!tunnel) { + return undefined; + } + + return { + tunnel, + createRelayClient: async () => { + const relayClient = new devTunnels.TunnelRelayTunnelClient(managementClient); + relayClient.acceptLocalConnectionsForForwardedPorts = false; + if (tunnel.endpoints) { + relayClient.endpoints = tunnel.endpoints; + } + return new BrowserTunnelRelayClient(relayClient, tunnel); + }, + }; + } +} + +class BrowserTunnelRelayClient implements ITunnelRelayClient { + constructor( + private readonly _relayClient: IDevTunnelsWebRelayClient, + private readonly _tunnel: IDevTunnelsWebTunnel, + ) { + } + + connect(): Promise { + return this._relayClient.connect(this._tunnel); + } + + waitForForwardedPort(port: number): Promise { + return this._relayClient.waitForForwardedPort(port); + } + + async connectToForwardedPort(port: number): Promise { + return await this._relayClient.connectToForwardedPort(port); + } + + dispose(): void { + this._relayClient.dispose(); + } +} + +/** Opens framed WebSockets directly over browser tunnel relay streams. */ +export class BrowserTunnelSocketFactory implements ITunnelSocketFactory { + constructor( + private readonly _loadDevTunnelsWeb: () => Promise, + ) { + } + + async open(stream: ITunnelDuplexStream, path: string): Promise { + const devTunnels = await this._loadDevTunnelsWeb(); + return await connectWebSocketOverDuplex(stream, { path, webSocketConnectionCtor: devTunnels.WebSocketConnection }); + } +} + +/** Browser service view of the transport-agnostic tunnel connector. */ +export interface ITunnelAgentHostConnector { + readonly onDidRelayMessage: Event<{ readonly connectionId: string; readonly data: string }>; + readonly onDidRelayClose: Event; + connect(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise; + prepareSelection(token: string, authProvider: 'github' | 'microsoft', tunnelId: string, clusterId: string): Promise; + completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise; + cancelSelection(selectionId: string): Promise; + relaySend(connectionId: string, message: string): Promise; + disconnect(connectionId: string): Promise; +} + +/** Construction options for injecting browser tunnel transports. */ +export interface IBrowserTunnelAgentHostServiceOptions { + readonly connector?: ITunnelAgentHostConnector; + readonly loadDevTunnelsWeb?: () => Promise; + readonly resolveGatewaySelection?: typeof resolveGatewaySelection; +} + +/** + * Connects browser Agents windows to Dev Tunnels without an embedder proxy. + */ +export class BrowserTunnelAgentHostService extends Disposable implements ITunnelAgentHostService { + declare readonly _serviceBrand: undefined; + + private readonly _storage = this._register(new TunnelAgentHostStorage(this._storageService)); + readonly onDidChangeTunnels: Event = this._storage.onDidChangeTunnels; + + private readonly _connector: ITunnelAgentHostConnector; + private readonly _resolveGatewaySelection: typeof resolveGatewaySelection; + private readonly _loadDevTunnelsWeb: () => Promise; + private _lastAuthProvider: 'github' | 'microsoft' | undefined; + + constructor( + @IRemoteAgentHostService private readonly _remoteAgentHostService: IRemoteAgentHostService, + @ILogService private readonly _logService: ILogService, + @IInstantiationService private readonly _instantiationService: IInstantiationService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @IAuthenticationService private readonly _authenticationService: IAuthenticationService, + @IProductService private readonly _productService: IProductService, + @IStorageService private readonly _storageService: IStorageService, + @IRemoteAgentHostLocationPreferenceService private readonly _locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + @IDialogService private readonly _dialogService: IDialogService, + options: IBrowserTunnelAgentHostServiceOptions = {}, + ) { + super(); + const load = options.loadDevTunnelsWeb ?? loadDevTunnelsWeb; + this._loadDevTunnelsWeb = load; + this._connector = options.connector ?? this._register(new TunnelAgentHostConnector( + new BrowserTunnelRelayClientFactory(load), + new BrowserTunnelSocketFactory(load), + this._logService, + )); + this._resolveGatewaySelection = options.resolveGatewaySelection ?? resolveGatewaySelection; + } + + async listTunnels(options?: { silent?: boolean }): Promise { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + return []; + } + + const auth = await this._getToken(options?.silent ?? false); + if (!auth) { + return []; + } + + try { + const managementClient = createManagementClient(await this._loadDevTunnelsWeb(), auth.token, auth.provider); + const tunnels = await managementClient.listTunnels(undefined, undefined, { + labels: [TUNNEL_LAUNCHER_LABEL], + requireAllLabels: true, + includePorts: true, + tokenScopes: ['connect'], + }); + const results = filterBrowserTunnelInfos(tunnels); + this._logService.info(`${LOG_PREFIX} Found ${results.length} tunnel(s) with agent host support`); + return results; + } catch (error) { + this._logService.error(`${LOG_PREFIX} Failed to enumerate tunnels`, error); + return []; + } + } + + getAutoConnectMode(tunnel: ITunnelInfo): TunnelAutoConnectMode { + return tunnel.protocolVersion >= TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION + && this._locationPreferenceService.getPreference(`${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`) === undefined + ? 'prompt' + : 'background'; + } + + async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft', options?: { readonly userInitiated?: boolean }): Promise { + if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { + throw new Error('Remote agent host connections are not enabled.'); + } + + const auth = authProvider + ? await this._getTokenForProvider(authProvider, false) + : await this._getToken(false); + if (!auth) { + throw new Error('No authentication available'); + } + + const result = await connectThroughTunnelGateway( + this._connector, + this._resolveGatewaySelection, + this._locationPreferenceService, + this._dialogService, + this._productService.nameShort, + auth, + tunnel, + options?.userInitiated ?? true, + ); + if (!result) { + return; + } + const transport = new BrowserTunnelConnectionTransport(result.connectionId, this._connector, this._logService); + const protocolClient = this._instantiationService.createInstance( + RemoteAgentHostProtocolClient, result.address, transport, undefined, undefined, agentsWindowAgentHostClientInfo, + ); + + let status: RemoteAgentHostConnectionStatus = RemoteAgentHostConnectionStatus.connected; + let connectError: unknown; + try { + await protocolClient.connect(); + this._logService.info(`${LOG_PREFIX} Protocol handshake completed with ${result.address}`); + } catch (error) { + const incompatible = RemoteAgentHostConnectionStatus.fromConnectError(error, [PROTOCOL_VERSION]); + if (!RemoteAgentHostConnectionStatus.isIncompatible(incompatible)) { + protocolClient.dispose(); + throw error; + } + status = incompatible; + connectError = error; + this._logService.warn(`${LOG_PREFIX} Incompatible with ${result.address}: ${incompatible.message}`); + } + + this.cacheTunnel(tunnel, auth.provider); + try { + await this._remoteAgentHostService.addManagedConnection({ + name: result.name, + connectionToken: result.connectionToken, + connection: { + type: RemoteAgentHostEntryType.Tunnel, + tunnelId: tunnel.tunnelId, + clusterId: tunnel.clusterId, + label: tunnel.name, + authProvider: auth.provider, + }, + }, protocolClient, undefined, status); + } catch (error) { + protocolClient.dispose(); + throw error; + } + + if (connectError) { + throw connectError; + } + } + + readonly canDeleteTunnels = true; + + async deleteTunnel(tunnel: ITunnelInfo): Promise { + const auth = await this._getToken(false); + if (!auth) { + throw new Error('No authentication available'); + } + const managementClient = createManagementClient(await this._loadDevTunnelsWeb(), auth.token, auth.provider); + await managementClient.deleteTunnel(tunnel); + this.removeCachedTunnel(tunnel.tunnelId); + } + + async disconnect(address: string): Promise { + await this._remoteAgentHostService.removeRemoteAgentHost(address); + this._storage.notifyTunnelsChanged(); + } + + async getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined> { + return (await this._getToken(options?.silent ?? true))?.provider; + } + + getCachedTunnels(): ICachedTunnel[] { + return this._storage.getCachedTunnels(); + } + + cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void { + this._storage.cacheTunnel({ + tunnelId: tunnel.tunnelId, + clusterId: tunnel.clusterId, + name: tunnel.name, + authProvider, + }); + } + + removeCachedTunnel(tunnelId: string): void { + this._storage.removeCachedTunnel(tunnelId); + } + + isAutoConnectSuppressed(tunnelId: string): boolean { + return this._storage.isAutoConnectSuppressed(tunnelId); + } + + suppressAutoConnect(tunnelId: string): void { + this._storage.suppressAutoConnect(tunnelId); + } + + clearAutoConnectSuppression(tunnelId: string): void { + this._storage.clearAutoConnectSuppression(tunnelId); + } + + private async _getToken(silent: boolean): Promise<{ readonly token: string; readonly provider: 'github' | 'microsoft' } | undefined> { + if (this._lastAuthProvider) { + const token = await this._getTokenForProvider(this._lastAuthProvider, silent); + if (token) { + return token; + } + } + + for (const provider of ['github', 'microsoft'] as const) { + if (provider === this._lastAuthProvider) { + continue; + } + const token = await this._getTokenForProvider(provider, true); + if (token) { + return token; + } + } + return undefined; + } + + private async _getTokenForProvider(provider: 'github' | 'microsoft', silent: boolean): Promise<{ readonly token: string; readonly provider: 'github' | 'microsoft' } | undefined> { + const scopes = this._productService.tunnelApplicationConfig?.authenticationProviders?.[provider]?.scopes ?? []; + if (scopes.length === 0) { + return undefined; + } + + try { + let sessions = await this._authenticationService.getSessions(provider, scopes, {}, true); + if (sessions.length === 0) { + const requestedScopes = new Set(scopes); + const allSessions = await this._authenticationService.getSessions(provider, undefined, {}, true); + let bestSession: typeof allSessions[number] | undefined; + let bestExtraScopes = Infinity; + for (const candidate of allSessions) { + const candidateScopes = new Set(candidate.scopes); + if (![...requestedScopes].every(scope => candidateScopes.has(scope))) { + continue; + } + const extraScopes = candidateScopes.size - requestedScopes.size; + if (extraScopes < bestExtraScopes) { + bestSession = candidate; + bestExtraScopes = extraScopes; + } + } + if (bestSession) { + sessions = [bestSession]; + } + } + if (sessions.length === 0 && !silent) { + sessions = [await this._authenticationService.createSession(provider, scopes, { activateImmediate: true })]; + } + const token = sessions[0]?.accessToken; + if (token) { + this._lastAuthProvider = provider; + return { token, provider }; + } + } catch (error) { + this._logService.debug(`${LOG_PREFIX} Failed to get ${provider} token: ${error}`); + } + return undefined; + } +} + +/** Connects through the versioned gateway when a tunnel advertises one. */ +export async function connectThroughTunnelGateway( + connector: ITunnelAgentHostConnector, + resolveSelection: typeof resolveGatewaySelection, + locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + dialogService: IDialogService, + productName: string, + auth: { readonly token: string; readonly provider: 'github' | 'microsoft' }, + tunnel: ITunnelInfo, + userInitiated: boolean, +): Promise { + const session = await connector.prepareSelection(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); + if (!session) { + return await connector.connect(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); + } + + let selection: ITunnelGatewaySelection | undefined; + try { + selection = await resolveSelection(locationPreferenceService, dialogService, { + hostKey: `${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`, + hostLabel: tunnel.name, + productName, + inventory: session.inventory, + userInitiated, + }); + } catch (error) { + await connector.cancelSelection(session.selectionId); + throw error; + } + if (!selection) { + await connector.cancelSelection(session.selectionId); + return undefined; + } + + try { + return await connector.completeSelection(session.selectionId, selection); + } catch (error) { + if (!isTunnelGatewaySelectionRejectedError(error)) { + throw error; + } + const retry = await connector.prepareSelection(auth.token, auth.provider, tunnel.tunnelId, tunnel.clusterId); + if (!retry) { + throw error; + } + const fallback = selectGatewayFallbackAfterRejection(selection, retry.inventory); + if (!fallback) { + await connector.cancelSelection(retry.selectionId); + throw error; + } + return await connector.completeSelection(retry.selectionId, fallback); + } +} + +/** Maps Dev Tunnels SDK descriptors to supported agent-host tunnels. */ +export function filterBrowserTunnelInfos( + tunnels: readonly IDevTunnelsWebTunnel[], +): ITunnelInfo[] { + return tunnels + .map(tunnel => parseTunnelInfo(tunnel)) + .filter((tunnel): tunnel is ITunnelInfo => !!tunnel && tunnel.protocolVersion >= TUNNEL_MIN_PROTOCOL_VERSION); +} + +class BrowserTunnelConnectionTransport extends Disposable implements IProtocolTransport { + readonly clientConnectionKind = AgentHostClientConnectionKind.DevTunnel; + + private readonly _onMessage = this._register(new Emitter()); + readonly onMessage = this._onMessage.event; + + private readonly _onClose = this._register(new Emitter()); + readonly onClose = this._onClose.event; + private _malformedFrames = 0; + + constructor( + private readonly _connectionId: string, + private readonly _connector: ITunnelAgentHostConnector, + private readonly _logService: ILogService, + ) { + super(); + this._register(this._connector.onDidRelayMessage(message => { + if (message.connectionId === this._connectionId) { + try { + this._onMessage.fire(JSON.parse(message.data) as ProtocolMessage); + } catch (error) { + this._malformedFrames++; + if (this._malformedFrames <= MALFORMED_FRAMES_LOG_CAP) { + const preview = message.data.length > 80 ? `${message.data.slice(0, 80)}…` : message.data; + this._logService.warn(`${LOG_PREFIX} Malformed relay frame #${this._malformedFrames} (len=${message.data.length}): ${preview}`, error); + } + if (this._malformedFrames > MALFORMED_FRAMES_FORCE_CLOSE_THRESHOLD) { + void this._connector.disconnect(this._connectionId); + } + } + } + })); + this._register(this._connector.onDidRelayClose(connectionId => { + if (connectionId === this._connectionId) { + this._onClose.fire(); + } + })); + } + + send(message: ProtocolMessage | AhpServerNotification | JsonRpcResponse): void { + void this._connector.relaySend(this._connectionId, JSON.stringify(message)); + } + + override dispose(): void { + void this._connector.disconnect(this._connectionId); + super.dispose(); + } +} + +function createManagementClient( + devTunnels: IDevTunnelsWeb, + token: string, + authProvider: 'github' | 'microsoft', +): IDevTunnelsWebManagementClient { + const authorization = authProvider === 'github' ? `github ${token}` : `Bearer ${token}`; + return new devTunnels.TunnelManagementHttpClient( + 'vscode-sessions', + devTunnels.ManagementApiVersions.Version20230927preview, + async () => authorization, + ); +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts new file mode 100644 index 00000000000000..847d3672b3b2ec --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsWebLoader.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import type { TunnelRelayTunnelClient } from '@microsoft/dev-tunnels-connections'; +import type { TunnelManagementHttpClient } from '@microsoft/dev-tunnels-management'; +import { AppResourcePath, FileAccess } from '../../../../../base/common/network.js'; +import type { ITunnelDescriptor } from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; +import type { ITunnelDuplexStream, WebSocketConnectionCtor } from '../../../../../platform/agentHost/common/tunnelMessageSocket.js'; + +const devTunnelsWebBundlePath: AppResourcePath = 'vs/sessions/contrib/providers/remoteAgentHost/browser/devTunnelsModule.js'; + +/** The subset of tunnel metadata consumed by the browser management and relay adapters. */ +export interface IDevTunnelsWebTunnel extends ITunnelDescriptor { + readonly endpoints?: object; +} + +/** Options for browser tunnel management requests. */ +export interface IDevTunnelsWebRequestOptions { + readonly labels?: string[]; + readonly requireAllLabels?: boolean; + readonly includePorts?: boolean; + readonly tokenScopes?: string[]; + readonly limit?: number; +} + +/** Browser-compatible subset of the Dev Tunnels management client. */ +export interface IDevTunnelsWebManagementClient { + listTunnels( + clusterId: string | undefined, + domain: string | undefined, + options: IDevTunnelsWebRequestOptions, + ): Promise; + getTunnel( + tunnel: Pick, + options: IDevTunnelsWebRequestOptions, + ): Promise; + deleteTunnel(tunnel: Pick): Promise; +} + +/** Browser-compatible subset of the Dev Tunnels relay client. */ +export interface IDevTunnelsWebRelayClient { + acceptLocalConnectionsForForwardedPorts: boolean; + endpoints?: object; + connect(tunnel: IDevTunnelsWebTunnel): Promise; + waitForForwardedPort(port: number): Promise; + connectToForwardedPort(port: number): Promise; + dispose(): void; +} + +/** The lazily-loaded Dev Tunnels browser bundle. */ +export interface IDevTunnelsWeb { + readonly TunnelManagementHttpClient: new ( + userAgent: string, + apiVersion: object, + userTokenCallback: () => Promise, + ) => IDevTunnelsWebManagementClient; + readonly ManagementApiVersions: { + readonly Version20230927preview: object; + }; + readonly TunnelRelayTunnelClient: new (managementClient: IDevTunnelsWebManagementClient) => IDevTunnelsWebRelayClient; + readonly TunnelAccessScopes: object; + readonly WebSocketConnection: WebSocketConnectionCtor; +} + +/** + * Compile-time proof that the structural subsets above remain a valid view of the + * real SDK types. The subsets exist so tests can supply small fakes — implementing + * the full SDK surface in a fake is impractical — but they would silently drift if + * the SDK renamed a member or changed a signature. These aliases fail the build if + * that happens; they are exported only so they count as used. + */ +type AssertSatisfies = TActual; +export type ManagementClientConformance = AssertSatisfies; +export type RelayClientConformance = AssertSatisfies; + +let devTunnelsWeb: Promise | undefined; + +/** Loads the browser-compatible Dev Tunnels SDK bundle on first use. */ +export function loadDevTunnelsWeb(): Promise { + devTunnelsWeb ??= (async () => { + // This generated browser-only module is emitted beside its loader. + const devTunnelsWebUrl = FileAccess.asBrowserUri(devTunnelsWebBundlePath).toString(true); + // Keep the URL runtime-resolved so bundlers do not rewrite the import. + const module = await import(/* webpackIgnore: true */ /* @vite-ignore */ `${devTunnelsWebUrl}`) as IDevTunnelsWeb; + return module; + })(); + return devTunnelsWeb; +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts index c70c0d3713fd86..5f4f585f21e74a 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHost.contribution.ts @@ -937,7 +937,13 @@ export class TunnelAgentHostContribution extends Disposable implements IWorkbenc c => c.address === address && RemoteAgentHostConnectionStatus.isConnected(c.status) ); if (!alreadyConnected) { - this._connectTunnel(address, { userInitiated: false }); + const mode = this._tunnelService.getAutoConnectMode(tunnel); + if (mode === 'prompt') { + this._logService.info(`[TunnelAgentHost] Prompting for the initial agent host location for ${address}`); + this._connectTunnel(address, { userInitiated: true }); + } else { + this._connectTunnel(address, { userInitiated: false }); + } } } } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHostStorage.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHostStorage.ts new file mode 100644 index 00000000000000..5542ee920e1fa9 --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/tunnelAgentHostStorage.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter, Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { type ICachedTunnel } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; + +const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels'; +const AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY = 'tunnelAgentHost.autoConnectSuppressedTunnels'; + +/** Persists the tunnel cache and explicit auto-connect suppressions shared by browser tunnel services. */ +export class TunnelAgentHostStorage extends Disposable { + private readonly _onDidChangeTunnels = this._register(new Emitter()); + readonly onDidChangeTunnels: Event = this._onDidChangeTunnels.event; + + constructor( + @IStorageService private readonly _storageService: IStorageService, + ) { + super(); + } + + getCachedTunnels(): ICachedTunnel[] { + const raw = this._storageService.get(CACHED_TUNNELS_KEY, StorageScope.APPLICATION); + if (!raw) { + return []; + } + try { + return JSON.parse(raw); + } catch { + return []; + } + } + + cacheTunnel(tunnel: ICachedTunnel): void { + const cached = this.getCachedTunnels(); + const filtered = cached.filter(candidate => candidate.tunnelId !== tunnel.tunnelId); + filtered.unshift(tunnel); + this.clearAutoConnectSuppression(tunnel.tunnelId); + this._storeCachedTunnels(filtered); + this._onDidChangeTunnels.fire(); + } + + removeCachedTunnel(tunnelId: string): void { + const cached = this.getCachedTunnels(); + this._storeCachedTunnels(cached.filter(tunnel => tunnel.tunnelId !== tunnelId)); + this.clearAutoConnectSuppression(tunnelId); + this._onDidChangeTunnels.fire(); + } + + isAutoConnectSuppressed(tunnelId: string): boolean { + return this._getAutoConnectSuppressedTunnels().has(tunnelId); + } + + suppressAutoConnect(tunnelId: string): void { + const suppressed = this._getAutoConnectSuppressedTunnels(); + suppressed.add(tunnelId); + this._storeAutoConnectSuppressedTunnels(suppressed); + } + + clearAutoConnectSuppression(tunnelId: string): void { + const suppressed = this._getAutoConnectSuppressedTunnels(); + if (!suppressed.delete(tunnelId)) { + return; + } + this._storeAutoConnectSuppressedTunnels(suppressed); + } + + /** Notifies consumers that a tunnel connection changed without changing its cache entry. */ + notifyTunnelsChanged(): void { + this._onDidChangeTunnels.fire(); + } + + private _storeCachedTunnels(tunnels: ICachedTunnel[]): void { + if (tunnels.length === 0) { + this._storageService.remove(CACHED_TUNNELS_KEY, StorageScope.APPLICATION); + } else { + this._storageService.store(CACHED_TUNNELS_KEY, JSON.stringify(tunnels), StorageScope.APPLICATION, StorageTarget.USER); + } + } + + private _getAutoConnectSuppressedTunnels(): Set { + const raw = this._storageService.get(AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY, StorageScope.APPLICATION); + if (!raw) { + return new Set(); + } + try { + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed)) { + return new Set(); + } + return new Set(parsed.filter(item => typeof item === 'string')); + } catch { + return new Set(); + } + } + + private _storeAutoConnectSuppressedTunnels(tunnelIds: Set): void { + if (tunnelIds.size === 0) { + this._storageService.remove(AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY, StorageScope.APPLICATION); + } else { + this._storageService.store(AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY, JSON.stringify([...tunnelIds]), StorageScope.APPLICATION, StorageTarget.USER); + } + } +} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts index 67265a6c1ac402..e1fdbc042280af 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.contribution.ts @@ -3,8 +3,120 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Event } from '../../../../../base/common/event.js'; +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; +import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { ITunnelAgentHostService, type ICachedTunnel, type ITunnelInfo, type TunnelAutoConnectMode } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { IDialogService } from '../../../../../platform/dialogs/common/dialogs.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; -import { ITunnelAgentHostService } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; +import { ILogService } from '../../../../../platform/log/common/log.js'; +import { IProductService } from '../../../../../platform/product/common/productService.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; +import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; +import { IBrowserWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/browser/environmentService.js'; +import { BrowserTunnelAgentHostService } from './browserTunnelAgentHostService.js'; import { WebTunnelAgentHostService } from './webTunnelAgentHostService.js'; -registerSingleton(ITunnelAgentHostService, WebTunnelAgentHostService, InstantiationType.Delayed); +/** + * Selects the embedder proxy when provided and otherwise connects directly from the browser. + */ +class BrowserTunnelAgentHostServiceSelector extends Disposable implements ITunnelAgentHostService { + declare readonly _serviceBrand: undefined; + + private readonly _delegate: ITunnelAgentHostService; + readonly onDidChangeTunnels: Event; + + constructor( + @IRemoteAgentHostService remoteAgentHostService: IRemoteAgentHostService, + @IBrowserWorkbenchEnvironmentService environmentService: IBrowserWorkbenchEnvironmentService, + @ILogService logService: ILogService, + @IInstantiationService instantiationService: IInstantiationService, + @IConfigurationService configurationService: IConfigurationService, + @IAuthenticationService authenticationService: IAuthenticationService, + @IProductService productService: IProductService, + @IStorageService storageService: IStorageService, + @IRemoteAgentHostLocationPreferenceService locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + @IDialogService dialogService: IDialogService, + ) { + super(); + this._delegate = this._register(environmentService.options?.tunnelDiscoveryProvider + ? new WebTunnelAgentHostService( + remoteAgentHostService, + environmentService, + logService, + instantiationService, + configurationService, + authenticationService, + storageService, + ) + : new BrowserTunnelAgentHostService( + remoteAgentHostService, + logService, + instantiationService, + configurationService, + authenticationService, + productService, + storageService, + locationPreferenceService, + dialogService, + )); + this.onDidChangeTunnels = this._delegate.onDidChangeTunnels; + } + + listTunnels(options?: { silent?: boolean }): Promise { + return this._delegate.listTunnels(options); + } + + getAutoConnectMode(tunnel: ITunnelInfo): TunnelAutoConnectMode { + return this._delegate.getAutoConnectMode(tunnel); + } + + connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft', options?: { readonly userInitiated?: boolean }): Promise { + return this._delegate.connect(tunnel, authProvider, options); + } + + get canDeleteTunnels(): boolean { + return this._delegate.canDeleteTunnels; + } + + deleteTunnel(tunnel: ITunnelInfo): Promise { + return this._delegate.deleteTunnel(tunnel); + } + + disconnect(address: string): Promise { + return this._delegate.disconnect(address); + } + + getCachedTunnels(): ICachedTunnel[] { + return this._delegate.getCachedTunnels(); + } + + cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void { + this._delegate.cacheTunnel(tunnel, authProvider); + } + + removeCachedTunnel(tunnelId: string): void { + this._delegate.removeCachedTunnel(tunnelId); + } + + isAutoConnectSuppressed(tunnelId: string): boolean { + return this._delegate.isAutoConnectSuppressed(tunnelId); + } + + suppressAutoConnect(tunnelId: string): void { + this._delegate.suppressAutoConnect(tunnelId); + } + + clearAutoConnectSuppression(tunnelId: string): void { + this._delegate.clearAutoConnectSuppression(tunnelId); + } + + getAuthProvider(options?: { silent?: boolean }): Promise<'github' | 'microsoft' | undefined> { + return this._delegate.getAuthProvider(options); + } +} + +registerSingleton(ITunnelAgentHostService, BrowserTunnelAgentHostServiceSelector, InstantiationType.Delayed); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts index b65f4d39985c90..76375324f2871d 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -8,6 +8,7 @@ import { Disposable } from '../../../../../base/common/lifecycle.js'; import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { AgentHostClientConnectionKind } from '../../../../../platform/agentHost/common/agentHostTelemetry.js'; +import { deriveConnectionToken } from '../../../../../platform/agentHost/common/tunnelAgentHostConnector.js'; import { RemoteAgentHostEntryType, IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import type { IProtocolTransport } from '../../../../../platform/agentHost/common/state/sessionTransport.js'; @@ -20,22 +21,19 @@ import { TunnelTags, type ICachedTunnel, type ITunnelInfo, + type TunnelAutoConnectMode, } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; +import { IStorageService } from '../../../../../platform/storage/common/storage.js'; import type { IDiscoveredTunnel, ITunnelConnection, ITunnelDiscoveryProvider } from '../../../../../workbench/browser/web.api.js'; import { IBrowserWorkbenchEnvironmentService } from '../../../../../workbench/services/environment/browser/environmentService.js'; import { IAuthenticationService } from '../../../../../workbench/services/authentication/common/authentication.js'; +import { TunnelAgentHostStorage } from './tunnelAgentHostStorage.js'; const LOG_PREFIX = '[WebTunnelAgentHost]'; -/** Storage key for recently used tunnel cache. */ -const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels'; -/** Storage key for tunnels the user explicitly disconnected. */ -const AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY = 'tunnelAgentHost.autoConnectSuppressedTunnels'; - /** * Web (browser) implementation of {@link ITunnelAgentHostService}. * @@ -51,8 +49,8 @@ const AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY = 'tunnelAgentHost.autoConnectSuppress export class WebTunnelAgentHostService extends Disposable implements ITunnelAgentHostService { declare readonly _serviceBrand: undefined; - private readonly _onDidChangeTunnels = this._register(new Emitter()); - readonly onDidChangeTunnels: Event = this._onDidChangeTunnels.event; + private readonly _storage = this._register(new TunnelAgentHostStorage(this._storageService)); + readonly onDidChangeTunnels: Event = this._storage.onDidChangeTunnels; private readonly _discoveryProvider: ITunnelDiscoveryProvider | undefined; @@ -134,6 +132,10 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen }; } + getAutoConnectMode(): TunnelAutoConnectMode { + return 'background'; + } + // Connection (via embedder) async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): Promise { @@ -225,7 +227,7 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen async disconnect(address: string): Promise { await this._remoteAgentHostService.removeRemoteAgentHost(address); - this._onDidChangeTunnels.fire(); + this._storage.notifyTunnelsChanged(); } // Auth @@ -243,86 +245,32 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen // Tunnel cache getCachedTunnels(): ICachedTunnel[] { - const raw = this._storageService.get(CACHED_TUNNELS_KEY, StorageScope.APPLICATION); - if (!raw) { - return []; - } - try { - return JSON.parse(raw); - } catch { - return []; - } + return this._storage.getCachedTunnels(); } cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void { - const cached = this.getCachedTunnels(); - const filtered = cached.filter(t => t.tunnelId !== tunnel.tunnelId); - filtered.unshift({ + this._storage.cacheTunnel({ tunnelId: tunnel.tunnelId, clusterId: tunnel.clusterId, name: tunnel.name, authProvider, }); - this.clearAutoConnectSuppression(tunnel.tunnelId); - this._storeCachedTunnels(filtered); - this._onDidChangeTunnels.fire(); } removeCachedTunnel(tunnelId: string): void { - const cached = this.getCachedTunnels(); - this._storeCachedTunnels(cached.filter(t => t.tunnelId !== tunnelId)); - this.clearAutoConnectSuppression(tunnelId); - this._onDidChangeTunnels.fire(); + this._storage.removeCachedTunnel(tunnelId); } isAutoConnectSuppressed(tunnelId: string): boolean { - return this._getAutoConnectSuppressedTunnels().has(tunnelId); + return this._storage.isAutoConnectSuppressed(tunnelId); } suppressAutoConnect(tunnelId: string): void { - const suppressed = this._getAutoConnectSuppressedTunnels(); - suppressed.add(tunnelId); - this._storeAutoConnectSuppressedTunnels(suppressed); + this._storage.suppressAutoConnect(tunnelId); } clearAutoConnectSuppression(tunnelId: string): void { - const suppressed = this._getAutoConnectSuppressedTunnels(); - if (!suppressed.delete(tunnelId)) { - return; - } - this._storeAutoConnectSuppressedTunnels(suppressed); - } - - private _storeCachedTunnels(tunnels: ICachedTunnel[]): void { - if (tunnels.length === 0) { - this._storageService.remove(CACHED_TUNNELS_KEY, StorageScope.APPLICATION); - } else { - this._storageService.store(CACHED_TUNNELS_KEY, JSON.stringify(tunnels), StorageScope.APPLICATION, StorageTarget.USER); - } - } - - private _getAutoConnectSuppressedTunnels(): Set { - const raw = this._storageService.get(AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY, StorageScope.APPLICATION); - if (!raw) { - return new Set(); - } - try { - const parsed: unknown = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return new Set(); - } - return new Set(parsed.filter(item => typeof item === 'string')); - } catch { - return new Set(); - } - } - - private _storeAutoConnectSuppressedTunnels(tunnelIds: Set): void { - if (tunnelIds.size === 0) { - this._storageService.remove(AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY, StorageScope.APPLICATION); - } else { - this._storageService.store(AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY, JSON.stringify([...tunnelIds]), StorageScope.APPLICATION, StorageTarget.USER); - } + this._storage.clearAutoConnectSuppression(tunnelId); } } @@ -386,25 +334,3 @@ class TunnelConnectionTransport extends Disposable implements IProtocolTransport super.dispose(); } } - -/** - * Derive a connection token from a tunnel ID using the same convention - * as the VS Code CLI and the desktop shared-process service. - */ -async function deriveConnectionToken(tunnelId: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(tunnelId); - const hashBuffer = await globalThis.crypto.subtle.digest('SHA-256', data); - const hashArray = new Uint8Array(hashBuffer); - - // Base64url encode (matches Node's createHash('sha256').digest('base64url')) - let result = btoa(String.fromCharCode(...hashArray)) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, ''); - - if (result.startsWith('-')) { - result = 'a' + result; - } - return result; -} diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts index 73672a5f1c3ea3..5368d6749291cc 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/electron-browser/tunnelAgentHostServiceImpl.ts @@ -20,30 +20,44 @@ import { IProductService } from '../../../../../platform/product/common/productS import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; import { IRemoteAgentHostService, RemoteAgentHostConnectionStatus, RemoteAgentHostEntryType, RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; import { IRemoteAgentHostLocationPreferenceService } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; -import { promptRemoteAgentHostLocationPreference } from '../../../../../platform/agentHost/common/remoteAgentHostLocationPreferenceDialog.js'; import { PROTOCOL_VERSION } from '../../../../../platform/agentHost/common/state/protocol/version/registry.js'; import { isTunnelGatewaySelectionRejectedError, ITunnelAgentHostService, TUNNEL_ADDRESS_PREFIX, TUNNEL_AGENT_HOST_CHANNEL, + TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION, TunnelAgentHostsSettingId, type ICachedTunnel, type ITunnelAgentHostMainService, type ITunnelConnectResult, - type ITunnelGatewayEndpoint, type ITunnelGatewayInventory, type ITunnelGatewaySelection, type ITunnelGatewaySelectionSession, type ITunnelInfo, - type TunnelGatewayServerType, + type TunnelAutoConnectMode, } from '../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { AhpJsonlLogger } from '../../../../../platform/agentHost/common/ahpJsonlLogger.js'; import { AgentHostAhpJsonlLoggingSettingId } from '../../../../../platform/agentHost/common/agentService.js'; +import { + resolveGatewaySelection, + selectGatewayFallbackAfterRejection, + TunnelFailoverTracker, +} from '../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; import { RemoteAgentHostProtocolClient } from '../../../../../platform/agentHost/browser/remoteAgentHostProtocolClient.js'; import { agentsWindowAgentHostClientInfo } from '../../../../../platform/agentHost/common/agentHostClientInfo.js'; import { TunnelRelayTransport } from '../../../../../platform/agentHost/electron-browser/tunnelRelayTransport.js'; +export { + type IGatewaySelectionRequest, + resolveGatewaySelection, + selectDedicatedGatewayFallback, + selectEditorGatewayEndpoint, + selectGatewayFallbackAfterRejection, + shouldNotifyTunnelFailover, + TunnelFailoverTracker, +} from '../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; + const LOG_PREFIX = '[TunnelAgentHost]'; /** Storage key for recently used tunnel cache. */ @@ -51,157 +65,12 @@ const CACHED_TUNNELS_KEY = 'tunnelAgentHost.recentTunnels'; /** Storage key for tunnels the user explicitly disconnected. */ const AUTO_CONNECT_SUPPRESSED_TUNNELS_KEY = 'tunnelAgentHost.autoConnectSuppressedTunnels'; -/** Endpoints of `type`, sorted deterministically by `instanceId`. */ -function sortedGatewayEndpoints(inventory: ITunnelGatewayInventory, type: TunnelGatewayServerType): ITunnelGatewayEndpoint[] { - return inventory.endpoints - .filter(endpoint => endpoint.type === type) - .sort((a, b) => a.instanceId.localeCompare(b.instanceId)); -} - -/** The live `editor` endpoint to use, chosen deterministically when several exist. */ -export function selectEditorGatewayEndpoint(inventory: ITunnelGatewayInventory): ITunnelGatewayEndpoint | undefined { - return sortedGatewayEndpoints(inventory, 'editor')[0]; -} - -/** - * Deterministic dedicated-agent-host selection: reuse the first live - * standalone instance if one exists, otherwise request a new dedicated one. - * - * Callers must not reach this on a delegated tunnel — {@link resolveGatewaySelection} - * short-circuits before any dedicated fallback, since a dedicated host behind - * an editor-bound tunnel would outlive the tunnel and be unreachable. - */ -export function selectDedicatedGatewayFallback(inventory: ITunnelGatewayInventory): ITunnelGatewaySelection { - const standalone = sortedGatewayEndpoints(inventory, 'standalone')[0]; - return standalone ? { instanceId: standalone.instanceId } : { newDedicated: true }; -} - -/** - * The selection to retry with after the gateway *rejected* `rejected` (see - * {@link isTunnelGatewaySelectionRejectedError}) — the tunnel is up and only - * the endpoint we asked for is gone, typically an `editor` endpoint whose - * agent host exited while its registry entry lingered. Picks a dedicated - * host exactly like {@link selectDedicatedGatewayFallback}, but never the - * instance that was just rejected. A delegated tunnel instead retries only - * its bound endpoint: it must never select or spawn a dedicated host. - * - * Returns `undefined` when there is nothing meaningful left to try: the - * rejected selection was itself a request for a brand new dedicated - * instance, so the gateway failed to *spawn* a host rather than failing to - * reach an existing one, and retrying would just fail the same way. - */ -export function selectGatewayFallbackAfterRejection(rejected: ITunnelGatewaySelection, inventory: ITunnelGatewayInventory): ITunnelGatewaySelection | undefined { - if (inventory.delegatedInstanceId) { - return { instanceId: inventory.delegatedInstanceId }; - } - if (!hasKey(rejected, { instanceId: true })) { - return undefined; - } - const standalone = sortedGatewayEndpoints(inventory, 'standalone').find(endpoint => endpoint.instanceId !== rejected.instanceId); - return standalone ? { instanceId: standalone.instanceId } : { newDedicated: true }; -} - /** Whether `selection` picked a live `editor` endpoint out of `inventory`. */ function isEditorGatewaySelection(selection: ITunnelGatewaySelection, inventory: ITunnelGatewayInventory): boolean { return hasKey(selection, { instanceId: true }) && inventory.endpoints.some(endpoint => endpoint.instanceId === selection.instanceId && endpoint.type === 'editor'); } -/** Inputs needed to resolve a protocol-v6 gateway endpoint selection. See {@link resolveGatewaySelection}. */ -export interface IGatewaySelectionRequest { - /** Stable {@link IRemoteAgentHostLocationPreferenceService} key, e.g. `tunnel:`. */ - readonly hostKey: string; - /** User-facing tunnel name shown in the location-preference modal. */ - readonly hostLabel: string; - /** Product name (typically {@link IProductService.nameShort}) substituted into the modal's editor-option detail text. */ - readonly productName: string; - readonly inventory: ITunnelGatewayInventory; - readonly userInitiated: boolean; -} - -/** - * Resolve which agent host endpoint to select for a protocol-v6 gateway - * session, driven by the user's saved {@link IRemoteAgentHostLocationPreferenceService} - * preference for the host rather than an endpoint picker: - * - * - A saved `'editor'` preference selects the live editor endpoint if one - * exists, or falls back to a dedicated endpoint (without changing the - * preference) if it doesn't — a stored editor preference is explicit - * consent, so this applies even for a background reconnect. - * - A saved `'dedicated'` preference always falls back to a dedicated - * endpoint and never prompts. - * - With no saved preference: falls back to a dedicated endpoint (no prompt, - * no persistence) when no editor endpoint exists, or for a background - * connection; otherwise prompts with {@link promptRemoteAgentHostLocationPreference} - * and persists the user's choice. - * - * Returns `undefined` only when the user cancels that modal. - */ -export async function resolveGatewaySelection( - locationPreferenceService: IRemoteAgentHostLocationPreferenceService, - dialogService: IDialogService, - request: IGatewaySelectionRequest, -): Promise { - const { hostKey, hostLabel, productName, inventory, userInitiated } = request; - // A dedicated host behind an editor-bound tunnel would be orphaned when - // that editor exits, so this tunnel may only use its delegated endpoint. - if (inventory.delegatedInstanceId) { - return { instanceId: inventory.delegatedInstanceId }; - } - const editor = selectEditorGatewayEndpoint(inventory); - const preference = locationPreferenceService.getPreference(hostKey); - - if (preference === 'editor') { - return editor ? { instanceId: editor.instanceId } : selectDedicatedGatewayFallback(inventory); - } - if (preference === 'dedicated' || !editor || !userInitiated) { - return selectDedicatedGatewayFallback(inventory); - } - - const chosen = await promptRemoteAgentHostLocationPreference(dialogService, hostLabel, productName); - if (!chosen) { - return undefined; - } - locationPreferenceService.setPreference(hostKey, chosen); - return chosen === 'editor' ? { instanceId: editor.instanceId } : selectDedicatedGatewayFallback(inventory); -} - -/** - * Decide whether a tunnel-failover notification should be shown after a - * connection attempt's {@link IRemoteAgentHostService.addManagedConnection} - * has already succeeded. Fires in two cases, both of which mean the editor - * process that used to host the connection is gone and a dedicated agent - * host silently took its place: - * - * - `editorFallback`: this very attempt asked the gateway for a live-looking - * `editor` endpoint, was rejected because it is not actually reachable, - * and transparently retried against a dedicated host. The substitution - * happened inside a single connect, so there is no earlier registration to - * compare against — and it is equally surprising for a user-initiated - * connect, which explicitly asked for the editor host. A stale `editor` - * entry can linger in the remote registry for as long as its PID does, so - * every later reconnect repeats the same fallback; those must stay quiet - * once the address is already known to be on a `standalone` host, or the - * user would be notified again on every reconnect. - * - An automatic/background reconnect (never a user-initiated one) that - * moved a previously `editor`-owned endpoint to a `standalone` one for the - * same stable tunnel address. - * - * Exported so the decision can be unit tested without constructing the full - * service. - */ -export function shouldNotifyTunnelFailover( - previousServerType: TunnelGatewayServerType | 'unknown' | undefined, - newServerType: TunnelGatewayServerType | 'unknown', - userInitiated: boolean, - editorFallback = false, -): boolean { - if (editorFallback) { - return newServerType === 'standalone' && previousServerType !== 'standalone'; - } - return !userInitiated && previousServerType === 'editor' && newServerType === 'standalone'; -} - /** * Whether the tunnel-failover tracker/notification step should run at all * for a completed `connect()` attempt. Must be `false` whenever the @@ -219,33 +88,6 @@ export function shouldTrackTunnelConnection(connectError: unknown): boolean { return !connectError; } -/** - * Retains the last successfully registered endpoint's server type per - * stable tunnel address (`tunnel:`) so a later automatic - * reconnect for the same tunnel can detect a silent editor → standalone - * failover via {@link shouldNotifyTunnelFailover}. Entries are only ever - * written after a successful {@link IRemoteAgentHostService.addManagedConnection} - * registration and are deliberately never cleared on relay closure, so the - * comparison survives disconnect/reconnect cycles for the tunnel's - * lifetime. Exported (and kept free of any IPC/protocol dependencies) so - * the retention + decision behavior can be unit tested in isolation. - */ -export class TunnelFailoverTracker { - private readonly _lastSelectedServerType = new Map(); - - /** - * Record a successful registration for `address` and report whether it - * should trigger a failover notification. Always updates the retained - * metadata, regardless of the returned value. - */ - recordAndShouldNotify(address: string, newServerType: TunnelGatewayServerType | 'unknown', userInitiated: boolean, editorFallback = false): boolean { - const previousServerType = this._lastSelectedServerType.get(address); - const notify = shouldNotifyTunnelFailover(previousServerType, newServerType, userInitiated, editorFallback); - this._lastSelectedServerType.set(address, newServerType); - return notify; - } -} - /** * Renderer-side implementation of {@link ITunnelAgentHostService} that * delegates tunnel SDK operations to the shared process via IPC, then @@ -306,6 +148,13 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo return this._mainService.listTunnels(auth.token, auth.provider, additionalNames.length > 0 ? additionalNames : undefined); } + getAutoConnectMode(tunnel: ITunnelInfo): TunnelAutoConnectMode { + return tunnel.protocolVersion >= TUNNEL_GATEWAY_MIN_PROTOCOL_VERSION + && this._locationPreferenceService.getPreference(`${TUNNEL_ADDRESS_PREFIX}${tunnel.tunnelId}`) === undefined + ? 'prompt' + : 'background'; + } + async connect(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft', options?: { readonly userInitiated?: boolean }): Promise { if (!this._configurationService.getValue(RemoteAgentHostsEnabledSettingId)) { throw new Error('Remote agent host connections are not enabled.'); @@ -337,7 +186,9 @@ export class TunnelAgentHostService extends Disposable implements ITunnelAgentHo userInitiated: options?.userInitiated ?? true, }); if (!selection) { - this._logService.info(`${LOG_PREFIX} Agent host selection cancelled for tunnel '${tunnel.name}'`); + this._logService.info(options?.userInitiated === false + ? `${LOG_PREFIX} Deferring tunnel '${tunnel.name}' until the user chooses an agent host location` + : `${LOG_PREFIX} Agent host selection cancelled for tunnel '${tunnel.name}'`); await this._mainService.cancelSelection(session.selectionId); return; } diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts new file mode 100644 index 00000000000000..5f07d5ec198aed --- /dev/null +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/browserTunnelAgentHostService.test.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../../base/common/event.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IRemoteAgentHostLocationPreferenceService } from '../../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; +import { type ITunnelConnectResult, type ITunnelGatewaySelection, type ITunnelGatewaySelectionSession, type ITunnelInfo } from '../../../../../../platform/agentHost/common/tunnelAgentHost.js'; +import { resolveGatewaySelection, type IGatewaySelectionRequest } from '../../../../../../platform/agentHost/common/tunnelGatewaySelection.js'; +import type { ITunnelDuplexStream, IWebSocketConnectionConfig, IWebSocketDuplexStream, WebSocketConnectionMessage } from '../../../../../../platform/agentHost/common/tunnelMessageSocket.js'; +import type { IDialogService } from '../../../../../../platform/dialogs/common/dialogs.js'; +import { + BrowserTunnelRelayClientFactory, + connectThroughTunnelGateway, + filterBrowserTunnelInfos, + type ITunnelAgentHostConnector, +} from '../../browser/browserTunnelAgentHostService.js'; +import { + type IDevTunnelsWeb, + type IDevTunnelsWebManagementClient, + type IDevTunnelsWebRelayClient, + type IDevTunnelsWebRequestOptions, + type IDevTunnelsWebTunnel, +} from '../../browser/devTunnelsWebLoader.js'; + +const tunnel: ITunnelInfo = { + tunnelId: 'tunnel-id', + clusterId: 'cluster-id', + name: 'Remote tunnel', + tags: ['vscode-server-launcher', 'protocolv6'], + protocolVersion: 6, + hostConnectionCount: 1, +}; + +const connection: ITunnelConnectResult = { + connectionId: 'connection-id', + address: 'tunnel:tunnel-id', + name: 'Remote tunnel', + connectionToken: 'token', + selected: { serverType: 'editor', instanceId: 'editor-id', role: 'primary', lifecycle: 'external' }, +}; + +class FakeSocket { + closed = false; + + close(): void { + this.closed = true; + } +} + +class FakeConnector implements ITunnelAgentHostConnector { + readonly onDidRelayMessage = Event.None; + readonly onDidRelayClose = Event.None; + readonly socket = new FakeSocket(); + readonly completeCalls: { selectionId: string; selection: ITunnelGatewaySelection }[] = []; + readonly cancelCalls: string[] = []; + + constructor( + private readonly _session: ITunnelGatewaySelectionSession | undefined, + ) { + } + + connect(): Promise { + return Promise.resolve(connection); + } + + prepareSelection(): Promise { + return Promise.resolve(this._session); + } + + completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise { + this.completeCalls.push({ selectionId, selection }); + return Promise.resolve(connection); + } + + cancelSelection(selectionId: string): Promise { + this.cancelCalls.push(selectionId); + this.socket.close(); + return Promise.resolve(); + } + + relaySend(): Promise { + return Promise.resolve(); + } + + disconnect(): Promise { + return Promise.resolve(); + } +} + +suite('BrowserTunnelAgentHostService', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('filters discovered tunnels below the supported protocol version', () => { + const results = filterBrowserTunnelInfos([ + { tunnelId: 'v6', clusterId: 'cluster', labels: ['vscode-server-launcher', 'protocolv6'] }, + { tunnelId: 'v4', clusterId: 'cluster', labels: ['vscode-server-launcher', 'protocolv4'] }, + { tunnelId: 'missing-cluster', labels: ['vscode-server-launcher', 'protocolv6'] }, + ]); + + assert.deepStrictEqual(results, [{ + tunnelId: 'v6', + clusterId: 'cluster', + name: 'v6', + tags: ['vscode-server-launcher', 'protocolv6'], + protocolVersion: 6, + hostConnectionCount: 0, + }]); + }); + + test('completes the version-six gateway selection returned by the browser picker', async () => { + const connector = new FakeConnector({ + selectionId: 'selection-id', + inventory: { + userDataPath: '/data', + endpoints: [{ type: 'editor', pid: 1, instanceId: 'editor-id', endpointKind: 'socket', endpointLabel: '/tmp/editor.sock' }], + }, + }); + const calls: { productName: string; userInitiated: boolean }[] = []; + const resolveSelection: typeof resolveGatewaySelection = async ( + _locationPreferenceService: IRemoteAgentHostLocationPreferenceService, + _dialogService: IDialogService, + request: IGatewaySelectionRequest, + ): Promise => { + calls.push({ productName: request.productName, userInitiated: request.userInitiated }); + return { instanceId: 'editor-id' }; + }; + + const result = await connectThroughTunnelGateway( + connector, + resolveSelection, + {} as IRemoteAgentHostLocationPreferenceService, + {} as IDialogService, + 'VS Code', + { token: 'token', provider: 'github' }, + tunnel, + true, + ); + + assert.deepStrictEqual({ result, calls, completeCalls: connector.completeCalls, cancelCalls: connector.cancelCalls }, { + result: connection, + calls: [{ productName: 'VS Code', userInitiated: true }], + completeCalls: [{ selectionId: 'selection-id', selection: { instanceId: 'editor-id' } }], + cancelCalls: [], + }); + }); + + test('cancels the pending gateway selection when the browser picker is dismissed', async () => { + const connector = new FakeConnector({ selectionId: 'selection-id', inventory: { userDataPath: '/data', endpoints: [] } }); + const result = await connectThroughTunnelGateway( + connector, + async () => undefined, + {} as IRemoteAgentHostLocationPreferenceService, + {} as IDialogService, + 'VS Code', + { token: 'token', provider: 'github' }, + tunnel, + true, + ); + + assert.deepStrictEqual({ result, completeCalls: connector.completeCalls, cancelCalls: connector.cancelCalls, socketClosed: connector.socket.closed }, { + result: undefined, + completeCalls: [], + cancelCalls: ['selection-id'], + socketClosed: true, + }); + }); + + test('configures the browser SDK relay client without local forwarded ports', async () => { + const requests: IDevTunnelsWebRequestOptions[] = []; + let authorization = ''; + let relay: FakeRelayClient | undefined; + + class FakeManagementClient implements IDevTunnelsWebManagementClient { + private readonly _userTokenCallback: () => Promise; + + constructor(_userAgent: string, _apiVersion: object, userTokenCallback: () => Promise) { + this._userTokenCallback = userTokenCallback; + } + + listTunnels(): Promise { + return Promise.resolve([]); + } + + async getTunnel(_tunnel: Pick, options: IDevTunnelsWebRequestOptions): Promise { + authorization = await this._userTokenCallback(); + requests.push(options); + return Promise.resolve({ tunnelId: 'tunnel-id', clusterId: 'cluster-id', labels: ['vscode-server-launcher', 'protocolv6'], endpoints: { relay: 'endpoint' } }); + } + + deleteTunnel(): Promise { + return Promise.resolve(true); + } + } + + class FakeRelayClient implements IDevTunnelsWebRelayClient { + acceptLocalConnectionsForForwardedPorts = true; + endpoints: object | undefined; + + constructor(_managementClient: IDevTunnelsWebManagementClient) { + relay = this; + } + + connect(_tunnel: IDevTunnelsWebTunnel): Promise { + return Promise.resolve(); + } + + waitForForwardedPort(): Promise { + return Promise.resolve(); + } + + connectToForwardedPort(): Promise { + throw new Error('Not used by this adapter test'); + } + + dispose(): void { + } + } + + class FakeWebSocketConnection { + constructor( + _stream: IWebSocketDuplexStream, + _extensions: [], + _protocol: string | null, + _maskOutgoingPackets: boolean, + _config: IWebSocketConnectionConfig, + ) { + } + + _addSocketEventListeners(): void { } + handleSocketData(_data: Uint8Array): void { } + on(_event: 'message', _listener: (message: WebSocketConnectionMessage) => void): void; + on(_event: 'close', _listener: (code: number, reason: string) => void): void; + on(_event: 'error', _listener: (error: Error) => void): void; + on(_event: 'message' | 'close' | 'error', _listener: ((message: WebSocketConnectionMessage) => void) | ((code: number, reason: string) => void) | ((error: Error) => void)): void { } + removeListener(_event: 'message', _listener: (message: WebSocketConnectionMessage) => void): void; + removeListener(_event: 'close', _listener: (code: number, reason: string) => void): void; + removeListener(_event: 'error', _listener: (error: Error) => void): void; + removeListener(_event: 'message' | 'close' | 'error', _listener: ((message: WebSocketConnectionMessage) => void) | ((code: number, reason: string) => void) | ((error: Error) => void)): void { } + send(_data: string): void { } + close(): void { } + } + + const bundle: IDevTunnelsWeb = { + TunnelManagementHttpClient: FakeManagementClient, + ManagementApiVersions: { Version20230927preview: {} }, + TunnelRelayTunnelClient: FakeRelayClient, + TunnelAccessScopes: {}, + WebSocketConnection: FakeWebSocketConnection, + }; + const session = await new BrowserTunnelRelayClientFactory(async () => bundle).getTunnel('tunnel-id', 'cluster-id', 'github', 'token'); + await session!.createRelayClient(); + + assert.deepStrictEqual({ authorization, requests, acceptsLocal: relay?.acceptLocalConnectionsForForwardedPorts, endpoints: relay?.endpoints }, { + authorization: 'github token', + requests: [{ includePorts: true, tokenScopes: ['connect'] }], + acceptsLocal: false, + endpoints: { relay: 'endpoint' }, + }); + }); +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts index 1fe24c755bda35..fdd1a49b22664f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/tunnelAgentHost.contribution.test.ts @@ -81,6 +81,7 @@ class StubTunnelService extends Disposable implements ITunnelAgentHostService { private _cached: ICachedTunnel[] = []; private _listed: ITunnelInfo[] | undefined; private readonly _suppressed = new Set(); + autoConnectMode: 'background' | 'prompt' = 'background'; /** Records every `connect()` call for assertions on the `userInitiated` threading. */ readonly connectCalls: Array<{ tunnel: ITunnelInfo; authProvider: string | undefined; options: { readonly userInitiated?: boolean } | undefined }> = []; @@ -93,6 +94,7 @@ class StubTunnelService extends Disposable implements ITunnelAgentHostService { getCachedTunnels(): ICachedTunnel[] { return this._cached; } setListed(tunnels: ITunnelInfo[] | undefined): void { this._listed = tunnels; } async listTunnels(): Promise { return this._listed ?? []; } + getAutoConnectMode(): 'background' | 'prompt' { return this.autoConnectMode; } readonly canDeleteTunnels = true; async deleteTunnel(tunnel: ITunnelInfo): Promise { this.removeCachedTunnel(tunnel.tunnelId); } cacheTunnel(tunnel: ITunnelInfo, authProvider?: 'github' | 'microsoft'): void { @@ -292,8 +294,8 @@ suite('TunnelAgentHostContribution', () => { test('background auto-connect threads userInitiated: false through to tunnelService.connect, while explicit connects thread userInitiated: true', async () => { // Focused regression test for the userInitiated/silent policy: // background/auto-connect must never be treated as user-initiated - // (so a v6 gateway selection never prompts or picks an editor - // entry), while an explicit connect must retain userInitiated: true. + // (so it can reuse, but never prompt for, a saved location), while an + // explicit connect must retain userInitiated: true. const tunnelService = store.add(new StubTunnelService()); const remoteService = store.add(new StubRemoteAgentHostService()); const providersService = store.add(new StubSessionsProvidersService()); @@ -336,6 +338,41 @@ suite('TunnelAgentHostContribution', () => { assert.strictEqual(tunnelService.connectCalls[1].options?.userInitiated, true, 'explicit/user-initiated connect must pass userInitiated: true'); }); + test('auto-connect prompts once for an initial location, then reconnects silently', async () => { + const tunnelService = store.add(new StubTunnelService()); + tunnelService.autoConnectMode = 'prompt'; + const remoteService = store.add(new StubRemoteAgentHostService()); + const providersService = store.add(new StubSessionsProvidersService()); + const configurationService = new TestConfigurationService({ + [RemoteAgentHostsEnabledSettingId]: true, + [RemoteAgentHostAutoConnectSettingId]: true, + }); + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(ITunnelAgentHostService, tunnelService); + instantiationService.stub(IRemoteAgentHostService, remoteService as unknown as IRemoteAgentHostService); + instantiationService.stub(ISessionsProvidersService, providersService as unknown as ISessionsProvidersService); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(INotificationService, { notify: () => ({ close() { } }) } as unknown as INotificationService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(IAuthenticationService, { onDidChangeSessions: Event.None } as unknown as IAuthenticationService); + instantiationService.stub(ITelemetryService, { publicLog2: () => { } } as unknown as ITelemetryService); + instantiationService.stub(IHostService, new StubHostService()); + instantiationService.stub(ITunnelHostService, store.add(new StubTunnelHostService())); + instantiationService.stub(IAgentHostFilterService, new StubFilterService() as unknown as IAgentHostFilterService); + + const contribution = store.add(instantiationService.createInstance(TestTunnelContribution)); + const tunnel: ITunnelInfo = { tunnelId: 'tunnel-needs-choice', clusterId: 'use', name: 'Needs Choice', tags: ['protocolv6'], protocolVersion: 6, hostConnectionCount: 1 }; + tunnelService.setListed([tunnel]); + const testable = contribution as unknown as { _silentStatusCheck(): Promise }; + + await testable._silentStatusCheck(); + assert.deepStrictEqual(tunnelService.connectCalls.map(call => call.options?.userInitiated), [true]); + + tunnelService.autoConnectMode = 'background'; + await testable._silentStatusCheck(); + assert.deepStrictEqual(tunnelService.connectCalls.map(call => call.options?.userInitiated), [true, false]); + }); + test('does not auto-connect the locally hosted tunnel and reconnects it after sharing stops', async () => { const tunnelService = store.add(new StubTunnelService()); const remoteService = store.add(new StubRemoteAgentHostService()); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/tunnelAgentHostServiceImpl.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/tunnelAgentHostServiceImpl.test.ts index 9bed7ffac03d52..03e0210d3b839c 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/tunnelAgentHostServiceImpl.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/electron-browser/tunnelAgentHostServiceImpl.test.ts @@ -4,13 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import { Event } from '../../../../../../base/common/event.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; -import { IDialogService, IPrompt } from '../../../../../../platform/dialogs/common/dialogs.js'; -import { IRemoteAgentHostLocationPreferenceService, RemoteAgentHostLocationPreference } from '../../../../../../platform/agentHost/common/remoteAgentHostLocationPreference.js'; import { ITunnelGatewayInventory } from '../../../../../../platform/agentHost/common/tunnelAgentHost.js'; import { - resolveGatewaySelection, selectDedicatedGatewayFallback, selectEditorGatewayEndpoint, selectGatewayFallbackAfterRejection, @@ -28,45 +24,6 @@ const secondEditorEndpoint = { type: 'editor', pid: 112, instanceId: 'editor-0', const standaloneEndpoint = { type: 'standalone', pid: 222, instanceId: 'standalone-2', tunnelName: 'my-tunnel', endpointKind: 'tcp', endpointLabel: '127.0.0.1:9001' } as const; const secondStandaloneEndpoint = { type: 'standalone', pid: 333, instanceId: 'standalone-1', endpointKind: 'tcp', endpointLabel: '127.0.0.1:9002' } as const; -interface IPreferenceServiceFixture { - readonly service: IRemoteAgentHostLocationPreferenceService; - readonly setCalls: { hostKey: string; preference: RemoteAgentHostLocationPreference }[]; -} - -function stubLocationPreferenceService(initial?: RemoteAgentHostLocationPreference): IPreferenceServiceFixture { - const store = new Map(); - if (initial) { - store.set('tunnel:abc', initial); - } - const setCalls: { hostKey: string; preference: RemoteAgentHostLocationPreference }[] = []; - const service: IRemoteAgentHostLocationPreferenceService = { - _serviceBrand: undefined, - onDidChangePreference: Event.None, - getPreference: hostKey => store.get(hostKey), - setPreference: (hostKey, preference) => { - store.set(hostKey, preference); - setCalls.push({ hostKey, preference }); - }, - }; - return { service, setCalls }; -} - -interface IDialogServiceFixture { - readonly dialogService: IDialogService; - readonly promptCalls: IPrompt[]; -} - -function stubDialogService(result: RemoteAgentHostLocationPreference | undefined): IDialogServiceFixture { - const promptCalls: IPrompt[] = []; - const dialogService = { - prompt: async (options: IPrompt) => { - promptCalls.push(options); - return { result }; - }, - } as unknown as IDialogService; - return { dialogService, promptCalls }; -} - suite('tunnelAgentHostServiceImpl - gateway selection', () => { ensureNoDisposablesAreLeakedInTestSuite(); @@ -131,142 +88,6 @@ suite('tunnelAgentHostServiceImpl - gateway selection', () => { }); }); - suite('resolveGatewaySelection', () => { - test('a delegated instance short-circuits saved preferences and prompts', async () => { - const { service, setCalls } = stubLocationPreferenceService('dedicated'); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', - inventory: { userDataPath: '/data', delegatedInstanceId: 'editor-1', endpoints: [editorEndpoint] }, - userInitiated: true, - }); - - assert.deepStrictEqual({ selection, promptCalls, setCalls }, { - selection: { instanceId: 'editor-1' }, - promptCalls: [], - setCalls: [], - }); - }); - - test('saved "editor" preference + a live editor selects that editor without prompting or re-persisting', async () => { - const { service, setCalls } = stubLocationPreferenceService('editor'); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, - }); - - assert.deepStrictEqual(selection, { instanceId: 'editor-1' }); - assert.strictEqual(promptCalls.length, 0); - assert.strictEqual(setCalls.length, 0); - }); - - test('saved "editor" preference + a background (non-user-initiated) reconnect still selects the live editor (explicit consent)', async () => { - const { service, setCalls } = stubLocationPreferenceService('editor'); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint]), userInitiated: false, - }); - - assert.deepStrictEqual(selection, { instanceId: 'editor-1' }); - assert.strictEqual(promptCalls.length, 0); - assert.strictEqual(setCalls.length, 0); - }); - - test('saved "editor" preference + no live editor falls back to dedicated without changing the preference', async () => { - const { service, setCalls } = stubLocationPreferenceService('editor'); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([standaloneEndpoint]), userInitiated: true, - }); - - assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); - assert.strictEqual(promptCalls.length, 0); - assert.strictEqual(setCalls.length, 0, 'an unavailable editor preference must not be overwritten'); - }); - - test('saved "dedicated" preference never prompts, even when a live editor exists', async () => { - const { service, setCalls } = stubLocationPreferenceService('dedicated'); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, - }); - - assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); - assert.strictEqual(promptCalls.length, 0); - assert.strictEqual(setCalls.length, 0); - }); - - test('no saved preference + no live editor falls back to dedicated with no prompt and no persistence', async () => { - const { service, setCalls } = stubLocationPreferenceService(); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([standaloneEndpoint]), userInitiated: true, - }); - - assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); - assert.strictEqual(promptCalls.length, 0); - assert.strictEqual(setCalls.length, 0); - }); - - test('no saved preference + a live editor + a background connection falls back to dedicated silently, never prompting', async () => { - const { service, setCalls } = stubLocationPreferenceService(); - const { dialogService, promptCalls } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: false, - }); - - assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); - assert.strictEqual(promptCalls.length, 0); - assert.strictEqual(setCalls.length, 0); - }); - - test('no saved preference + a live editor + a user-initiated connection prompts the shared modal with the tunnel name and persists an "editor" choice', async () => { - const { service, setCalls } = stubLocationPreferenceService(); - const { dialogService, promptCalls } = stubDialogService('editor'); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, - }); - - assert.deepStrictEqual(selection, { instanceId: 'editor-1' }); - assert.strictEqual(promptCalls.length, 1); - assert.match(promptCalls[0].message, /My Tunnel/); - assert.deepStrictEqual((promptCalls[0] as unknown as { custom: { buttonDetails: string[] } }).custom.buttonDetails[1], 'Agents are available only while the remote Test Product window is open.'); - assert.deepStrictEqual(setCalls, [{ hostKey: 'tunnel:abc', preference: 'editor' }]); - }); - - test('no saved preference + a live editor + a user-initiated connection persists a "dedicated" choice and translates it to a concrete selection', async () => { - const { service, setCalls } = stubLocationPreferenceService(); - const { dialogService } = stubDialogService('dedicated'); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, - }); - - assert.deepStrictEqual(selection, { instanceId: 'standalone-2' }); - assert.deepStrictEqual(setCalls, [{ hostKey: 'tunnel:abc', preference: 'dedicated' }]); - }); - - test('cancelling the modal returns undefined and persists nothing', async () => { - const { service, setCalls } = stubLocationPreferenceService(); - const { dialogService } = stubDialogService(undefined); - - const selection = await resolveGatewaySelection(service, dialogService, { - hostKey: 'tunnel:abc', hostLabel: 'My Tunnel', productName: 'Test Product', inventory: inventory([editorEndpoint, standaloneEndpoint]), userInitiated: true, - }); - - assert.strictEqual(selection, undefined); - assert.strictEqual(setCalls.length, 0); - }); - }); - suite('shouldNotifyTunnelFailover', () => { test('notifies on a background reconnect that moved from an editor endpoint to a standalone one', () => { assert.strictEqual(shouldNotifyTunnelFailover('editor', 'standalone', false), true); diff --git a/src/vs/sessions/sessions.web.main.ts b/src/vs/sessions/sessions.web.main.ts index 56b8e7b7b0fba5..6789f9dc0ce920 100644 --- a/src/vs/sessions/sessions.web.main.ts +++ b/src/vs/sessions/sessions.web.main.ts @@ -108,6 +108,8 @@ import { WorkbenchMcpGalleryManifestService } from '../workbench/services/mcp/br import { UserDataSyncResourceProviderService } from '../platform/userDataSync/common/userDataSyncResourceProvider.js'; import { IRemoteAgentHostService } from '../platform/agentHost/common/remoteAgentHostService.js'; import { AgentsWindowRemoteAgentHostService } from '../platform/agentHost/browser/remoteAgentHostServiceImpl.js'; +import { IRemoteAgentHostLocationPreferenceService } from '../platform/agentHost/common/remoteAgentHostLocationPreference.js'; +import { RemoteAgentHostLocationPreferenceService } from '../platform/agentHost/browser/remoteAgentHostLocationPreferenceService.js'; import { ISSHRemoteAgentHostService } from '../platform/agentHost/common/sshRemoteAgentHost.js'; import { NullSSHRemoteAgentHostService } from '../platform/agentHost/browser/nullSshRemoteAgentHostService.js'; import { IWSLRemoteAgentHostService } from '../platform/agentHost/common/wslRemoteAgentHost.js'; @@ -136,6 +138,7 @@ registerSingleton(IWebContentExtractorService, NullWebContentExtractorService, I registerSingleton(ISharedWebContentExtractorService, NullSharedWebContentExtractorService, InstantiationType.Delayed); registerSingleton(IMcpGalleryManifestService, WorkbenchMcpGalleryManifestService, InstantiationType.Delayed); registerSingleton(IRemoteAgentHostService, AgentsWindowRemoteAgentHostService, InstantiationType.Delayed); +registerSingleton(IRemoteAgentHostLocationPreferenceService, RemoteAgentHostLocationPreferenceService, InstantiationType.Delayed); registerSingleton(ISSHRemoteAgentHostService, NullSSHRemoteAgentHostService, InstantiationType.Delayed); registerSingleton(IWSLRemoteAgentHostService, NullWSLRemoteAgentHostService, InstantiationType.Delayed); registerSingleton(IAgentHostService, EditorRemoteAgentHostServiceClient, InstantiationType.Delayed); From 41435f6ac85e37542ec2d4f600dea2ecd8ab0456 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 18 Aug 2026 21:24:01 -0700 Subject: [PATCH 02/28] sessions: initialize tunnel storage after services Create tunnel storage after dependency-injected services are initialized. - Avoids accessing the storage service from class field initializers. - Preserves the shared tunnel-change event exposed by both browser services. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../browser/browserTunnelAgentHostService.ts | 6 ++++-- .../remoteAgentHost/browser/webTunnelAgentHostService.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts index 8e442598556b32..fc39e76bfc580e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/browserTunnelAgentHostService.ts @@ -145,8 +145,8 @@ export interface IBrowserTunnelAgentHostServiceOptions { export class BrowserTunnelAgentHostService extends Disposable implements ITunnelAgentHostService { declare readonly _serviceBrand: undefined; - private readonly _storage = this._register(new TunnelAgentHostStorage(this._storageService)); - readonly onDidChangeTunnels: Event = this._storage.onDidChangeTunnels; + private readonly _storage: TunnelAgentHostStorage; + readonly onDidChangeTunnels: Event; private readonly _connector: ITunnelAgentHostConnector; private readonly _resolveGatewaySelection: typeof resolveGatewaySelection; @@ -166,6 +166,8 @@ export class BrowserTunnelAgentHostService extends Disposable implements ITunnel options: IBrowserTunnelAgentHostServiceOptions = {}, ) { super(); + this._storage = this._register(new TunnelAgentHostStorage(this._storageService)); + this.onDidChangeTunnels = this._storage.onDidChangeTunnels; const load = options.loadDevTunnelsWeb ?? loadDevTunnelsWeb; this._loadDevTunnelsWeb = load; this._connector = options.connector ?? this._register(new TunnelAgentHostConnector( diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts index 76375324f2871d..ebbd06df282087 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/webTunnelAgentHostService.ts @@ -49,8 +49,8 @@ const LOG_PREFIX = '[WebTunnelAgentHost]'; export class WebTunnelAgentHostService extends Disposable implements ITunnelAgentHostService { declare readonly _serviceBrand: undefined; - private readonly _storage = this._register(new TunnelAgentHostStorage(this._storageService)); - readonly onDidChangeTunnels: Event = this._storage.onDidChangeTunnels; + private readonly _storage: TunnelAgentHostStorage; + readonly onDidChangeTunnels: Event; private readonly _discoveryProvider: ITunnelDiscoveryProvider | undefined; @@ -64,6 +64,8 @@ export class WebTunnelAgentHostService extends Disposable implements ITunnelAgen @IStorageService private readonly _storageService: IStorageService, ) { super(); + this._storage = this._register(new TunnelAgentHostStorage(this._storageService)); + this.onDidChangeTunnels = this._storage.onDidChangeTunnels; this._discoveryProvider = environmentService.options?.tunnelDiscoveryProvider; if (!this._discoveryProvider) { this._logService.debug(`${LOG_PREFIX} No tunnelDiscoveryProvider — tunnel discovery disabled`); From 20fdc2534ad8dbe57a4d9170e3860896f6285bb4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 18 Aug 2026 21:34:44 -0700 Subject: [PATCH 03/28] sessions: harden browser tunnel lifecycle Fix lifecycle and build integration issues found during review. - Emits the Dev Tunnels module from every bundle containing the sessions web entry. - Disposes active tunnel connections when their connector shuts down. - Handles synchronous gateway-message replay without losing listener handles. - Cleans up relay resources when gateway payload parsing fails. - Adds regression tests for each cleanup and replay path. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- build/gulpfile.vscode.web.ts | 39 +------ build/next/index.ts | 24 ++-- .../common/tunnelAgentHostConnector.ts | 56 +++++++-- .../common/tunnelAgentHostConnector.test.ts | 110 +++++++++++++++++- 4 files changed, 162 insertions(+), 67 deletions(-) diff --git a/build/gulpfile.vscode.web.ts b/build/gulpfile.vscode.web.ts index af8a4b40f37b6b..18b85c9142adc2 100644 --- a/build/gulpfile.vscode.web.ts +++ b/build/gulpfile.vscode.web.ts @@ -61,34 +61,6 @@ function runEsbuildBundle(outDir: string, minify: boolean, nls: boolean, sourceM }); } -function runDevTunnelsWebBundle(outDir: string, minify: boolean): Promise { - return new Promise((resolve, reject) => { - const scriptPath = path.join(REPO_ROOT, 'build/next/devTunnelsWeb.ts'); - const args = [ - scriptPath, - '--out', - path.join(outDir, 'vs', 'sessions', 'contrib', 'providers', 'remoteAgentHost', 'browser'), - ]; - if (minify) { - args.push('--minify'); - } - - const proc = cp.spawn(process.execPath, args, { - cwd: REPO_ROOT, - stdio: 'inherit' - }); - - proc.on('error', reject); - proc.on('close', code => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`Dev Tunnels web bundle failed with exit code ${code} (outDir: ${outDir}, minify: ${minify})`)); - } - }); - }); -} - export const vscodeWebResourceIncludes = [ // NLS @@ -199,15 +171,8 @@ task.task(minifyVSCodeWebTask); // esbuild-based tasks (new) const sourceMappingURLBase = `https://main.vscode-cdn.net/sourcemaps/${commit}`; -const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', async () => { - await runEsbuildBundle('out-vscode-web', false, true); - // Required by the web-target invariant in build/next/index.ts. - await runDevTunnelsWebBundle('out-vscode-web', false); -}); -const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', async () => { - await runEsbuildBundle('out-vscode-web-min', true, true, `${sourceMappingURLBase}/core`); - await runDevTunnelsWebBundle('out-vscode-web-min', true); -}); +const esbuildBundleVSCodeWebTask = task.define('esbuild-vscode-web', () => runEsbuildBundle('out-vscode-web', false, true)); +const esbuildBundleVSCodeWebMinTask = task.define('esbuild-vscode-web-min', () => runEsbuildBundle('out-vscode-web-min', true, true, `${sourceMappingURLBase}/core`)); function packageTask(sourceFolderName: string, destinationFolderName: string) { const destination = path.join(BUILD_ROOT, destinationFolderName); diff --git a/build/next/index.ts b/build/next/index.ts index 38bef410ad9d54..53f43b0279a413 100644 --- a/build/next/index.ts +++ b/build/next/index.ts @@ -19,6 +19,7 @@ import packageJson from '../../package.json' with { type: 'json' }; import { useEsbuildTranspile } from '../buildConfig.ts'; import { isWebExtension, type IScannedBuiltinExtension } from '../lib/extensions.ts'; import { runBuildFast } from './build-fast.ts'; +import { bundleDevTunnelsWeb } from './devTunnelsWeb.ts'; import { copyFile, mapWithConcurrency, MAX_CONCURRENT_FILE_OPERATIONS, transpileFile } from './transpile.ts'; const globAsync = promisify(glob); @@ -57,10 +58,6 @@ const options = { // Build targets type BuildTarget = 'desktop' | 'server' | 'server-web' | 'web'; -const buildTargets: readonly BuildTarget[] = ['desktop', 'server', 'server-web', 'web']; - -// The Dev Tunnels bundle is emitted only by the standalone web build tasks. -const devTunnelsWebBundleTargets: ReadonlySet = new Set(['web']); const SRC_DIR = 'src'; const OUT_DIR = 'out'; @@ -193,18 +190,6 @@ function getEntryPointsForTarget(target: BuildTarget): string[] { } } -/** Ensures every Sessions web target emits the Dev Tunnels bundle. */ -function assertDevTunnelsWebBundleTargetInvariant(): void { - for (const target of buildTargets) { - const hasSessionsWebEntryPoint = getEntryPointsForTarget(target).includes(sessionsWebEntryPoint); - if (hasSessionsWebEntryPoint !== devTunnelsWebBundleTargets.has(target)) { - throw new Error(`The Dev Tunnels web bundle and '${sessionsWebEntryPoint}' must target the same builds (mismatch for '${target}').`); - } - } -} - -assertDevTunnelsWebBundleTargetInvariant(); - /** * Get bootstrap entry points for a build target. */ @@ -1033,6 +1018,13 @@ ${tslib}`, // Compile standalone TypeScript files (like Electron preload scripts) that cannot be bundled await compileStandaloneFiles(outDir, doMinify, target); + if (allEntryPoints.includes(sessionsWebEntryPoint)) { + await bundleDevTunnelsWeb({ + minify: doMinify, + outDir: path.join(outDir, 'vs', 'sessions', 'contrib', 'providers', 'remoteAgentHost', 'browser'), + }); + } + console.log(`[bundle] Done in ${Date.now() - t1}ms (${bundled} bundles)`); } diff --git a/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts b/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts index f8c6d2c4212052..20103ae736e285 100644 --- a/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts +++ b/src/vs/platform/agentHost/common/tunnelAgentHostConnector.ts @@ -18,6 +18,7 @@ import { TUNNEL_MIN_PROTOCOL_VERSION, TunnelTags, type ITunnelConnectResult, + type ITunnelGatewayInventory, type ITunnelGatewaySelection, type ITunnelGatewaySelectionSession, type ITunnelInfo, @@ -251,7 +252,7 @@ export class TunnelAgentHostConnector extends Disposable { private readonly _onDidRelayClose = this._register(new Emitter()); readonly onDidRelayClose: Event = this._onDidRelayClose.event; - private readonly _connections = new Map(); + private readonly _connections = this._register(new DisposableMap()); private readonly _pendingSelections = this._register(new DisposableMap()); constructor( @@ -345,11 +346,22 @@ export class TunnelAgentHostConnector extends Disposable { throw err; } + let inventory: ITunnelGatewayInventory; + let connectionToken: string; + try { + inventory = parseTunnelGatewayInventory(inventoryText); + connectionToken = await deriveConnectionToken(tunnelId); + } catch (err) { + this._disposeSocket(socket); + this._disposeRelayClient(relayClient); + throw err; + } + const selectionId = generateUuid(); this._pendingSelections.set(selectionId, new PendingGatewaySelection( `${TUNNEL_ADDRESS_PREFIX}${tunnelId}`, tags.name || tunnel.name || tunnelId, - await deriveConnectionToken(tunnelId), + connectionToken, socket, relayClient, () => { @@ -357,7 +369,7 @@ export class TunnelAgentHostConnector extends Disposable { this._pendingSelections.deleteAndDispose(selectionId); }, )); - return { selectionId, inventory: parseTunnelGatewayInventory(inventoryText) }; + return { selectionId, inventory }; } async completeSelection(selectionId: string, selection: ITunnelGatewaySelection): Promise { @@ -367,17 +379,17 @@ export class TunnelAgentHostConnector extends Disposable { } pending.detach(); - let responseText: string; + let response: ReturnType; try { pending.socket.send(JSON.stringify(selection)); - responseText = await withTimeout(() => this._readNextGatewayMessage(pending.socket), TUNNEL_STEP_TIMEOUT_MS, 'gateway selection acknowledgement'); + const responseText = await withTimeout(() => this._readNextGatewayMessage(pending.socket), TUNNEL_STEP_TIMEOUT_MS, 'gateway selection acknowledgement'); + response = parseTunnelGatewaySelectionResponse(responseText); } catch (err) { this._disposeSocket(pending.socket); this._disposeRelayClient(pending.relayClient); throw err; } - const response = parseTunnelGatewaySelectionResponse(responseText); if (!response.ok) { this._disposeSocket(pending.socket); this._disposeRelayClient(pending.relayClient); @@ -399,7 +411,7 @@ export class TunnelAgentHostConnector extends Disposable { } async disconnect(connectionId: string): Promise { - this._connections.get(connectionId)?.dispose(); + this._connections.deleteAndDispose(connectionId); } closeTunnelConnections(tunnelId: string, operation: 'deleting' | 'reconnecting'): void { @@ -407,8 +419,7 @@ export class TunnelAgentHostConnector extends Disposable { for (const [connectionId, connection] of this._connections) { if (connection.address === address) { this._logService.info(`${LOG_PREFIX} Closing existing relay for tunnel ${tunnelId} before ${operation}`); - this._connections.delete(connectionId); - connection.dispose(); + this._connections.deleteAndDispose(connectionId); } } } @@ -436,7 +447,7 @@ export class TunnelAgentHostConnector extends Disposable { ); const onConnectionClose = connection.onDidClose(() => { onConnectionClose.dispose(); - this._connections.delete(connectionId); + this._connections.deleteAndLeak(connectionId); this._onDidRelayClose.fire(connectionId); }); this._connections.set(connectionId, connection); @@ -444,15 +455,31 @@ export class TunnelAgentHostConnector extends Disposable { private _readNextGatewayMessage(socket: ITunnelMessageSocket): Promise { return new Promise((resolve, reject) => { + const subscriptions: IDisposable[] = []; + let settled = false; const cleanup = () => { - onMessage?.dispose(); - onClose?.dispose(); + for (const subscription of subscriptions.splice(0)) { + subscription.dispose(); + } }; const onMessage = socket.onDidReceiveMessage(message => { + if (settled) { + return; + } + settled = true; cleanup(); resolve(message); }); + if (settled) { + onMessage.dispose(); + return; + } + subscriptions.push(onMessage); const onClose = socket.onDidClose(event => { + if (settled) { + return; + } + settled = true; cleanup(); if (event.error) { reject(event.error); @@ -460,6 +487,11 @@ export class TunnelAgentHostConnector extends Disposable { reject(new Error(`${LOG_PREFIX} Gateway WebSocket closed before expected message; code=${event.code}, reason=${event.reason || '(empty)'}`)); } }); + if (settled) { + onClose.dispose(); + } else { + subscriptions.push(onClose); + } }); } diff --git a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts index b86403cdf4b78e..046eccbe6f08ca 100644 --- a/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts +++ b/src/vs/platform/agentHost/test/common/tunnelAgentHostConnector.test.ts @@ -90,14 +90,19 @@ class FakeSocket implements ITunnelMessageSocket { const disposable = this._onDidReceiveMessage.event(listener, thisArgs, disposables); const message = this._queuedMessages.shift(); if (message !== undefined) { - queueMicrotask(() => this._onDidReceiveMessage.fire(message)); + if (this._replaySynchronously) { + listener.call(thisArgs, message); + } else { + queueMicrotask(() => this._onDidReceiveMessage.fire(message)); + } } return disposable; }; readonly onDidClose = this._onDidClose.event; closeCalls = 0; + disposeCalls = 0; - constructor(messages: string[] = []) { + constructor(messages: string[] = [], private readonly _replaySynchronously = false) { this._queuedMessages = messages; } @@ -109,6 +114,7 @@ class FakeSocket implements ITunnelMessageSocket { } dispose(): void { + this.disposeCalls++; this._onDidReceiveMessage.dispose(); this._onDidClose.dispose(); } @@ -269,4 +275,104 @@ suite('TunnelAgentHostConnector', () => { connector.dispose(); } }); + + test('disposes active tunnel connections when the connector is disposed', async () => { + const relayClient = new FakeRelayClient(); + const socket = new FakeSocket(); + const { connector } = createConnector( + { tunnelId: 'active', clusterId: 'cluster', labels: ['protocolv5'] }, + relayClient, + new FakeSocketFactory(socket), + ); + + await connector.connect('token', 'github', 'active', 'cluster'); + connector.dispose(); + + assert.deepStrictEqual({ + socketCloseCalls: socket.closeCalls, + socketDisposeCalls: socket.disposeCalls, + relayDisposeCalls: relayClient.disposeCalls, + }, { + socketCloseCalls: 1, + socketDisposeCalls: 1, + relayDisposeCalls: 1, + }); + }); + + test('cleans up when the gateway inventory is malformed', async () => { + const relayClient = new FakeRelayClient(); + const socket = new FakeSocket(['{}']); + const { connector } = createConnector( + { tunnelId: 'bad-inventory', clusterId: 'cluster', labels: ['protocolv6'] }, + relayClient, + new FakeSocketFactory(socket), + ); + try { + await assert.rejects( + () => connector.prepareSelection('token', 'github', 'bad-inventory', 'cluster'), + /invalid "userDataPath"/, + ); + assert.deepStrictEqual({ + socketCloseCalls: socket.closeCalls, + socketDisposeCalls: socket.disposeCalls, + relayDisposeCalls: relayClient.disposeCalls, + }, { + socketCloseCalls: 1, + socketDisposeCalls: 1, + relayDisposeCalls: 1, + }); + } finally { + connector.dispose(); + } + }); + + test('cleans up when the gateway selection acknowledgement is malformed', async () => { + const relayClient = new FakeRelayClient(); + const socket = new FakeSocket([ + JSON.stringify({ userDataPath: '/data', endpoints: [] }), + '{}', + ]); + const { connector } = createConnector( + { tunnelId: 'bad-ack', clusterId: 'cluster', labels: ['protocolv6'] }, + relayClient, + new FakeSocketFactory(socket), + ); + try { + const selection = await connector.prepareSelection('token', 'github', 'bad-ack', 'cluster'); + await assert.rejects( + () => connector.completeSelection(selection!.selectionId, { newDedicated: true }), + /not a valid response/, + ); + assert.deepStrictEqual({ + socketCloseCalls: socket.closeCalls, + socketDisposeCalls: socket.disposeCalls, + relayDisposeCalls: relayClient.disposeCalls, + }, { + socketCloseCalls: 1, + socketDisposeCalls: 1, + relayDisposeCalls: 1, + }); + } finally { + connector.dispose(); + } + }); + + test('accepts a gateway inventory replayed synchronously by the socket', async () => { + const relayClient = new FakeRelayClient(); + const socket = new FakeSocket([ + JSON.stringify({ userDataPath: '/data', endpoints: [] }), + ], true); + const { connector } = createConnector( + { tunnelId: 'sync-inventory', clusterId: 'cluster', labels: ['protocolv6'] }, + relayClient, + new FakeSocketFactory(socket), + ); + try { + const selection = await connector.prepareSelection('token', 'github', 'sync-inventory', 'cluster'); + assert.deepStrictEqual(selection?.inventory, { userDataPath: '/data', endpoints: [] }); + await connector.cancelSelection(selection!.selectionId); + } finally { + connector.dispose(); + } + }); }); From fff2914fa0497b26ebcc5a3cf78fc571e24807d0 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 19 Aug 2026 09:45:09 -0700 Subject: [PATCH 04/28] chat: back terminal chat with the Agent Host (#331591) * chat: back terminal chat with the Agent Host Adds an experimental path that backs Terminal Chat with an Agent Host session instead of the extension host. Sessions created for throwaway UI surfaces are marked ephemeral so they are never retained or listed, and the terminal surface supplies per-turn instructions that keep answers focused on a single command. - Adds `chat.terminal.agentHost.enabled` (default off, startup experiment) to select the backend. When disabled, session creation follows the previous extension-host path exactly. - Adds `TerminalChatSessionResolver`, which applies the Agent Host preference, falls back to a local session when the Agent Host is unavailable or fails, and reports the contribution the widget must lock to. Requests then carry `agentIdSilent`, because a contributed session type is not the default agent for its location. - Adds an optional `locations` to chat session contributions, which defaults to the primary Chat surface, so a session type can serve the terminal. - Adds the `vscode.chat.ephemeralSession` metadata slot. The Agent Host tombstones such sessions at creation, omits them from the session list and the overlay summaries, and collects them after their last subscription ends. - Adds the `vscode.chat.surface` metadata slot, which carries the shell type and the operating system. `AgentSideEffects` uses it to add terminal instructions to each turn. The widget refreshes the slot when the shell type becomes known. - Replaces `AgentSessionRegistry.unregister` with `tombstone`, because the two methods did the same operation. - Shows the widget immediately and waits for the session only before a request is sent, so the Agent Host round trip does not delay the terminal chat display. (Commit message generated by Copilot) * chat: clean up ephemeral session lifecycle - Clear pending session markers after backend materialization or model abandonment. - Remove ephemeral registry tombstones after successful session teardown. - Add regression coverage for both lifecycle paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/meta/agentChatSurfaceMeta.ts | 90 ++++++ .../common/meta/agentEphemeralSessionMeta.ts | 31 ++ .../agentHost/node/agentHostStateManager.ts | 16 +- .../platform/agentHost/node/agentService.ts | 102 ++++++- .../agentHost/node/agentSessionRegistry.ts | 20 +- .../agentHost/node/agentSideEffects.ts | 3 + .../test/common/agentMetaReaders.test.ts | 70 +++++ .../agentHost/test/node/agentService.test.ts | 107 ++++++- .../test/node/agentSessionRegistry.test.ts | 26 +- .../test/node/agentSideEffects.test.ts | 32 +- .../agentHost/agentHostChatContribution.ts | 3 + .../agentHost/agentHostSessionHandler.ts | 26 +- .../agentHostSessionListController.ts | 21 ++ .../agentHost/agentHostSessionListStore.ts | 5 + ...ntHostUntitledProvisionalSessionService.ts | 40 ++- .../chat/browser/chat.shared.contribution.ts | 9 + .../chatSessions/chatSessions.contribution.ts | 17 +- .../chat/common/chatSessionsService.ts | 26 ++ .../contrib/chat/common/constants.ts | 1 + .../agentHostChatContribution.test.ts | 42 ++- .../test/common/mockChatSessionsService.ts | 10 + .../browser/terminal.chat.contribution.ts | 2 + .../browser/terminalChatSessionResolver.ts | 107 +++++++ .../chat/browser/terminalChatWidget.ts | 87 +++++- .../terminalChatSessionResolver.test.ts | 275 ++++++++++++++++++ 25 files changed, 1106 insertions(+), 62 deletions(-) create mode 100644 src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts create mode 100644 src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts create mode 100644 src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatSessionResolver.ts create mode 100644 src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatSessionResolver.test.ts diff --git a/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts b/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts new file mode 100644 index 00000000000000..0bf13d1f6de5f4 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentChatSurfaceMeta.ts @@ -0,0 +1,90 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** VS Code-owned metadata describing the chat surface that created a session. */ +export const VSCODE_CHAT_SURFACE_META_KEY = 'vscode.chat.surface'; + +interface IHasChatSurfaceMeta { + readonly _meta?: Record; +} + +/** Typed metadata for a terminal-backed chat session. */ +export interface ITerminalChatSurfaceMeta { + readonly surface: 'terminal'; + readonly shellType?: string; + readonly osName: string; +} + +/** Reads recognized chat-surface metadata, dropping malformed values. */ +export function readChatSurfaceMeta(source: IHasChatSurfaceMeta): ITerminalChatSurfaceMeta | undefined { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced chat-surface slot. + const value = source._meta?.[VSCODE_CHAT_SURFACE_META_KEY]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return undefined; + } + + const raw = value as Record; + if (raw['surface'] !== 'terminal' + || (raw['shellType'] !== undefined && typeof raw['shellType'] !== 'string') + || typeof raw['osName'] !== 'string') { + return undefined; + } + + return { + surface: 'terminal', + ...(typeof raw['shellType'] === 'string' ? { shellType: raw['shellType'] } : {}), + osName: raw['osName'], + }; +} + +/** Adds VS Code's typed chat-surface metadata to an open request metadata bag. */ +export function withChatSurfaceMeta(meta: Record | undefined, surface: ITerminalChatSurfaceMeta | undefined): Record | undefined { + if (!surface) { + return meta; + } + return { + ...(meta ?? {}), + [VSCODE_CHAT_SURFACE_META_KEY]: { + surface: surface.surface, + ...(surface.shellType !== undefined ? { shellType: surface.shellType } : {}), + osName: surface.osName, + }, + }; +} + +function isPowerShell(shellType: string): boolean { + return shellType === 'ps1' || shellType === 'pwsh' || shellType === 'powershell'; +} + +/** + * Builds the per-turn host instruction for a terminal chat surface. + * + * Lives next to {@link ITerminalChatSurfaceMeta} because it is a pure function + * of that shape, so the prompt and the metadata it consumes cannot drift. + * + * This is additive context layered on top of the harness's own system prompt — + * it biases toward terse, shell-appropriate command answers but does not by + * itself prevent an agentic harness from exploring with tools first. + */ +export function createTerminalChatInstruction(surface: ITerminalChatSurfaceMeta): string { + const shellType = surface.shellType; + return [ + '', + 'You specialize in the command line. Help the user craft a command to run.', + `- You're targeting ${surface.osName}.`, + ...(shellType ? [`- The active shell is ${shellType}.`] : []), + '- Prefer single-line commands. Omit explanations unless the command is complex; then be concise.', + '- Use `{placeholder_text}` for required replacement text that the user did not provide.', + ...(shellType && isPowerShell(shellType) + ? [ + '- Prefer idiomatic PowerShell: use `Stop-Process` or `Get-NetTCPConnection` instead of `kill` or `lsof`.', + '- Prefer cross-platform PowerShell and use Unix utilities only when PowerShell has no equivalent.', + ] + : shellType ? ['- Only use Python or Perl when the shell cannot accomplish the task.'] : []), + `- Do not try to accomplish the task yourself, instead provide a${shellType ? ` ${shellType}` : ''} command to run.`, + '- Avoid extraneous steps or context-gathering prior to providing the command, unless context is required to resolve ambiguity.', + '', + ].join('\n'); +} diff --git a/src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts b/src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts new file mode 100644 index 00000000000000..6b7b1cbd843109 --- /dev/null +++ b/src/vs/platform/agentHost/common/meta/agentEphemeralSessionMeta.ts @@ -0,0 +1,31 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** VS Code-owned request metadata indicating a throwaway chat surface. */ +export const VSCODE_EPHEMERAL_SESSION_META_KEY = 'vscode.chat.ephemeralSession'; + +interface IHasEphemeralSessionMeta { + readonly _meta?: Record; +} + +/** Typed view over VS Code's ephemeral-session request metadata. */ +export interface IEphemeralSessionMeta { + readonly isEphemeral?: boolean; +} + +/** Reads recognized ephemeral-session metadata, dropping wrong-typed values. */ +export function readEphemeralSessionMeta(source: IHasEphemeralSessionMeta): IEphemeralSessionMeta { + // eslint-disable-next-line local/code-no-untyped-meta-access -- sanctioned first hop into the namespaced ephemeral-session slot. + const value = source._meta?.[VSCODE_EPHEMERAL_SESSION_META_KEY]; + return typeof value === 'boolean' ? { isEphemeral: value } : {}; +} + +/** Adds VS Code's ephemeral-session metadata to an open request metadata bag. */ +export function withEphemeralSessionMeta(meta: Record | undefined, isEphemeral: boolean | undefined): Record | undefined { + if (isEphemeral === undefined) { + return meta; + } + return { ...(meta ?? {}), [VSCODE_EPHEMERAL_SESSION_META_KEY]: isEphemeral }; +} diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 2015c077fa2e46..4105cd38e58ef4 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -23,6 +23,8 @@ import { ChangesSummary, ChatInteractivity, type ChatOrigin } from '../common/st import { arrayEquals, structuralEquals } from '../../../base/common/equals.js'; import { preserveProviderBackedRootConfigValues } from '../common/agentCustomizationSettings.js'; import type { IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; +import { readEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; +import { ITerminalChatSurfaceMeta, readChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; export interface IAgentHostStateManagerOptions { readonly changesetStateRetention?: IAgentHostChangesetStateRetentionOptions; @@ -572,7 +574,7 @@ export class AgentHostStateManager extends Disposable { getOverlaySessionSummaries(): SessionSummary[] { const summaries: SessionSummary[] = []; for (const [key, entry] of this._sessionStates) { - if (this._isIdleProvisional(key, entry.state.lifecycle)) { + if (this._isIdleProvisional(key, entry.state.lifecycle) || this.isEphemeralSession(key)) { continue; } summaries.push(this._toSummary(key, entry)); @@ -591,6 +593,18 @@ export class AgentHostStateManager extends Disposable { return entry ? this._isIdleProvisional(session, entry.state.lifecycle) : false; } + /** Whether the session is owned by a throwaway VS Code chat surface. */ + isEphemeralSession(session: string): boolean { + const entry = this._sessionStates.get(session); + return entry ? readEphemeralSessionMeta(entry.state).isEphemeral === true : false; + } + + /** Returns the typed VS Code surface metadata for a tracked session, when present. */ + getSessionSurfaceMeta(session: string): ITerminalChatSurfaceMeta | undefined { + const entry = this._sessionStates.get(session); + return entry ? readChatSurfaceMeta(entry.state) : undefined; + } + private _isIdleProvisional(session: string, lifecycle: SessionLifecycle): boolean { // Turn activity lives on the session's default chat after the multi-chat // protocol move, so consult that chat's turns/activeTurn. diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 22699c2965c178..6cddb0e4a0c6d7 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -39,6 +39,8 @@ import { ChangesSummary, ChatInteractivity, ChatOriginKind, MessageAttachmentKin import type { ChatPendingMessageSetAction, ChatTurnStartedAction, SessionConfigChangedAction } from '../common/state/protocol/actions.js'; import { ISessionGitHubState, ISessionGitState, MessageKind, ResponsePartKind, SESSION_META_GITHUB_KEY, SESSION_META_GIT_KEY, SESSION_META_MULTI_ROOT_KEY, SESSION_META_SOURCE_CONTROL_KEY, AH_META_ORCHESTRATION_DB_KEY, readSessionSpawnDepth, parseSessionOrchestration, withSessionSpawnDepth, withSessionOrchestration, SessionLifecycle, SessionStatus, ToolCallStatus, ToolResultContentType, AH_META_WORKSPACELESS_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_DONE_DB_KEY, AH_META_IS_READ_DB_KEY, buildChatUri, buildDefaultChatUri, buildResourceWatchChannelUri, buildSubagentChatUri, buildSubagentSessionUriPrefix, hostBuildInfoFromProduct, isAhpChatChannel, isDefaultChatUri, isSubagentChatUri, isSubagentSession, parseChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseResourceWatchChannelUri, parseSessionMultiRootMetadata, parseSubagentSessionUri, readSessionExternal, readSessionGitHubState, readSessionGitState, readSessionMultiRootMetadata, readSessionSourceControlState, readSessionWorkspaceless, withSessionExternal, withSessionGitHubState, withSessionGitState, withSessionMultiRootMetadata, withSessionSourceControlState, withSessionStatusFlag, withSessionWorkspaceless, withSessionFolderPickerDecision, readSessionFolderPickerDecision, parseSessionFolderPickerDecision, SESSION_META_FOLDER_PICKER_KEY, readSessionEhcliAdoptable, type ISessionSourceControlState, type SessionConfigState, type SessionSummary, type ToolResultSubagentContent, type Turn, type UsageInfo, chatStorageUri, hasReportedUsage } from '../common/state/sessionState.js'; import { readToolCallMeta } from '../common/meta/agentToolCallMeta.js'; +import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../common/meta/agentEphemeralSessionMeta.js'; +import { readChatSurfaceMeta, withChatSurfaceMeta } from '../common/meta/agentChatSurfaceMeta.js'; import { IProductService } from '../../product/common/productService.js'; import { buildBoundedSideChatSourceContext, getSideChatPartialResponse } from './agentPeerChats.js'; import { AgentConfigurationService, getEffectiveWorkingDirectories, IAgentConfigurationService } from './agentConfigurationService.js'; @@ -1603,6 +1605,7 @@ export class AgentService extends Disposable implements IAgentService { if (this._unpersistedChatBackings.has(session.toString())) { return true; } + try { const ref = await this._sessionDataService.tryOpenDatabase(session); if (!ref) { @@ -1617,6 +1620,7 @@ export class AgentService extends Disposable implements IAgentService { return false; } } + /** In-flight list computations, shared per mode until they settle or the registry changes. */ private readonly _inFlightListSessions = new Map }>(); @@ -1652,8 +1656,9 @@ export class AgentService extends Disposable implements IAgentService { // The first list waits for registration-time legacy migration if it is still in flight. await this._awaitInitialProviderMigration(); // The registry is the source of truth for top-level sessions. Internal - // chat backings and subagent sessions never enter it, and a transiently - // missing provider snapshot no longer evicts a session. + // chat backings and subagent sessions never enter it; ephemeral sessions + // are tombstoned at creation. A transiently missing provider snapshot no + // longer evicts a session. const registered = await this._listRegisteredSessions(); const metadataLimiter = new Limiter(4); const results = await Promise.all(registered.map(registeredSession => metadataLimiter.queue(async (): Promise => { @@ -2144,6 +2149,7 @@ export class AgentService extends Disposable implements IAgentService { async createSession(config?: IAgentCreateSessionConfig): Promise { const providerId = config?.provider ?? this._defaultProvider; const provider = providerId ? this._providers.get(providerId) : undefined; + const isEphemeral = config ? readEphemeralSessionMeta(config).isEphemeral === true : false; if (!provider) { throw new Error(`No agent provider registered for: ${providerId ?? '(none)'}`); } @@ -2225,15 +2231,28 @@ export class AgentService extends Disposable implements IAgentService { ]); const session = created.session; this._logService.trace(`[AgentService] createSession: initialization complete`); - try { - await this._retryRegistryMutation( - () => this._sessionRegistry.register(session, { provider: provider.id, startTime: Date.now(), source: 'explicit' }, { checkTombstone: false }), - `registration for ${session.toString()}`, - ); - this._invalidateSessionList(); - } catch (err) { - await this._rollbackProviderSession(provider, session); - throw err; + if (isEphemeral) { + try { + await this._retryRegistryMutation( + () => this._sessionRegistry.tombstone(session), + `tombstoning ephemeral session ${session.toString()}`, + ); + this._invalidateSessionList(); + } catch (err) { + await this._rollbackProviderSession(provider, session); + throw err; + } + } else { + try { + await this._retryRegistryMutation( + () => this._sessionRegistry.register(session, { provider: provider.id, startTime: Date.now(), source: 'explicit' }, { checkTombstone: false }), + `registration for ${session.toString()}`, + ); + this._invalidateSessionList(); + } catch (err) { + await this._rollbackProviderSession(provider, session); + throw err; + } } // Cancel any pending GC armed for this URI. A client may be @@ -3048,6 +3067,8 @@ export class AgentService extends Disposable implements IAgentService { : undefined; let _meta = withSessionGitHubState(undefined, explicitGitHubState); _meta = withSessionMultiRootMetadata(_meta, explicitMultiRoot ?? inheritedMultiRoot); + _meta = withEphemeralSessionMeta(_meta, config ? readEphemeralSessionMeta(config).isEphemeral : undefined); + _meta = withChatSurfaceMeta(_meta, readChatSurfaceMeta(config ?? {})); _meta = withSessionExternal(_meta, false); _meta = !config?.fork && !config?.workingDirectories ? withSessionWorkspaceless(_meta, true) @@ -3425,6 +3446,8 @@ export class AgentService extends Disposable implements IAgentService { async disposeSession(session: URI): Promise { this._logService.trace(`[AgentService] disposeSession: ${session.toString()}`); + const sessionKey = session.toString(); + const isEphemeral = this._stateManager.isEphemeralSession(sessionKey); this._stateManager.invalidateSessionChatResolutions(session.toString()); const sessionChats = this._stateManager.getSessionState(session.toString())?.chats ?? []; for (const chat of sessionChats) { @@ -3443,10 +3466,12 @@ export class AgentService extends Disposable implements IAgentService { if (provider) { await this._disposeSession(provider, session); } - await this._retryRegistryMutation( - () => this._sessionRegistry.unregister(session), - `unregistration for ${session.toString()}`, - ); + if (!isEphemeral) { + await this._retryRegistryMutation( + () => this._sessionRegistry.tombstone(session), + `unregistration for ${session.toString()}`, + ); + } this._invalidateSessionList(); if (provider) { this._sessionToProvider.delete(session.toString()); @@ -3472,6 +3497,12 @@ export class AgentService extends Disposable implements IAgentService { // Remove all subagent sessions for this parent this._sideEffects.removeSubagentSessions(session.toString()); this._stateManager.deleteSession(session.toString()); + if (isEphemeral) { + await this._retryRegistryMutation( + () => this._sessionRegistry.clearTombstone(session), + `clearing ephemeral session tombstone for ${session.toString()}`, + ); + } } private async _whenSessionDataIdle(session: URI): Promise { @@ -3633,6 +3664,7 @@ export class AgentService extends Disposable implements IAgentService { // A new subscriber means the session is being observed again; cancel // any pending GC or idle-release armed while it had no subscribers. this._cancelPendingSessionGc(resource); + this._cancelPendingEphemeralSessionGc(resource); this._cancelPendingSessionRelease(resource); // 0→1 transition — covers both the full subscribe path AND the // handshake fast-path used by `ProtocolServerHandler` when state is @@ -3655,6 +3687,9 @@ export class AgentService extends Disposable implements IAgentService { this._resourceSubscribers.delete(resource); this._changesetCoordinator.onLastSubscriber(resource); this._stateManager.onChangesetLivenessChanged(); + if (this._maybeScheduleEphemeralSessionGc(resource)) { + return; + } // An empty session whose last subscriber dropped is a candidate for // full GC (provider session, worktree, on-disk state). Sessions with // at least one turn fall through to {@link _maybeEvictIdleSession}, @@ -3674,6 +3709,28 @@ export class AgentService extends Disposable implements IAgentService { this._scheduleSessionRelease(resource); } + /** + * Schedules full cleanup for a throwaway surface after all its session and + * chat subscriptions are gone, regardless of whether it has completed turns. + */ + private _maybeScheduleEphemeralSessionGc(resource: URI): boolean { + const session = this._sessionReleaseResource(resource); + const sessionKey = session.toString(); + if (!this._stateManager.isEphemeralSession(sessionKey)) { + return false; + } + if (this._hasSessionSubscribers(session)) { + return true; + } + this._pendingSessionGc.set(session, disposableTimeout(() => { + this._pendingSessionGc.deleteAndDispose(session); + void this._runEphemeralSessionGc(session).catch(err => { + this._logService.error(err, `[AgentService] GC failed for ephemeral session ${sessionKey}`); + }); + }, SESSION_GC_GRACE_MS)); + return true; + } + private _cancelPendingSessionRelease(resource: URI): void { this._pendingSessionRelease.deleteAndDispose(this._sessionReleaseResource(resource)); } @@ -3744,6 +3801,21 @@ export class AgentService extends Disposable implements IAgentService { this._pendingSessionGc.deleteAndDispose(resource); } + private _cancelPendingEphemeralSessionGc(resource: URI): void { + const session = this._sessionReleaseResource(resource); + if (this._stateManager.isEphemeralSession(session.toString())) { + this._pendingSessionGc.deleteAndDispose(session); + } + } + + private async _runEphemeralSessionGc(session: URI): Promise { + if (this._hasSessionSubscribers(session)) { + return; + } + this._logService.info(`[AgentService] GC: disposing unsubscribed ephemeral session ${session.toString()}`); + await this.disposeSession(session); + } + /** * Fires {@link SESSION_GC_GRACE_MS} after a session lost its last * subscriber while empty. Re-checks the invariants (still no subscribers, diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index 026392cf35ea36..2092e0247982a1 100644 --- a/src/vs/platform/agentHost/node/agentSessionRegistry.ts +++ b/src/vs/platform/agentHost/node/agentSessionRegistry.ts @@ -50,11 +50,13 @@ export type RegisteredSessionMigration = (entry: IStoredRegisteredSession) => Pr * `markBackfilled` remains callable for tests and any explicit migration * tooling, but nothing in the per-provider sweep invokes it automatically. * - * Sessions removed via {@link unregister} are durably tombstoned so a forced + * Sessions passed to {@link tombstone} are durably tombstoned so a forced * or repeated native discovery pass — which re-reads a provider's catalog from - * scratch — cannot resurrect a session the user explicitly deleted. Tombstones - * are cleared only by an explicit {@link register} of the same session URI - * (i.e. an explicit create/restore), never by backfill itself. + * scratch — cannot register them. This covers both a session the user + * explicitly deleted and one that must never be listed at all (e.g. a + * throwaway chat surface, tombstoned at creation). Tombstones are cleared only + * by an explicit {@link register} of the same session URI (i.e. an explicit + * create/restore), never by backfill itself. */ export class AgentSessionRegistry extends Disposable { @@ -67,8 +69,14 @@ export class AgentSessionRegistry extends Disposable { return this._database.registerSession(session.toString(), sessionOptions, registerOptions); } - /** Remove a session from the registry (true delete) and tombstone it so discovery cannot resurrect it. No-op if absent. */ - async unregister(session: URI): Promise { + /** + * Removes any registry entry for `session` (a true delete) and durably + * tombstones it so discovery cannot register it. Used both to delete a + * session the user explicitly removed and to keep a session that must never + * be listed (e.g. a throwaway chat surface) out of the registry entirely. + * No-op on the registry entry if absent; the tombstone is still written. + */ + async tombstone(session: URI): Promise { await this._database.tombstoneAndUnregisterSession(session.toString()); } diff --git a/src/vs/platform/agentHost/node/agentSideEffects.ts b/src/vs/platform/agentHost/node/agentSideEffects.ts index b43656c0559a7c..9c04ed0dc49a3e 100644 --- a/src/vs/platform/agentHost/node/agentSideEffects.ts +++ b/src/vs/platform/agentHost/node/agentSideEffects.ts @@ -22,6 +22,7 @@ import { AgentHostClientType } from '../common/agentHostClientInfo.js'; import { AgentHostLaunchKind, createUnknownAgentHostClientTelemetryContext, type IAgentHostClientTelemetryContext } from '../common/agentHostTelemetry.js'; import { readAgentModelByokIdentifier } from '../common/agentModelByokMeta.js'; import { AgentSession, AgentSignal, IAgent, IAgentChatContext, IAgentToolPendingConfirmationSignal, type IAgentModelCallCompletedSignal } from '../common/agent.js'; +import { createTerminalChatInstruction } from '../common/meta/agentChatSurfaceMeta.js'; import { readToolCallMeta, toToolCallMeta } from '../common/meta/agentToolCallMeta.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; @@ -2196,10 +2197,12 @@ export class AgentSideEffects extends Disposable { this._turnTracker.setCurrentStage(turnChannel, turnId, failureStage); const resolvedAttachments = await this._resolveChatAttachments(message.attachments); const renameInstruction = await this._titleController.prepareInstructionForAgent(sessionChannel, chat); + const terminalSurface = this._stateManager.getSessionSurfaceMeta(sessionChannel); const hostInstructions = [ ...(this._agentConfigService.getRootValue(platformRootSchema, AgentHostMarkdownPlanRichLinksEnabledConfigKey) ? [createMarkdownPlanRichLinksInstruction(chat)] : []), + ...(terminalSurface ? [createTerminalChatInstruction(terminalSurface)] : []), ...(renameInstruction ? [renameInstruction] : []), ]; const sendContext = { ...clientOperationContext, ...(hostInstructions.length ? { hostInstructions } : {}) }; diff --git a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts index 8483d0ac669565..d34c59e325c074 100644 --- a/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts +++ b/src/vs/platform/agentHost/test/common/agentMetaReaders.test.ts @@ -6,6 +6,8 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import { readToolCallMeta, toToolCallMeta } from '../../common/meta/agentToolCallMeta.js'; +import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; +import { readChatSurfaceMeta, withChatSurfaceMeta, createTerminalChatInstruction } from '../../common/meta/agentChatSurfaceMeta.js'; import { readAgentCustomizationMeta, toAgentCustomizationMeta } from '../../common/meta/agentCustomizationMeta.js'; import { getCommandArgumentHint, getCompletionAction, readCompletionAttachmentMeta, toCommandCompletionAttachmentMeta, toSkillCompletionAttachmentMeta } from '../../common/meta/agentCompletionAttachmentMeta.js'; import { CustomizationType, MessageAttachmentKind, ToolCallStatus, hasReportedUsage, readUsageInfoMeta, type AgentCustomization, type ClientPluginCustomization, type ToolCallState, type UsageInfo } from '../../common/state/sessionState.js'; @@ -84,6 +86,74 @@ suite('Agent host _meta readers', () => { }); }); + suite('readEphemeralSessionMeta', () => { + test('reads the namespaced flag and drops wrong-typed values', () => { + assert.deepStrictEqual(readEphemeralSessionMeta({ _meta: withEphemeralSessionMeta(undefined, true) }), { isEphemeral: true }); + assert.deepStrictEqual(readEphemeralSessionMeta({ _meta: { 'vscode.chat.ephemeralSession': 'true' } }), {}); + assert.deepStrictEqual(readEphemeralSessionMeta({ _meta: { unrelated: true } }), {}); + }); + + test('preserves existing metadata when adding the flag', () => { + assert.deepStrictEqual( + withEphemeralSessionMeta({ existing: 'value' }, false), + { existing: 'value', 'vscode.chat.ephemeralSession': false }, + ); + }); + }); + + suite('readChatSurfaceMeta', () => { + test('reads terminal metadata with an optional shell type and drops malformed values', () => { + assert.deepStrictEqual( + readChatSurfaceMeta({ _meta: withChatSurfaceMeta(undefined, { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }) }), + { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }, + ); + assert.deepStrictEqual( + readChatSurfaceMeta({ _meta: withChatSurfaceMeta(undefined, { surface: 'terminal', osName: 'Linux' }) }), + { surface: 'terminal', osName: 'Linux' }, + ); + assert.strictEqual(readChatSurfaceMeta({ _meta: { 'vscode.chat.surface': { surface: 'editor', shellType: 'pwsh', osName: 'Windows' } } }), undefined); + assert.strictEqual(readChatSurfaceMeta({ _meta: { 'vscode.chat.surface': { surface: 'terminal', shellType: 1, osName: 'Windows' } } }), undefined); + assert.strictEqual(readChatSurfaceMeta({ _meta: { 'vscode.chat.surface': { surface: 'terminal', shellType: 'pwsh' } } }), undefined); + assert.strictEqual(readChatSurfaceMeta({ _meta: { unrelated: true } }), undefined); + }); + + test('preserves existing metadata when adding terminal metadata', () => { + assert.deepStrictEqual( + withChatSurfaceMeta({ existing: 'value' }, { surface: 'terminal', shellType: 'bash', osName: 'Linux' }), + { existing: 'value', 'vscode.chat.surface': { surface: 'terminal', shellType: 'bash', osName: 'Linux' } }, + ); + }); + }); + + suite('createTerminalChatInstruction', () => { + test('targets the active shell and OS when known, and keeps general guidance otherwise', () => { + const pwsh = createTerminalChatInstruction({ surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }); + const bash = createTerminalChatInstruction({ surface: 'terminal', shellType: 'bash', osName: 'Linux' }); + const shellUnknown = createTerminalChatInstruction({ surface: 'terminal', osName: 'Linux' }); + assert.deepStrictEqual({ + pwshTargetsShellAndOs: pwsh.includes('targeting Windows') && pwsh.includes('active shell is pwsh'), + pwshHasPowerShellIdioms: pwsh.includes('Stop-Process'), + pwshOmitsFallbackRule: !pwsh.includes('Python or Perl'), + bashTargetsShellAndOs: bash.includes('targeting Linux') && bash.includes('active shell is bash'), + bashHasFallbackRule: bash.includes('Python or Perl'), + bashOmitsPowerShellIdioms: !bash.includes('Stop-Process'), + shellUnknownTargetsOs: shellUnknown.includes('targeting Linux'), + shellUnknownOmitsShellGuidance: !shellUnknown.includes('active shell') && !shellUnknown.includes('Python or Perl') && !shellUnknown.includes('Stop-Process'), + allTagged: pwsh.startsWith('') && bash.endsWith('') && shellUnknown.endsWith(''), + }, { + pwshTargetsShellAndOs: true, + pwshHasPowerShellIdioms: true, + pwshOmitsFallbackRule: true, + bashTargetsShellAndOs: true, + bashHasFallbackRule: true, + bashOmitsPowerShellIdioms: true, + shellUnknownTargetsOs: true, + shellUnknownOmitsShellGuidance: true, + allTagged: true, + }); + }); + }); + suite('readAgentCustomizationMeta', () => { test('reads userInvocable, ignores garbage, round-trips', () => { assert.deepStrictEqual(readAgentCustomizationMeta(agentCustomization(undefined)), {}); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 4e4fa33ff152b6..218ec0e8463664 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -60,6 +60,8 @@ import type { INetworkDiagnosticsService } from '../../node/networkDiagnosticsSe import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { SessionServerToolName } from '../../common/serverToolNames.js'; import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js'; +import { readEphemeralSessionMeta, withEphemeralSessionMeta } from '../../common/meta/agentEphemeralSessionMeta.js'; +import { readChatSurfaceMeta, withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; /** * Replace individual operations on an agent's chat surface, delegating every @@ -3322,7 +3324,7 @@ suite('AgentService (node dispatcher)', () => { const unknown = AgentSession.uri('copilot', 'filter-unknown'); const register = (svc as unknown as { _registerDiscoveredChats(provider: IAgent, chats: readonly IAgentDiscoveredChat[]): Promise })._registerDiscoveredChats.bind(svc); await register(agent, [discoveredChat(registered), discoveredChat(deleted)]); - await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.unregister(deleted); + await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.tombstone(deleted); const known = await (svc as unknown as { _filterKnownSessions(sessions: readonly URI[]): Promise> })._filterKnownSessions([registered, deleted, unknown]); const reRegistered = await register(agent, [discoveredChat(deleted)]); @@ -3862,7 +3864,7 @@ suite('AgentService (node dispatcher)', () => { const legacy = AgentSession.uri('copilot', 'deleted-adoptable-legacy'); svc.registerProvider(agent); await svc.listSessions(); - await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.unregister(legacy); + await (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry.tombstone(legacy); agent.fireDiscoveredChats([{ ...discoveredChat(legacy), _meta: withSessionEhcliAdoptable(undefined), @@ -7687,6 +7689,107 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('createSession carries client-owned _meta slots and drops unknown ones', async () => { + const perSession = createPerSessionDataService(); + const agent = disposables.add(new MockAgent('copilot')); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + + const session = await svc.createSession({ + provider: 'copilot', + workingDirectories: [URI.file('/repo')], + _meta: { + ...withChatSurfaceMeta(withEphemeralSessionMeta(undefined, true), { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }), + // Session `_meta` is a whitelist, so an unrecognized slot must not survive. + 'vscode.chat.unknownFutureSlot': { hello: 'world' }, + }, + }); + + const state = svc.stateManager.getSessionState(session.toString()); + assert.deepStrictEqual({ + ephemeral: readEphemeralSessionMeta(state ?? {}).isEphemeral, + surface: readChatSurfaceMeta(state ?? {}), + unknownSlot: state?._meta?.['vscode.chat.unknownFutureSlot'], + }, { + ephemeral: true, + surface: { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' }, + unknownSlot: undefined, + }); + }); + + test('ephemeral session teardown clears its discovery tombstone', async () => { + const perSession = createPerSessionDataService(); + const agent = disposables.add(new MockAgent('copilot')); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + const registry = (svc as unknown as { _sessionRegistry: AgentSessionRegistry })._sessionRegistry; + + const session = await svc.createSession({ + provider: 'copilot', + _meta: withEphemeralSessionMeta(undefined, true), + }); + const tombstonedBeforeDispose = await registry.isTombstoned(session); + await svc.disposeSession(session); + + assert.deepStrictEqual({ + tombstonedBeforeDispose, + tombstonedAfterDispose: await registry.isTombstoned(session), + }, { + tombstonedBeforeDispose: true, + tombstonedAfterDispose: false, + }); + }); + + test('ephemeral sessions never appear in direct or overlay listSessions paths', async () => { + const perSession = createPerSessionDataService(); + const overlaySession = AgentSession.uri('copilot', 'ephemeral-overlay-session'); + const directSession = AgentSession.uri('copilot', 'ephemeral-direct-session'); + class LeakyAgent extends MockAgent { + override async listSessions(): Promise { + return [ + { session: overlaySession, startTime: Date.now(), modifiedTime: Date.now() }, + { session: directSession, startTime: Date.now(), modifiedTime: Date.now() }, + ]; + } + } + + const agent = disposables.add(new LeakyAgent('copilot')); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + svc.registerProvider(agent); + await svc.createSession({ + provider: 'copilot', + session: overlaySession, + _meta: withEphemeralSessionMeta(undefined, true), + }); + await svc.createSession({ + provider: 'copilot', + session: directSession, + _meta: withEphemeralSessionMeta(undefined, true), + }); + + const firstList = await svc.listSessions(); + const registeredBeforeRestart = await svc.getRegisteredSessions(); + + const restartedAgent = disposables.add(new LeakyAgent('copilot')); + const restarted = disposables.add(new AgentService(new NullLogService(), fileService, perSession.service, { _serviceBrand: undefined } as IProductService, createNoopGitService())); + restarted.registerProvider(restartedAgent); + const afterRestart = await restarted.listSessions(); + + assert.deepStrictEqual({ + overlayIncludesEphemeral: svc.stateManager.getOverlaySessionSummaries().some(s => s.resource === overlaySession.toString()), + directListIncludesEphemeral: firstList.some(s => s.session.toString() === directSession.toString()), + registeredBeforeRestart: registeredBeforeRestart.map(s => s.toString()), + restartedListIncludesEphemeral: afterRestart.some(s => s.session.toString() === overlaySession.toString() || s.session.toString() === directSession.toString()), + registeredAfterRestart: (await restarted.getRegisteredSessions()).map(s => s.toString()), + }, { + overlayIncludesEphemeral: false, + directListIncludesEphemeral: false, + registeredBeforeRestart: [], + restartedListIncludesEphemeral: false, + registeredAfterRestart: [], + }); + }); + test('createChat succeeds and persists the backing-session marker after one transient write failure', async () => { // A DB whose `setMetadata` can be told to fail for the peer-chat // backing marker key a configurable number of times, to simulate a diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index 28f37d0a2d75e5..29a156a1306853 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -246,7 +246,7 @@ suite('AgentSessionRegistry', () => { const registerDiscovered = (registry: AgentSessionRegistry, session: typeof a, provider: 'copilot' | 'claude', startTime: number) => registry.register(session, { provider, startTime, source: 'discovery' }, { checkTombstone: true }); - test('register / list / unregister', async () => { + test('register / list / tombstone', async () => { const registry = createRegistry(); assert.strictEqual(await registry.isEmpty(), true); @@ -262,7 +262,7 @@ suite('AgentSessionRegistry', () => { ].sort((x, y) => x.session.localeCompare(y.session)), ); - await registry.unregister(a); + await registry.tombstone(a); assert.deepStrictEqual((await list(registry)).map(s => s.session.toString()), [b.toString()]); }); @@ -275,12 +275,12 @@ suite('AgentSessionRegistry', () => { assert.strictEqual(entry.startTime, 100); }); - test('register and unregister preserve submission order', async () => { + test('register and tombstone preserve submission order', async () => { const registry = createRegistry(); await Promise.all([ registerExplicit(registry, a, 'copilot', 100), - registry.unregister(a), + registry.tombstone(a), ]); assert.deepStrictEqual(await list(registry), []); @@ -397,17 +397,17 @@ suite('AgentSessionRegistry', () => { assert.deepStrictEqual((await list(registry)).map(entry => entry.session.toString()), [a.toString()]); }); - test('unregister persistence failure can be retried', async () => { + test('tombstone persistence failure can be retried', async () => { await database.close(); database = new TestAgentHostDatabase(); const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); (database as TestAgentHostDatabase).failNextWrite(); - await assert.rejects(registry.unregister(a), /write failed/); + await assert.rejects(registry.tombstone(a), /write failed/); assert.deepStrictEqual((await list(registry)).map(entry => entry.session.toString()), [a.toString()]); - await registry.unregister(a); + await registry.tombstone(a); assert.deepStrictEqual(await list(registry), []); }); @@ -459,13 +459,13 @@ suite('AgentSessionRegistry', () => { ); }); - test('unregister durably tombstones a session so it is not resurrected by register', async () => { + test('tombstone durably prevents a session from being resurrected by register', async () => { const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); assert.strictEqual(await registry.isTombstoned(a), false); - await registry.unregister(a); - assert.strictEqual(await registry.isTombstoned(a), true, 'unregister must durably tombstone the session'); + await registry.tombstone(a); + assert.strictEqual(await registry.isTombstoned(a), true, 'tombstone must durably tombstone the session'); // The tombstone persists across instances (it is durable, not in-process). const second = createRegistry(); @@ -475,7 +475,7 @@ suite('AgentSessionRegistry', () => { test('register clears an existing tombstone (explicit create)', async () => { const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); - await registry.unregister(a); + await registry.tombstone(a); assert.strictEqual(await registry.isTombstoned(a), true); // An explicit re-register (a genuine new `createSession`) must clear @@ -488,7 +488,7 @@ suite('AgentSessionRegistry', () => { test('clearTombstone can also be called directly', async () => { const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); - await registry.unregister(a); + await registry.tombstone(a); assert.strictEqual(await registry.isTombstoned(a), true); await registry.clearTombstone(a); @@ -498,7 +498,7 @@ suite('AgentSessionRegistry', () => { test('discovery declines to register (or resurrect) a tombstoned session', async () => { const registry = createRegistry(); await registerExplicit(registry, a, 'copilot', 100); - await registry.unregister(a); + await registry.tombstone(a); assert.strictEqual(await registry.isTombstoned(a), true); // Unlike `register`, a revival attempt (backfill, restore) must not diff --git a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts index 17ea4d9e462b88..f2cc47addcc4c4 100644 --- a/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSideEffects.test.ts @@ -46,6 +46,7 @@ import type { IAgentHostAskQuestionsToolInvokedEvent } from '../../node/agentHos import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { AgentHostStateManager } from '../../node/agentHostStateManager.js'; +import { withChatSurfaceMeta } from '../../common/meta/agentChatSurfaceMeta.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentHostStorageService } from '../../node/agentHostStorageService.js'; import { applyMcpServerEnablement } from '../../node/shared/mcpCustomizationController.js'; @@ -195,7 +196,7 @@ suite('AgentSideEffects', () => { const sessionUri = AgentSession.uri('mock', 'session-1'); const defaultChatUri = buildDefaultChatUri(sessionUri); - function setupSession(workingDirectory?: string): void { + function setupSession(workingDirectory?: string, meta?: Record): void { stateManager.createSession({ resource: sessionUri.toString(), provider: 'mock', @@ -205,6 +206,7 @@ suite('AgentSideEffects', () => { modifiedAt: new Date().toISOString(), project: { uri: 'file:///test-project', displayName: 'Test Project' }, workingDirectories: workingDirectory ? [workingDirectory] : undefined, + _meta: meta, }); stateManager.setSessionChangesets(sessionUri.toString(), buildDefaultChangesetCatalog(sessionUri.toString())); stateManager.dispatchServerAction(sessionUri.toString(), { type: ActionType.SessionReady, }); @@ -662,6 +664,7 @@ suite('AgentSideEffects', () => { type: ActionType.RootConfigChanged, config: { [AgentHostMarkdownPlanRichLinksEnabledConfigKey]: true }, }); + const peerChatUri = buildChatUri(sessionUri, 'peer-plan'); stateManager.addChat(sessionUri.toString(), peerChatUri, { title: 'Plan chat' }); @@ -688,6 +691,33 @@ suite('AgentSideEffects', () => { assert.strictEqual(agent.sendMessageCalls[0].prompt, 'Create a plan'); }); + test('adds terminal command guidance for a terminal surface', async () => { + setupSession(undefined, withChatSurfaceMeta(undefined, { surface: 'terminal', shellType: 'pwsh', osName: 'Windows' })); + + sideEffects.handleAction(defaultChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'Stop a process', origin: { kind: MessageKind.User } }, + }); + await waitForSendMessageCalls(1); + + const sendContext = agent.chatContexts.find(call => call.boundary === 'sendMessage')?.context; + assert.deepStrictEqual(!URI.isUri(sendContext) ? sendContext?.hostInstructions : undefined, [[ + '', + 'You specialize in the command line. Help the user craft a command to run.', + '- You\'re targeting Windows.', + '- The active shell is pwsh.', + '- Prefer single-line commands. Omit explanations unless the command is complex; then be concise.', + '- Use `{placeholder_text}` for required replacement text that the user did not provide.', + '- Prefer idiomatic PowerShell: use `Stop-Process` or `Get-NetTCPConnection` instead of `kill` or `lsof`.', + '- Prefer cross-platform PowerShell and use Unix utilities only when PowerShell has no equivalent.', + '- Do not try to accomplish the task yourself, instead provide a pwsh command to run.', + '- Avoid extraneous steps or context-gathering prior to providing the command, unless context is required to resolve ambiguity.', + '', + ].join('\n')]); + }); + test('passes the dispatching client id and type to sendMessage', async () => { setupSession(); const action: ChatAction = { 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 39be482241374c..ad05e3de432ebd 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -28,6 +28,7 @@ import { IAgentHostFileSystemService } from '../../../../../services/agentHost/c import { IAuthenticationService } from '../../../../../services/authentication/common/authentication.js'; import { IWorkbenchEnvironmentService } from '../../../../../services/environment/common/environmentService.js'; import { ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatSessionsService, isLocalAgentHostTarget } from '../../../common/chatSessionsService.js'; +import { ChatAgentLocation } from '../../../common/constants.js'; import { ICustomizationHarnessService } from '../../../common/customizationHarnessService.js'; import { ILanguageModelsService } from '../../../common/languageModels.js'; import { languageModelSourcePresentationRegistry } from '../../../common/languageModelSourcePresentation.js'; @@ -282,6 +283,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr name: agentId, displayName: agent.displayName, description: agent.description, + locations: agent.provider === 'copilotcli' ? [ChatAgentLocation.Chat, ChatAgentLocation.Terminal] : undefined, customAgentTarget: this._isSessionsWindow ? undefined : Target.GitHubCopilot, canDelegate: true, requiresCustomModels: true, @@ -339,6 +341,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr description: agent.description, connection: this._agentHostService, connectionAuthority: LOCAL_AGENT_HOST_AUTHORITY, + onSessionMaterialized: resource => this._chatSessionsService.notifySessionMaterialized?.(resource), resolveAuthentication: (resources) => this._resolveAuthenticationInteractively(resources), promptCacheNotification: this._promptCacheNotification, })); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts index bba09c727a8585..c790e00aaac7ec 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.ts @@ -769,6 +769,8 @@ export interface IAgentHostSessionHandlerConfig { readonly resolveWorkingDirectory?: (sessionResource: URI) => URI | undefined; /** Whether a final-looking chat resource is still a client-side draft. */ readonly isNewSession?: (sessionResource: URI) => boolean; + /** Called after a locally-created session has been accepted by the backend. */ + readonly onSessionMaterialized?: (sessionResource: URI) => void; /** * Optional callback invoked when the server rejects an operation because * authentication is required. Should trigger interactive authentication @@ -1254,6 +1256,23 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC } } + updateChatSessionMetadata(sessionResource: URI, metadata: Record): void { + const backendSession = this._resolveSessionUri(sessionResource); + const state = this._getSessionState(backendSession.toString()); + if (state) { + this._config.connection.dispatch(backendSession.toString(), { + type: ActionType.SessionMetaChanged, + _meta: { ...state._meta, ...metadata }, + }); + return; + } + + this._provisionalService.setSessionCreationMetadata(sessionResource, { + ...(this._provisionalService.getInitialSessionMetadata(sessionResource) ?? {}), + ...metadata, + }); + } + async provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise { if (sessionResource.path.substring(1).startsWith('untitled-')) { throw new Error(`Agent host chat sessions must be created by the sessions provider: ${sessionResource.toString()}`); @@ -4988,6 +5007,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC private async _createAndSubscribe(sessionResource: URI, model: ModelSelection | undefined, fork?: { session: URI; chat: URI; turnIndex: number; turnId: string }, config?: Record, importConversation?: { readonly turns: readonly Turn[]; readonly model?: ModelSelection }, onFailureStage?: (stage: AgentHostInvocationFailureStage) => void): Promise { const workingDirectories = this._resolveRequestedWorkingDirectories(sessionResource); const requestedSession = fork ? undefined : this._resolveSessionUri(sessionResource); + const meta = this._provisionalService.getInitialSessionMetadata(sessionResource); this._logService.trace(`[AgentHost] Creating new session, model=${model?.id ?? '(default)'}, provider=${this._config.provider}${fork ? `, fork from ${fork.session.toString()} at index ${fork.turnIndex}` : ''}`); @@ -5011,7 +5031,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC try { session = await this._config.connection.createSession({ session: requestedSession, - _meta: this._provisionalService.getInitialSessionMetadata(), + _meta: meta, model, provider: this._config.provider, workingDirectories, @@ -5031,7 +5051,7 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC onFailureStage?.('createSession'); session = await this._config.connection.createSession({ session: requestedSession, - _meta: this._provisionalService.getInitialSessionMetadata(), + _meta: meta, model, provider: this._config.provider, workingDirectories, @@ -5048,10 +5068,12 @@ export class AgentHostSessionHandler extends Disposable implements IChatSessionC throw err; } } + this._provisionalService.clearSessionCreationMetadata(sessionResource); if (requestedSession && !isEqual(session, requestedSession)) { throw new Error(`Agent host returned unexpected session URI. Expected ${requestedSession.toString()}, got ${session.toString()}`); } + this._config.onSessionMaterialized?.(sessionResource); this._logService.trace(`[AgentHost] Created session: ${session.toString()}`); diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts index 7bbe829b8f85cb..b3df465f570f0d 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListController.ts @@ -9,9 +9,11 @@ import { Disposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; import { AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; +import { withEphemeralSessionMeta } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import type { ChangesSummary } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; import { SessionStatus, readSessionEhcliAdoptable, SESSION_META_EHCLI_ADOPTABLE_KEY, type SessionSummary } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { IWorkspaceContextService } from '../../../../../../platform/workspace/common/workspace.js'; +import { IChatService } from '../../../common/chatService/chatService.js'; import { ChatSessionStatus, IChatNewSessionRequest, IChatSessionItem, IChatSessionItemController, IChatSessionItemsDelta } from '../../../common/chatSessionsService.js'; import { getAgentSessionProviderIcon } from '../agentSessions.js'; import { IAgentHostUntitledProvisionalSessionService } from './agentHostUntitledProvisionalSessionService.js'; @@ -52,10 +54,19 @@ export class AgentHostSessionListController extends Disposable implements IChatS @IWorkspaceContextService private readonly _workspaceContextService: IWorkspaceContextService, @IAgentHostNewSessionFolderService private readonly _newSessionFolderService: IAgentHostNewSessionFolderService, @IAgentHostImportConversationStore private readonly _importConversationStore: IAgentHostImportConversationStore, + @IChatService chatService: IChatService, ) { super(); void _connectionAuthority; + this._register(chatService.onDidDisposeSession(event => { + for (const resource of event.sessionResources) { + if (resource.scheme === this._sessionType) { + this._sessionListStore.clearPendingNewSession(this._provider, AgentSession.id(resource)); + } + } + })); + // Project the store's provider-agnostic delta down to this provider's // chat-session-item delta, dropping events that don't touch us. Both // combinators are bound to `this._store` so disposing the controller @@ -77,6 +88,12 @@ export class AgentHostSessionListController extends Disposable implements IChatS && this._sessionListStore.isPendingNewSession(this._provider, resource.path.substring(1)); } + notifySessionMaterialized(resource: URI): void { + if (resource.scheme === this._sessionType) { + this._sessionListStore.clearPendingNewSession(this._provider, AgentSession.id(resource)); + } + } + async newChatSessionItem(request: IChatNewSessionRequest, token: CancellationToken): Promise { if (token.isCancellationRequested) { return undefined; @@ -90,6 +107,10 @@ export class AgentHostSessionListController extends Disposable implements IChatS createdAt: now, modifiedAt: now, }); + const metadata = withEphemeralSessionMeta(request._meta, request.isEphemeral ? true : undefined); + if (metadata) { + this._provisional.setSessionCreationMetadata(item.resource, metadata); + } // Bridge any pre-creation provisional session the user built up // against the untitled chat-input URI to the freshly-minted real diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index b191d0b07a842a..311fbdfd35f2c7 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -119,6 +119,11 @@ export class AgentHostSessionListStore extends Disposable { return this._pendingNewSessions.has(this._key(provider, rawId)); } + /** Stop treating a locally-created session as pending without adding it to the visible list. */ + clearPendingNewSession(provider: string, rawId: string): void { + this._pendingNewSessions.delete(this._key(provider, rawId)); + } + resetCache(): void { this._cacheValid = false; this._mutationGeneration++; diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts index 457954ce5e2bc3..bc2877f3746343 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostUntitledProvisionalSessionService.ts @@ -112,8 +112,14 @@ export interface IAgentHostUntitledProvisionalSessionService { */ getInitialSessionConfig(): Record | undefined; - /** Initial session metadata contributed by the current Editor workspace. */ - getInitialSessionMetadata(): Record | undefined; + /** Initial session metadata, including any metadata registered for the resource. */ + getInitialSessionMetadata(sessionResource?: URI): Record | undefined; + + /** Associates creation metadata with a real chat resource until the backend session is created. */ + setSessionCreationMetadata(sessionResource: URI, metadata: Record): void; + + /** Drops creation metadata after the backend session has been created or abandoned. */ + clearSessionCreationMetadata(sessionResource: URI): void; /** * Ensure a backend provisional exists for an untitled chat UI resource. @@ -261,6 +267,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple private readonly _pending = new ResourceMap>(); private readonly _resolvedConfigs = new ResourceMap(); private readonly _resolvedConfigRequestSeq = new ResourceMap(); + private readonly _sessionCreationMetadata = new ResourceMap>(); private readonly _pendingBackendDisposals = new ResourceSet(); // URIs that were the source of a successful `tryRebind`. The chat widget // briefly reattaches to the old untitled URI before its viewModel switches @@ -298,6 +305,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple } this._resolvedConfigs.delete(sessionResource); this._resolvedConfigRequestSeq.delete(sessionResource); + this._sessionCreationMetadata.delete(sessionResource); // Drop any tombstone for the abandoned untitled URI so the // set doesn't grow unbounded across the workbench lifetime. this._rebound.delete(sessionResource); @@ -384,16 +392,28 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple entry.activeClientBinding.value = new ActiveClientBinding(roots, scope, this._agentHostService.clientId, () => this._publishActiveClient(entry)); } - getInitialSessionMetadata(): Record | undefined { + getInitialSessionMetadata(sessionResource?: URI): Record | undefined { const workspace = this._workspaceContextService.getWorkspace(); - if (this._environmentService.isSessionsWindow + const workspaceMetadata = this._environmentService.isSessionsWindow || this._workspaceContextService.getWorkbenchState() !== WorkbenchState.WORKSPACE - || !URI.isUri(workspace.configuration)) { - return undefined; + || !URI.isUri(workspace.configuration) + ? undefined + : withSessionMultiRootMetadata(undefined, { + workspaceFile: workspace.configuration.toString(), + }); + const sessionMetadata = sessionResource ? this._sessionCreationMetadata.get(sessionResource) : undefined; + if (!sessionMetadata) { + return workspaceMetadata; } - return withSessionMultiRootMetadata(undefined, { - workspaceFile: workspace.configuration.toString(), - }); + return { ...(workspaceMetadata ?? {}), ...sessionMetadata }; + } + + setSessionCreationMetadata(sessionResource: URI, metadata: Record): void { + this._sessionCreationMetadata.set(sessionResource, metadata); + } + + clearSessionCreationMetadata(sessionResource: URI): void { + this._sessionCreationMetadata.delete(sessionResource); } getInitialSessionConfig(): Record | undefined { @@ -823,6 +843,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple const entry = this._entries.get(sessionResource); this._resolvedConfigs.delete(sessionResource); this._resolvedConfigRequestSeq.delete(sessionResource); + this._sessionCreationMetadata.delete(sessionResource); if (!entry) { return Promise.resolve(); } @@ -856,6 +877,7 @@ export class AgentHostUntitledProvisionalSessionService extends Disposable imple this._pendingBackendDisposals.clear(); this._resolvedConfigs.clear(); this._resolvedConfigRequestSeq.clear(); + this._sessionCreationMetadata.clear(); this._rebound.clear(); super.dispose(); } diff --git a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts index 3917e49d3ca58d..a0a65a7a8f61c5 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.shared.contribution.ts @@ -2283,6 +2283,15 @@ configurationRegistry.registerConfiguration({ description: nls.localize('chat.subagents.useRichRendering', "Controls whether subagents in chat editors use a rich presentation that opens each subagent in its own editor instead of rendering its full activity inline in the parent chat."), default: true, }, + [ChatConfiguration.TerminalAgentHostEnabled]: { + type: 'boolean', + description: nls.localize('chat.terminal.agentHost.enabled', "Controls whether Terminal Chat is backed by the Agent Host instead of the extension host. Applied on startup."), + default: false, + tags: ['experimental'], + experiment: { + mode: 'startup' + } + }, [ChatConfiguration.CollectInstructionsInExtension]: { type: 'boolean', description: nls.localize('chat.experimental.collectInstructionsInExtension', "When enabled, automatic instruction collection (.instructions.md, agent instructions, customizations index) is performed by the GitHub Copilot Chat extension instead of the core workbench."), 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 6766f5e52f6286..a5645c446c07e1 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSessions/chatSessions.contribution.ts @@ -839,7 +839,7 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ isCore: false, isDynamic: true, slashCommands: contribution.commands ?? [], - locations: [ChatAgentLocation.Chat], + locations: contribution.locations ?? [ChatAgentLocation.Chat], modes: [ChatModeKind.Agent, ChatModeKind.Ask], disambiguation: [], metadata: { @@ -1038,6 +1038,17 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return this._contentProviders.get(resolvedType)?.resolveChatResponseUri?.(sessionResource, href, kind) ?? href; } + public updateChatSessionMetadata(sessionResource: URI, metadata: Record): boolean { + const sessionType = getChatSessionType(sessionResource); + const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; + const provider = this._contentProviders.get(resolvedType); + if (!provider?.updateChatSessionMetadata) { + return false; + } + provider.updateChatSessionMetadata(sessionResource, metadata); + return true; + } + async getChatInputCompletionTriggerCharacters(sessionType: string): Promise { const resolvedType = this._resolveToPrimaryType(sessionType) || sessionType; const provider = this._contentProviders.get(resolvedType); @@ -1217,6 +1228,10 @@ export class ChatSessionsService extends Disposable implements IChatSessionsServ return controllerData.controller.newChatSessionItem?.(request, token); } + notifySessionMaterialized(sessionResource: URI): void { + this._getChatSessionItemController(sessionResource)?.controller.notifySessionMaterialized?.(sessionResource); + } + async deleteChatSessionItem(sessionResource: URI, token: CancellationToken): Promise { const controllerData = this._getChatSessionItemController(sessionResource); if (!controllerData?.controller.deleteChatSessionItem) { diff --git a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts index 3c078821d4ae35..2a00f4c78ff3a1 100644 --- a/src/vs/workbench/contrib/chat/common/chatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/common/chatSessionsService.ts @@ -20,6 +20,7 @@ import { IChatEditingSession } from './editing/chatEditingService.js'; import { IChatRequestModeInstructions, IChatRequestVariableData, ISerializableChatModelInputState } from './model/chatModel.js'; import { IChatRequestOrigin } from './chatRequestOrigin.js'; import { IChatProgress, IChatResponseErrorDetails, IChatSessionTiming } from './chatService/chatService.js'; +import { ChatAgentLocation } from './constants.js'; import { Target } from './promptSyntax/promptTypes.js'; export const enum ChatSessionsExtensions { @@ -165,6 +166,11 @@ export interface IChatSessionsExtensionPoint { readonly inputPlaceholder?: string; readonly capabilities?: IChatAgentAttachmentCapabilities; readonly commands?: IChatSessionCommandContribution[]; + /** + * Chat surfaces where this session type's agent can be selected. + * Defaults to the primary Chat surface. + */ + readonly locations?: ChatAgentLocation[]; readonly canDelegate?: boolean; readonly isReadOnly?: boolean; /** @@ -459,6 +465,9 @@ export interface IChatSession extends IDisposable { export interface IChatSessionContentProvider { provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise; + /** Updates provider-owned metadata for a session. */ + updateChatSessionMetadata?(sessionResource: URI, metadata: Record): void; + /** Resolves a parsed response Markdown URI before it is sanitized and rendered. */ resolveChatResponseUri?(sessionResource: URI, href: string, kind: 'link' | 'image'): string; @@ -619,6 +628,12 @@ export interface IChatNewSessionRequest { readonly command?: string; readonly initialSessionOptions?: ReadonlyChatSessionOptionsMap; + /** VS Code-specific metadata forwarded to Agent Host session creation. */ + readonly _meta?: Record; + /** + * Marks this session as a throwaway UI surface that must not be retained or listed. + */ + readonly isEphemeral?: boolean; /** * The chat-input session resource the user was typing into when this @@ -646,6 +661,11 @@ export interface IChatSessionItemController { newChatSessionItem?(request: IChatNewSessionRequest, token: CancellationToken): Promise; + /** + * Notifies the controller that a locally-created session now exists on its backend. + */ + notifySessionMaterialized?(resource: URI): void; + getNewChatSessionInputState?(sessionResource: URI, token: CancellationToken): Promise; resolveChatSessionItem?(resource: URI, token: CancellationToken): Promise; @@ -845,6 +865,7 @@ export interface IChatSessionsService { registerChatSessionContentProvider(scheme: string, provider: IChatSessionContentProvider): IDisposable; canResolveChatSession(sessionType: string): Promise; getOrCreateChatSession(sessionResource: URI, token: CancellationToken): Promise; + updateChatSessionMetadata(sessionResource: URI, metadata: Record): boolean; /** Resolves a parsed response Markdown URI through its session content provider. */ resolveChatResponseUri(sessionResource: URI, href: string, kind: 'link' | 'image'): string; @@ -960,6 +981,11 @@ export interface IChatSessionsService { */ createNewChatSessionItem(chatSessionType: string, request: IChatNewSessionRequest, token: CancellationToken): Promise; + /** + * Notifies the registered controller that a locally-created session now exists on its backend. + */ + notifySessionMaterialized?(sessionResource: URI): void; + /** * Permanently deletes a chat session item by delegating to the registered controller's `deleteChatSessionItem` * handler. Throws if the controller does not implement `deleteChatSessionItem`. diff --git a/src/vs/workbench/contrib/chat/common/constants.ts b/src/vs/workbench/contrib/chat/common/constants.ts index ada948dabfce68..67ffafd3f30855 100644 --- a/src/vs/workbench/contrib/chat/common/constants.ts +++ b/src/vs/workbench/contrib/chat/common/constants.ts @@ -61,6 +61,7 @@ export enum ChatConfiguration { ThinkingStyle = 'chat.agent.thinkingStyle', ThinkingGenerateTitles = 'chat.agent.thinking.generateTitles', TerminalToolsInThinking = 'chat.agent.thinking.terminalTools', + TerminalAgentHostEnabled = 'chat.terminal.agentHost.enabled', CollapseCompletedResponses = 'chat.agent.collapseCompletedResponses', SimpleTerminalCollapsible = 'chat.tools.terminal.simpleCollapsible', CompressOutputEnabled = 'chat.tools.compressOutput.enabled', 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 8060ae9072a788..87b7dd8ad4c463 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 @@ -27,6 +27,7 @@ import { IConfigurationService } from '../../../../../../platform/configuration/ import { IAgentCreateSessionConfig, IAgentHostService, IAgentSessionMetadata, AgentSession } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ChatInputRequestWithPlanReview } from '../../../../../../platform/agentHost/common/agentHostPlanReview.js'; import { AgentFeedbackAttachmentDisplayKind, AgentFeedbackAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/agentFeedbackAttachments.js'; +import { VSCODE_EPHEMERAL_SESSION_META_KEY } from '../../../../../../platform/agentHost/common/meta/agentEphemeralSessionMeta.js'; import { getElementAttachmentCorrelationId, toElementAttachmentMeta } from '../../../../../../platform/agentHost/common/meta/agentElementAttachments.js'; import { BrowserViewAttachmentDisplayKind, BrowserViewAttachmentMetadataKey } from '../../../../../../platform/agentHost/common/meta/browserViewAttachments.js'; import { AgentSystemNotificationKind, AgentSystemNotificationSeverity, toAgentSystemNotificationMeta } from '../../../../../../platform/agentHost/common/meta/agentSystemNotificationMeta.js'; @@ -806,10 +807,14 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv }); const chatModels = new Map(); const onDidCreateModel = disposables.add(new Emitter()); + const onDidDisposeSession = disposables.add(new Emitter<{ readonly sessionResources: readonly URI[]; readonly reason: 'cleared' }>()); const chatService = { getSession: (sessionResource: URI) => chatModels.get(sessionResource.toString()), onDidCreateModel: onDidCreateModel.event, - onDidDisposeSession: Event.None, + onDidDisposeSession: onDidDisposeSession.event, + fireDidDisposeSession(...sessionResources: URI[]) { + onDidDisposeSession.fire({ sessionResources, reason: 'cleared' }); + }, setSession(sessionResource: URI, model: IChatModel) { chatModels.set(sessionResource.toString(), model); onDidCreateModel.fire(model); @@ -885,11 +890,14 @@ function createTestServices(disposables: DisposableStore, workingDirectoryResolv announceRendered: () => { }, }); instantiationService.stub(IAgentHostCustomizationService, customizationServiceOverride ?? new NullAgentHostCustomizationService()); + const sessionCreationMetadata = new Map>(); instantiationService.stub(IAgentHostUntitledProvisionalSessionService, { onDidChange: Event.None, get: () => undefined, getInitialSessionConfig: () => undefined, - getInitialSessionMetadata: () => undefined, + getInitialSessionMetadata: sessionResource => sessionResource ? sessionCreationMetadata.get(sessionResource.toString()) : undefined, + setSessionCreationMetadata: (sessionResource, metadata) => sessionCreationMetadata.set(sessionResource.toString(), metadata), + clearSessionCreationMetadata: sessionResource => sessionCreationMetadata.delete(sessionResource.toString()), waitForPending: async () => undefined, getOrCreate: async () => undefined, tryRebind: async () => undefined, @@ -996,6 +1004,7 @@ function createContribution(disposables: DisposableStore, opts?: { authServiceOv connection: agentHostService, connectionAuthority: 'local', isNewSession: sessionResource => listController.isNewSession(sessionResource), + onSessionMaterialized: sessionResource => listController.notifySessionMaterialized(sessionResource), })); const contribution = disposables.add(instantiationService.createInstance(AgentHostContribution)); @@ -3481,6 +3490,35 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(listController.items.some(existing => existing.resource.toString() === item.resource.toString()), true); })); + test('terminal ephemeral session creation carries the list-suppression metadata', () => runWithFakedTimers({ useFakeTimers: true }, async () => { + const { listController, sessionHandler, agentHostService, chatAgentService, chatService } = createContribution(disposables); + const item = await listController.newChatSessionItem({ prompt: '', isEphemeral: true }, CancellationToken.None); + assert.ok(item); + assert.strictEqual(listController.isNewSession(item.resource), true); + + const { turnPromise, session, turnId, fire } = await startTurn(sessionHandler, agentHostService, chatAgentService, disposables, { + message: 'Explain this command', + sessionResource: item.resource, + }); + fire({ type: 'chat/turnComplete', endedAt: '2025-01-01T00:00:00.000Z', session, turnId } as ChatAction); + await turnPromise; + + assert.deepStrictEqual({ + resourceQuery: item.resource.query, + metadata: agentHostService.createSessionCalls[0]._meta, + }, { + resourceQuery: '', + metadata: { [VSCODE_EPHEMERAL_SESSION_META_KEY]: true }, + }); + assert.strictEqual(listController.isNewSession(item.resource), false, 'materialization must clear the pending marker'); + + const abandoned = await listController.newChatSessionItem({ prompt: '', isEphemeral: true }, CancellationToken.None); + assert.ok(abandoned); + assert.strictEqual(listController.isNewSession(abandoned.resource), true); + chatService.fireDidDisposeSession(abandoned.resource); + assert.strictEqual(listController.isNewSession(abandoned.resource), false, 'disposing an abandoned model must clear the pending marker'); + })); + test('newChatSessionItem rebinds untitled provisional to real resource so chip-selected config survives first send', () => runWithFakedTimers({ useFakeTimers: true }, async () => { const { instantiationService, agentHostService, newSessionFolderService } = createTestServices(disposables); diff --git a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts index 0db1858069a41e..b9331147ede382 100644 --- a/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts +++ b/src/vs/workbench/contrib/chat/test/common/mockChatSessionsService.ts @@ -187,6 +187,16 @@ export class MockChatSessionsService implements IChatSessionsService { return provider.provideChatSessionContent(sessionResource, token); } + updateChatSessionMetadata(sessionResource: URI, metadata: Record): boolean { + const sessionType = getChatSessionType(sessionResource); + const provider = this.contentProviders.get(sessionType); + if (!provider?.updateChatSessionMetadata) { + return false; + } + provider.updateChatSessionMetadata(sessionResource, metadata); + return true; + } + async getChatSessionHistory(sessionResource: URI, token: CancellationToken): Promise { const session = await this.getOrCreateChatSession(sessionResource, token); try { diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts index 06e092ab794db0..a45ab1b800f462 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.chat.contribution.ts @@ -32,10 +32,12 @@ import { TerminalChatEnabler } from './terminalChatEnabler.js'; import { InstantiationType, registerSingleton } from '../../../../../platform/instantiation/common/extensions.js'; import { ITerminalChatService } from '../../../terminal/browser/terminal.js'; import { TerminalChatService } from './terminalChatService.js'; +import { ITerminalChatSessionResolver, TerminalChatSessionResolver } from './terminalChatSessionResolver.js'; // #region Services registerSingleton(ITerminalChatService, TerminalChatService, InstantiationType.Delayed); +registerSingleton(ITerminalChatSessionResolver, TerminalChatSessionResolver, InstantiationType.Delayed); // #endregion diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatSessionResolver.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatSessionResolver.ts new file mode 100644 index 00000000000000..b461d9c9b0db2e --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatSessionResolver.ts @@ -0,0 +1,107 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from '../../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; +import { OperatingSystem } from '../../../../../base/common/platform.js'; +import { withChatSurfaceMeta } from '../../../../../platform/agentHost/common/meta/agentChatSurfaceMeta.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { createDecorator } from '../../../../../platform/instantiation/common/instantiation.js'; +import { IChatModelReference, IChatService } from '../../../chat/common/chatService/chatService.js'; +import { ChatAgentLocation, ChatConfiguration } from '../../../chat/common/constants.js'; +import { IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../../chat/common/chatSessionsService.js'; + +export const ITerminalChatSessionResolver = createDecorator('terminalChatSessionResolver'); + +/** Result of resolving the chat model used by the terminal chat surface. */ +export interface ITerminalChatSessionResolution { + readonly modelRef: IChatModelReference; + /** + * The chat session contribution the widget must lock to so requests carry + * `agentIdSilent` and reach the Agent Host agent instead of the default + * terminal participant. `undefined` for a local fallback session, which + * must stay on the legacy extension-host agent. + */ + readonly lockToAgent: ResolvedChatSessionsExtensionPoint | undefined; +} + +/** Resolves the chat model reference used by the terminal chat surface. */ +export interface ITerminalChatSessionResolver { + readonly _serviceBrand: undefined; + resolve(token: CancellationToken, shellType: string | undefined, os: OperatingSystem): Promise; +} + +/** Builds the Agent Host metadata for a terminal chat session. */ +export function getTerminalChatSessionMeta(shellType: string | undefined, os: OperatingSystem): Record { + return withChatSurfaceMeta(undefined, { + surface: 'terminal', + shellType, + osName: getOperatingSystemName(os), + })!; +} + +function getOperatingSystemName(os: OperatingSystem): string { + switch (os) { + case OperatingSystem.Windows: + return 'Windows'; + case OperatingSystem.Macintosh: + return 'macOS'; + case OperatingSystem.Linux: + return 'Linux'; + } +} + +/** Applies terminal-specific Agent Host and local-session fallback policy. */ +export class TerminalChatSessionResolver implements ITerminalChatSessionResolver { + declare readonly _serviceBrand: undefined; + + constructor( + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, + @IChatService private readonly _chatService: IChatService, + @IConfigurationService private readonly _configurationService: IConfigurationService, + ) { } + + async resolve(token: CancellationToken, shellType: string | undefined, os: OperatingSystem): Promise { + if (token.isCancellationRequested) { + return undefined; + } + + const meta = getTerminalChatSessionMeta(shellType, os); + let modelRef: IChatModelReference | undefined; + const agentHostEnabled = this._configurationService.getValue(ChatConfiguration.TerminalAgentHostEnabled) === true; + const contribution = agentHostEnabled ? this._chatSessionsService.getChatSessionContribution(SessionType.AgentHostCopilot) : undefined; + if (contribution?.locations?.includes(ChatAgentLocation.Terminal)) { + try { + const item = await this._chatSessionsService.createNewChatSessionItem(SessionType.AgentHostCopilot, { + prompt: '', + isEphemeral: true, + _meta: meta, + }, token); + modelRef = item && await this._chatService.acquireOrLoadSession(item.resource, ChatAgentLocation.Terminal, token, 'TerminalChatSessionResolver#resolve'); + } catch (error) { + if (isCancellationError(error) || token.isCancellationRequested) { + throw error; + } + onUnexpectedError(error); + } + } + + if (token.isCancellationRequested) { + modelRef?.dispose(); + return undefined; + } + + if (modelRef) { + return { modelRef, lockToAgent: contribution }; + } + + modelRef = this._chatService.startNewLocalSession(ChatAgentLocation.Terminal); + if (token.isCancellationRequested) { + modelRef.dispose(); + return undefined; + } + return { modelRef, lockToAgent: undefined }; + } +} diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts index d14a3dd89879aa..7d59ed19721679 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -7,12 +7,14 @@ import type { Terminal as RawXtermTerminal } from '@xterm/xterm'; import { Dimension, getActiveWindow, IFocusTracker, trackFocus } from '../../../../../base/browser/dom.js'; import { CancelablePromise, createCancelablePromise, DeferredPromise } from '../../../../../base/common/async.js'; import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { isCancellationError, onUnexpectedError } from '../../../../../base/common/errors.js'; import { Emitter, Event } from '../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { autorun, observableValue, type IObservable } from '../../../../../base/common/observable.js'; import { MicrotaskDelay } from '../../../../../base/common/symbols.js'; import { localize } from '../../../../../nls.js'; import { MenuId } from '../../../../../platform/actions/common/actions.js'; +import { OS } from '../../../../../base/common/platform.js'; import { IContextKey, IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; @@ -22,6 +24,7 @@ import { IChatModel, IChatResponseModel, isCellTextEditOperationArray } from '.. import { ChatMode } from '../../../chat/common/chatModes.js'; import { IChatModelReference, IChatProgress, IChatService } from '../../../chat/common/chatService/chatService.js'; import { ChatAgentLocation } from '../../../chat/common/constants.js'; +import { IChatSessionsService } from '../../../chat/common/chatSessionsService.js'; import { IInlineChatWidgetConstructionOptions, InlineChatWidget } from '../../../inlineChat/browser/inlineChatWidget.js'; import { MENU_INLINE_CHAT_WIDGET_SECONDARY } from '../../../inlineChat/common/inlineChat.js'; import { ITerminalInstance, type IXtermTerminal } from '../../../terminal/browser/terminal.js'; @@ -41,6 +44,7 @@ import { IMarkdownRendererService } from '../../../../../platform/markdown/brows import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetLocationOptions } from '../../../chat/browser/widget/chatWidget.js'; import { Selection } from '../../../../../editor/common/core/selection.js'; +import { getTerminalChatSessionMeta, ITerminalChatSessionResolver } from './terminalChatSessionResolver.js'; const enum Constants { HorizontalMargin = 10, @@ -98,6 +102,7 @@ export class TerminalChatWidget extends Disposable { private readonly _sessionDisposables: MutableDisposable = this._register(new MutableDisposable()); private _sessionCtor: CancelablePromise | undefined; + private _agentHostSessionResource: IChatModel['sessionResource'] | undefined; private _currentRequestId: string | undefined; private _activeRequestCts?: CancellationTokenSource; @@ -111,6 +116,8 @@ export class TerminalChatWidget extends Disposable { private readonly _xterm: IXtermTerminal & { raw: RawXtermTerminal }, @IContextKeyService contextKeyService: IContextKeyService, @IChatService private readonly _chatService: IChatService, + @ITerminalChatSessionResolver private readonly _sessionResolver: ITerminalChatSessionResolver, + @IChatSessionsService private readonly _chatSessionsService: IChatSessionsService, @IStorageService private readonly _storageService: IStorageService, @IInstantiationService instantiationService: IInstantiationService, @IChatAgentService private readonly _chatAgentService: IChatAgentService, @@ -167,6 +174,7 @@ export class TerminalChatWidget extends Disposable { Event.fromObservableLight(this._inlineChatWidget.chatWidget.input.selectedLanguageModel), Event.debounce(this._xterm.raw.onCursorMove, () => void 0, MicrotaskDelay), )(() => this._relayout())); + this._register(this._instance.onDidChangeShellType(() => this._refreshAgentHostSessionMetadata())); const observer = new ResizeObserver(() => this._relayout()); observer.observe(this._terminalElement); @@ -247,7 +255,7 @@ export class TerminalChatWidget extends Disposable { } async reveal(): Promise { - await this._createSession(); + this._createSession(); this._doLayout(); this._container.classList.remove('hide'); this._visibleContextKey.set(true); @@ -339,17 +347,71 @@ export class TerminalChatWidget extends Disposable { return this._focusTracker; } - private async _createSession(): Promise { - this._sessionCtor = createCancelablePromise(async token => { - if (!this._model.value) { - const modelRef = this._chatService.startNewLocalSession(ChatAgentLocation.Terminal); - this._model.value = modelRef; - const model = modelRef.object; - this._inlineChatWidget.setChatModel(model); - this._resetPlaceholder(); + private _createSession(): void { + if (this._model.value || this._sessionCtor) { + return; + } + // Intentionally only starts asynchronous creation so `reveal` can display and focus immediately. + const sessionCtor = createCancelablePromise(async token => { + const resolution = await this._sessionResolver.resolve(token, this._instance.shellType ?? this._instance.processName, this._instance.os ?? OS); + if (!resolution || token.isCancellationRequested) { + resolution?.modelRef.dispose(); + return; + } + this._model.value = resolution.modelRef; + const model = resolution.modelRef.object; + this._inlineChatWidget.setChatModel(model); + // A contributed session type is not the default agent for its locations, so the widget + // must be locked for requests to carry `agentIdSilent` and reach the Agent Host agent. + const lockToAgent = resolution.lockToAgent; + if (lockToAgent) { + this._inlineChatWidget.chatWidget.lockToCodingAgent(lockToAgent.name, lockToAgent.displayName, lockToAgent.type, lockToAgent.agentHostProviderId); + this._agentHostSessionResource = model.sessionResource; + this._refreshAgentHostSessionMetadata(); + } else { + this._inlineChatWidget.chatWidget.unlockFromCodingAgent(); + this._agentHostSessionResource = undefined; + } + this._resetPlaceholder(); + }); + this._sessionCtor = sessionCtor; + this._sessionDisposables.value = toDisposable(() => sessionCtor.cancel()); + void sessionCtor.catch(error => { + if (this._sessionCtor === sessionCtor) { + this._sessionCtor = undefined; + } + if (!isCancellationError(error)) { + onUnexpectedError(error); } }); - this._sessionDisposables.value = toDisposable(() => this._sessionCtor?.cancel()); + } + + private _refreshAgentHostSessionMetadata(): void { + if (this._agentHostSessionResource) { + this._chatSessionsService.updateChatSessionMetadata( + this._agentHostSessionResource, + getTerminalChatSessionMeta(this._instance.shellType ?? this._instance.processName, this._instance.os ?? OS), + ); + } + } + + private async _waitForSession(): Promise { + if (this._model.value) { + return true; + } + const sessionCtor = this._sessionCtor; + if (!sessionCtor) { + return false; + } + try { + await sessionCtor; + return !!this._model.value; + } catch (error) { + if (isCancellationError(error)) { + return false; + } + throw error; + } } private _saveInputState() { @@ -362,6 +424,8 @@ export class TerminalChatWidget extends Disposable { clear(): void { this.cancel(); this._model.clear(); + this._agentHostSessionResource = undefined; + this._inlineChatWidget.chatWidget.unlockFromCodingAgent(); this._responseContainsCodeBlockContextKey.reset(); this._requestActiveContextKey.reset(); this.hide(); @@ -372,6 +436,9 @@ export class TerminalChatWidget extends Disposable { if (!this._model.value) { await this.reveal(); } + if (!await this._waitForSession()) { + return undefined; + } this._messages.fire(Message.AcceptInput); const lastInput = this._inlineChatWidget.value; if (!lastInput) { diff --git a/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatSessionResolver.test.ts b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatSessionResolver.test.ts new file mode 100644 index 00000000000000..ebaf33985f4da3 --- /dev/null +++ b/src/vs/workbench/contrib/terminalContrib/chat/test/browser/terminalChatSessionResolver.test.ts @@ -0,0 +1,275 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { DeferredPromise } from '../../../../../../base/common/async.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { CancellationError, errorHandler, isCancellationError, setUnexpectedErrorHandler } from '../../../../../../base/common/errors.js'; +import { DisposableStore } from '../../../../../../base/common/lifecycle.js'; +import { OperatingSystem } from '../../../../../../base/common/platform.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { IChatModelReference, IChatService } from '../../../../chat/common/chatService/chatService.js'; +import { ChatAgentLocation, ChatConfiguration } from '../../../../chat/common/constants.js'; +import { IChatNewSessionRequest, IChatSessionItem, IChatSessionsService, ResolvedChatSessionsExtensionPoint, SessionType } from '../../../../chat/common/chatSessionsService.js'; +import { getTerminalChatSessionMeta, TerminalChatSessionResolver } from '../../browser/terminalChatSessionResolver.js'; + +const terminalContribution: ResolvedChatSessionsExtensionPoint = { + type: SessionType.AgentHostCopilot, + name: 'Agent Host Copilot', + displayName: 'Agent Host Copilot', + description: 'Test contribution', + icon: undefined, + locations: [ChatAgentLocation.Terminal], +}; + +const agentHostItem: IChatSessionItem = { + resource: URI.from({ scheme: SessionType.AgentHostCopilot, path: '/terminal-session' }), + label: 'Terminal session', + timing: { created: 0, lastRequestStarted: 0, lastRequestEnded: 0 }, +}; + +const terminalShellType = 'pwsh'; +const terminalOperatingSystem = OperatingSystem.Windows; + +class TestModelReference extends mock() { + disposeCalls = 0; + + override dispose(): void { + this.disposeCalls++; + } +} + +class TestChatSessionsService extends mock() { + contribution: ResolvedChatSessionsExtensionPoint | undefined = terminalContribution; + item: IChatSessionItem | undefined = agentHostItem; + error: Error | undefined; + contributionLookups = 0; + creationCalls = 0; + request: IChatNewSessionRequest | undefined; + + override getChatSessionContribution(): ResolvedChatSessionsExtensionPoint | undefined { + this.contributionLookups++; + return this.contribution; + } + + override async createNewChatSessionItem(_chatSessionType: string, request: IChatNewSessionRequest): Promise { + this.creationCalls++; + this.request = request; + if (this.error) { + throw this.error; + } + return this.item; + } +} + +class TestChatService extends mock() { + agentHostReference: IChatModelReference | undefined; + agentHostResult: Promise | undefined; + localReference = new TestModelReference(); + localSessionStarts = 0; + agentHostAcquisitions = 0; + readonly acquisitionStarted = new DeferredPromise(); + + override async acquireOrLoadSession(): Promise { + this.agentHostAcquisitions++; + this.acquisitionStarted.complete(); + return this.agentHostResult ?? this.agentHostReference; + } + + override startNewLocalSession(): IChatModelReference { + this.localSessionStarts++; + return this.localReference; + } +} + +suite('TerminalChatSessionResolver', () => { + const store = new DisposableStore(); + let instantiationService: TestInstantiationService; + let chatSessionsService: TestChatSessionsService; + let chatService: TestChatService; + let configurationService: TestConfigurationService; + let resolver: TerminalChatSessionResolver; + + setup(() => { + instantiationService = store.add(new TestInstantiationService()); + chatSessionsService = new TestChatSessionsService(); + chatService = new TestChatService(); + configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(ChatConfiguration.TerminalAgentHostEnabled, true); + instantiationService.stub(IChatSessionsService, chatSessionsService); + instantiationService.stub(IChatService, chatService); + instantiationService.stub(IConfigurationService, configurationService); + resolver = instantiationService.createInstance(TerminalChatSessionResolver); + }); + + teardown(() => { + store.clear(); + }); + + ensureNoDisposablesAreLeakedInTestSuite(); + + test('uses the Agent Host session when it supports the terminal location', async () => { + const agentHostReference = new TestModelReference(); + chatService.agentHostReference = agentHostReference; + const meta = getTerminalChatSessionMeta(terminalShellType, terminalOperatingSystem); + + const result = await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + + assert.deepStrictEqual({ + usesAgentHostReference: result?.modelRef === agentHostReference, + lockToAgent: result?.lockToAgent?.type, + request: chatSessionsService.request, + creationCalls: chatSessionsService.creationCalls, + agentHostAcquisitions: chatService.agentHostAcquisitions, + localSessionStarts: chatService.localSessionStarts, + }, { + usesAgentHostReference: true, + lockToAgent: SessionType.AgentHostCopilot, + request: { prompt: '', isEphemeral: true, _meta: meta }, + creationCalls: 1, + agentHostAcquisitions: 1, + localSessionStarts: 0, + }); + }); + + test('produces terminal surface metadata when the shell is not yet known', () => { + assert.deepStrictEqual(getTerminalChatSessionMeta(undefined, OperatingSystem.Linux), { + 'vscode.chat.surface': { surface: 'terminal', osName: 'Linux' }, + }); + }); + + test('uses the local session without attempting Agent Host when disabled', async () => { + configurationService.setUserConfiguration(ChatConfiguration.TerminalAgentHostEnabled, false); + + const result = await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent?.type, + contributionLookups: chatSessionsService.contributionLookups, + creationCalls: chatSessionsService.creationCalls, + agentHostAcquisitions: chatService.agentHostAcquisitions, + localSessionStarts: chatService.localSessionStarts, + }, { + usesLocalReference: true, + lockToAgent: undefined, + contributionLookups: 0, + creationCalls: 0, + agentHostAcquisitions: 0, + localSessionStarts: 1, + }); + }); + + test('falls back to a local session when Agent Host returns undefined', async () => { + chatService.agentHostReference = undefined; + + const result = await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent?.type, + creationCalls: chatSessionsService.creationCalls, + agentHostAcquisitions: chatService.agentHostAcquisitions, + localSessionStarts: chatService.localSessionStarts, + }, { + usesLocalReference: true, + lockToAgent: undefined, + creationCalls: 1, + agentHostAcquisitions: 1, + localSessionStarts: 1, + }); + }); + + test('falls back to a local session and reports Agent Host failures', async () => { + const originalErrorHandler = errorHandler.getUnexpectedErrorHandler(); + const reportedErrors: string[] = []; + chatSessionsService.error = new Error('Agent Host unavailable'); + setUnexpectedErrorHandler(error => reportedErrors.push(error instanceof Error ? error.message : String(error))); + try { + const result = await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + + assert.deepStrictEqual({ + usesLocalReference: result?.modelRef === chatService.localReference, + lockToAgent: result?.lockToAgent?.type, + creationCalls: chatSessionsService.creationCalls, + localSessionStarts: chatService.localSessionStarts, + reportedErrors, + }, { + usesLocalReference: true, + lockToAgent: undefined, + creationCalls: 1, + localSessionStarts: 1, + reportedErrors: ['Agent Host unavailable'], + }); + } finally { + setUnexpectedErrorHandler(originalErrorHandler); + } + }); + + test('propagates Agent Host cancellation without creating a local session', async () => { + chatSessionsService.error = new CancellationError(); + let cancellationPropagated = false; + try { + await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + } catch (error) { + cancellationPropagated = isCancellationError(error); + } + + assert.deepStrictEqual({ + cancellationPropagated, + creationCalls: chatSessionsService.creationCalls, + localSessionStarts: chatService.localSessionStarts, + }, { + cancellationPropagated: true, + creationCalls: 1, + localSessionStarts: 0, + }); + }); + + test('disposes an Agent Host session acquired after cancellation', async () => { + const agentHostReference = new TestModelReference(); + const pendingAcquisition = new DeferredPromise(); + const cancellationSource = store.add(new CancellationTokenSource()); + chatService.agentHostResult = pendingAcquisition.p; + + const resolving = resolver.resolve(cancellationSource.token, terminalShellType, terminalOperatingSystem); + await chatService.acquisitionStarted.p; + cancellationSource.cancel(); + pendingAcquisition.complete(agentHostReference); + const result = await resolving; + + assert.deepStrictEqual({ + result, + localSessionStarts: chatService.localSessionStarts, + disposeCalls: agentHostReference.disposeCalls, + }, { + result: undefined, + localSessionStarts: 0, + disposeCalls: 1, + }); + }); + + test('reports the contribution to lock to, and none for local fallbacks', async () => { + chatService.agentHostReference = new TestModelReference(); + const agentHostResolution = await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + chatService.agentHostReference = undefined; + const localResolution = await resolver.resolve(CancellationToken.None, terminalShellType, terminalOperatingSystem); + + assert.deepStrictEqual({ + agentHostLocksToContribution: agentHostResolution?.lockToAgent === terminalContribution, + agentHostLockAgentId: agentHostResolution?.lockToAgent?.type, + localLockToAgent: localResolution?.lockToAgent, + }, { + agentHostLocksToContribution: true, + agentHostLockAgentId: SessionType.AgentHostCopilot, + localLockToAgent: undefined, + }); + }); +}); From cb0b17e13c90be2c7b53f6872aa96e4c05df2c8b Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 19 Aug 2026 10:05:09 -0700 Subject: [PATCH 05/28] agentHost: enable built-in GitHub MCP servers (#331561) * agentHost: enable built-in GitHub MCP servers Enable the official GitHub MCP server in each built-in Agent Host provider. - Enable the Copilot runtime server for sessions that use a session token. - Add a shared Agent Host setting that defaults the GitHub MCP server to enabled. - Configure Claude and Codex with the authenticated endpoint and selected tool set. - Keep web search available and omit tools that the installed gh CLI can replace. - Apply the existing scoped customization enablement decisions before provider launch. - Hide the duplicate Copilot Chat GitHub collection from Agent Host customization views. - Add focused provider, configuration, synchronization, and UI tests. Fixes https://github.com/microsoft/vscode/issues/331392 (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: update GitHub MCP test narrowing Use the MCP transport discriminator after merging the latest SDK typings. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: harden GitHub MCP endpoint handling Address review feedback for GitHub MCP authentication and remote hosts. - Omit servers when account endpoint discovery does not return a trusted host. - Let provider MCP authentication flows supply credentials after a challenge. - Preserve the client GitHub server for remote hosts without a negotiated capability. - Clear Codex GitHub MCP credentials when the configured endpoint changes. - Add regression coverage for endpoint discovery, remote compatibility, and cache invalidation. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: defer GitHub MCP authentication Use each provider's normal MCP authentication flow for GitHub servers. - Use the Copilot token only to discover the account-specific MCP endpoint. - Do not include an authorization header in the initial MCP configuration. - Fail closed when endpoint discovery cannot resolve a trusted host. - Clear Codex endpoint state when GitHub host configuration changes. - Preserve client GitHub MCP synchronization for older remote hosts. - Complete Codex test fixtures and cover the endpoint and authentication behavior. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * tests * agentHost: stub GitHub MCP in E2E replay Keep GitHub MCP bootstrap and OAuth discovery out of model replay fixtures. - Return unavailable responses for GitHub MCP bootstrap endpoints. - Stub the follow-up OAuth metadata probes used by Codex. - Document the ancillary endpoints in the E2E replay architecture. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: scope Copilot customization assertions Exclude the runtime-owned GitHub MCP entry from integration assertions that cover workspace, user, and plugin customizations. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: close GitHub MCP launch gaps Apply GitHub MCP enablement and authentication state before provider launch. - Disable the Copilot built-in before startup when scoped enablement says it is off. - Guard Codex authentication and endpoint resolution with a generation counter. - Suppress Codex built-in injection when an enabled alias uses the same endpoint. - Add focused launch, race, and alias regression tests. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: initialize MCP enablement before launch Resolve the built-in GitHub MCP decision only after session enablement initialization. - Await launch and refresh decisions at every Copilot session creation path. - Keep general Copilot tests on the permissive enablement fixture. - Preserve the pending fixture only for the host-snapshot enablement test. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: align provider authentication fixtures Align launch and integration fixtures with provider authentication contracts. - Initialize Copilot customization enablement before launch and refresh decisions. - Use permissive enablement in general Copilot tests and pending state only where tested. - Authenticate restarted and runtime-enabled Codex providers with the Copilot resource. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: gate Codex restart replay on Windows Track the Windows-only Codex provider-context restart timeout as a known E2E issue. - Skip only the Codex Windows persistence variant by default. - Preserve macOS and Linux coverage. - Allow focused reproduction through AGENT_HOST_RUN_KNOWN_ISSUES. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: gate Codex restart replay Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: await Codex before integration auth Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/agentHostSchema.ts | 10 +- .../agentHostStarter.config.contribution.ts | 10 ++ .../platform/agentHost/common/agentService.ts | 3 + .../node/agentHostAuthenticationService.ts | 31 +++- .../platform/agentHost/node/agentHostMain.ts | 2 + .../agentHost/node/agentHostServerMain.ts | 2 + .../platform/agentHost/node/agentService.ts | 5 +- .../node/claude/claudeAgentSession.ts | 69 +++++++- .../agentHost/node/codex/codexAgent.ts | 98 +++++++++++- .../agentHost/node/copilot/copilotAgent.ts | 116 ++++++++------ .../node/copilot/copilotAgentStartupConfig.ts | 44 ++++++ .../agentHost/node/shared/githubMcpServer.ts | 98 ++++++++++++ .../test/common/agentHostSchema.test.ts | 8 +- .../agentHost/test/node/agentService.test.ts | 5 +- .../test/node/claudeAgent.integrationTest.ts | 13 ++ .../agentHost/test/node/claudeAgent.test.ts | 149 +++++++++++++++++- .../test/node/codex/codexAgent.test.ts | 135 +++++++++++++++- .../node/codex/codexSessionConfigKeys.test.ts | 3 + .../node/codex/codexSessionTitleSpans.test.ts | 3 + .../agentHost/test/node/copilotAgent.test.ts | 117 ++++++++++++-- .../node/copilotAgentStartupConfig.test.ts | 30 ++++ .../agentHost/test/node/e2e/KNOWN_ISSUES.md | 18 +++ .../agentHost/test/node/e2e/README.md | 1 + .../test/node/e2e/harness/capiStubs.ts | 13 ++ .../e2e/suites/sessionPersistenceSuite.ts | 7 +- .../codexCustomizations.integrationTest.ts | 10 +- .../copilotCustomizations.integrationTest.ts | 8 +- .../test/node/shared/githubMcpServer.test.ts | 44 ++++++ src/vs/sessions/AI_CUSTOMIZATIONS.md | 3 + .../agentHost/agentHostChatContribution.ts | 2 + .../agentHost/agentHostLocalCustomizations.ts | 20 ++- .../browser/aiCustomization/mcpListWidget.ts | 6 + .../common/customizationHarnessService.ts | 6 + .../resolveCustomizationRefs.test.ts | 45 +++--- .../aiCustomization/mcpListWidget.test.ts | 13 ++ 35 files changed, 1025 insertions(+), 122 deletions(-) create mode 100644 src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts create mode 100644 src/vs/platform/agentHost/node/shared/githubMcpServer.ts create mode 100644 src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts create mode 100644 src/vs/platform/agentHost/test/node/shared/githubMcpServer.test.ts diff --git a/src/vs/platform/agentHost/common/agentHostSchema.ts b/src/vs/platform/agentHost/common/agentHostSchema.ts index 0d69985c8e5e7e..c15e289671f423 100644 --- a/src/vs/platform/agentHost/common/agentHostSchema.ts +++ b/src/vs/platform/agentHost/common/agentHostSchema.ts @@ -460,6 +460,9 @@ export const AgentHostAutoReplyAnswer = 'The user is not available to answer you /** Root config key forwarded from the renderer for automatic OS system proxy discovery. */ export const AgentHostSystemProxyEnabledConfigKey = 'systemProxyEnabled'; +/** Root config key forwarded from the renderer for the GitHub MCP server. */ +export const AgentHostGitHubMcpServerEnabledConfigKey = 'githubMcpServerEnabled'; + /** * Independently synchronized proxy settings retain their VS Code `http.*` * names, matching other flat namespaced root keys such as `agentMerge.*`. @@ -489,7 +492,6 @@ const agentHostProxyConfigDefinition = { default: [], }), }; - export const agentHostProxyConfigSchema = createSchema(agentHostProxyConfigDefinition); /** Root config key forwarded from the renderer for active-agent title generation. */ @@ -779,6 +781,12 @@ export const platformRootSchema = createSchema({ description: localize('agentHost.config.systemProxyEnabled.description', "Whether Copilot sessions automatically discover and use the operating system's proxy configuration."), default: true, }), + [AgentHostGitHubMcpServerEnabledConfigKey]: schemaProperty({ + type: 'boolean', + title: localize('agentHost.config.githubMcpServerEnabled.title', "GitHub MCP Server"), + description: localize('agentHost.config.githubMcpServerEnabled.description', "Whether agent sessions include a GitHub MCP server by default."), + default: true, + }), [AgentHostActiveAgentTitleGenerationConfigKey]: schemaProperty({ type: 'boolean', title: localize('agentHost.config.activeAgentTitleGeneration.title', "Active Agent Title Generation"), diff --git a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts index d97f41265134f2..e70af159e3627c 100644 --- a/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts +++ b/src/vs/platform/agentHost/common/agentHostStarter.config.contribution.ts @@ -12,6 +12,7 @@ import product from '../../product/common/product.js'; import { Registry } from '../../registry/common/platform.js'; import { AgentHostByokModelsEnabledSettingId, + AgentHostGitHubMcpServerEnabledSettingId, AgentHostActiveAgentTitleGenerationSettingId, AgentHostClaudeAgentEnabledSettingId, AgentHostClaudeMultiRootEnabledSettingId, @@ -37,6 +38,7 @@ import { AgentHostClaudeMultiRootEnabledConfigKey, AgentHostActiveAgentTitleGenerationConfigKey, AgentHostByokModelsEnabledConfigKey, + AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCodexEnabledConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, @@ -196,6 +198,14 @@ configurationRegistry.registerConfiguration({ experiment: { mode: 'startup' }, agentHost: { key: AgentHostSystemProxyEnabledConfigKey }, }, + [AgentHostGitHubMcpServerEnabledSettingId]: { + type: 'boolean', + description: nls.localize('chat.agentHost.githubMcpServer.enabled', "When enabled, agent-host sessions include the GitHub MCP server."), + default: true, + tags: ['experimental', 'advanced'], + experiment: { mode: 'startup' }, + agentHost: { key: AgentHostGitHubMcpServerEnabledConfigKey }, + }, [AgentHostCopilotMultiRootEnabledSettingId]: { type: 'boolean', description: nls.localize('chat.agentHost.copilotAgent.multiRootEnabled', "When enabled, Copilot agent-host sessions advertise support for multiple working directories, so a session created in a multi-root workspace can span every workspace folder. Experimental; newly created sessions pick up a change without restarting the agent host."), diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index 35b5dc7c289783..b0b41dc90a0659 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -120,6 +120,9 @@ export interface IAgentHostDebugLogsChunk { /** Configuration key controlling automatic OS system proxy discovery for agent-host Copilot sessions. */ export const AgentHostSystemProxyEnabledSettingId = 'chat.agentHost.systemProxy.enabled'; +/** Configuration key controlling the GitHub MCP server in agent-host sessions. */ +export const AgentHostGitHubMcpServerEnabledSettingId = 'chat.agentHost.githubMcpServer.enabled'; + /** Configuration key gating active-agent session and chat title generation. */ export const AgentHostActiveAgentTitleGenerationSettingId = 'chat.agentHost.experimental.activeAgentTitleGeneration'; diff --git a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts index 69b55177645ecf..2b1dbef11d6ae0 100644 --- a/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts +++ b/src/vs/platform/agentHost/node/agentHostAuthenticationService.ts @@ -3,22 +3,44 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { Emitter, Event } from '../../../base/common/event.js'; +import { Disposable } from '../../../base/common/lifecycle.js'; +import { createDecorator } from '../../instantiation/common/instantiation.js'; import type { ILogService } from '../../log/common/log.js'; import type { AuthenticateParams, AuthenticateResult, IAgent, IAgentHostAuthTokenRequest } from '../common/agent.js'; +export interface IAgentHostAuthTokenChangeEvent { + readonly resource: string; + readonly scopes: readonly string[]; + readonly token: string | undefined; +} + +export const IAgentHostAuthenticationService = createDecorator('agentHostAuthenticationService'); + +export interface IAgentHostAuthenticationService { + readonly _serviceBrand: undefined; + readonly onDidChangeAuthToken: Event; + getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined; +} + interface IStoredAuthToken { readonly resource: string; readonly scopes: readonly string[]; readonly token: string; } -export class AgentHostAuthenticationService { +export class AgentHostAuthenticationService extends Disposable implements IAgentHostAuthenticationService { + declare readonly _serviceBrand: undefined; private readonly _tokens = new Map(); + private readonly _onDidChangeAuthToken = this._register(new Emitter()); + readonly onDidChangeAuthToken = this._onDidChangeAuthToken.event; constructor( private readonly _logService: ILogService, - ) { } + ) { + super(); + } async authenticate(params: AuthenticateParams, providers: Iterable): Promise { this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`); @@ -68,6 +90,7 @@ export class AgentHostAuthenticationService { } const scopes = this._normalizeScopes(params.scopes); const key = this._key(params.resource, scopes); + const previousToken = this._tokens.get(key)?.token; if (!authenticated && !rejected) { authenticated = this._tokens.get(key)?.token === params.token; } @@ -78,6 +101,10 @@ export class AgentHostAuthenticationService { } else if (authenticated) { this._tokens.set(key, { resource: params.resource, scopes, token: params.token }); } + const token = this._tokens.get(key)?.token; + if (previousToken !== token) { + this._onDidChangeAuthToken.fire({ resource: params.resource, scopes, token }); + } return { authenticated }; } diff --git a/src/vs/platform/agentHost/node/agentHostMain.ts b/src/vs/platform/agentHost/node/agentHostMain.ts index 48672a1123dcb7..ee8737a8590406 100644 --- a/src/vs/platform/agentHost/node/agentHostMain.ts +++ b/src/vs/platform/agentHost/node/agentHostMain.ts @@ -20,6 +20,7 @@ import { AgentHostClaudeAgentEnabledEnvVar, AgentHostCodexAgentEnabledEnvVar, Ag import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentService } from './agentService.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostPromptCache } from './agentHostPromptCache.js'; import { IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; @@ -210,6 +211,7 @@ async function startAgentHost(): Promise { tmpDir: environmentService.tmpDir, }); diServices.set(IAgentService, agentService); + diServices.set(IAgentHostAuthenticationService, agentService.authenticationService); diServices.set(IAgentHostStateManager, agentService.stateManager); // Narrow host seams providers consume instead of the whole state manager. diServices.set(IAgentHostPromptCache, agentService.promptCache); diff --git a/src/vs/platform/agentHost/node/agentHostServerMain.ts b/src/vs/platform/agentHost/node/agentHostServerMain.ts index 72572d78b59a4a..9d360559a41cc4 100644 --- a/src/vs/platform/agentHost/node/agentHostServerMain.ts +++ b/src/vs/platform/agentHost/node/agentHostServerMain.ts @@ -53,6 +53,7 @@ import { AgentHostOTelService } from './otel/agentHostOTelService.js'; import { AgentHostCodexEnabledConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentModelRefreshScheduler, MODEL_REFRESH_INTERVAL_MS } from './agentModelRefreshScheduler.js'; import { AgentService } from './agentService.js'; +import { IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { IAgentHostStateManager } from './agentHostStateManager.js'; import { IAgentHostPromptCache } from './agentHostPromptCache.js'; import { IAgentHostSessionTitleSignal } from './agentHostSessionTitleSignal.js'; @@ -270,6 +271,7 @@ async function main(): Promise { }); disposables.add(agentService); diServices.set(IAgentService, agentService); + diServices.set(IAgentHostAuthenticationService, agentService.authenticationService); diServices.set(IAgentHostStateManager, agentService.stateManager); // Narrow host seams providers consume instead of the whole state manager. diServices.set(IAgentHostPromptCache, agentService.promptCache); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 6cddb0e4a0c6d7..f613b5b7c44c58 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -93,7 +93,7 @@ import { AgentMergeConfigKey, agentMergeRootConfigSchema } from '../common/agent import { AgentMergeTools } from './agentMergeTools.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; -import { AgentHostAuthenticationService } from './agentHostAuthenticationService.js'; +import { AgentHostAuthenticationService, type IAgentHostAuthenticationService } from './agentHostAuthenticationService.js'; import { updateAgentHostTelemetryLevelFromConfig } from './agentHostTelemetryService.js'; import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostEditTelemetryEnabledConfigKey, AgentHostExternalSessionsMode, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostShowExternalSessionsConfigKey, platformRootSchema } from '../common/agentHostSchema.js'; import { AgentHostCustomizationEnablementService, IAgentHostCustomizationEnablementService } from './agentHostCustomizationEnablementService.js'; @@ -418,6 +418,7 @@ export class AgentService extends Disposable implements IAgentService { private readonly _disposingPeerChats = new Set(); private readonly _defaultChatBackingWrites = new Map>(); private readonly _authService: AgentHostAuthenticationService; + get authenticationService(): IAgentHostAuthenticationService { return this._authService; } /** Default provider used when no explicit provider is specified. */ private _defaultProvider: AgentProvider | undefined; /** Observable registered agents, drives `root/agentsChanged` via {@link AgentSideEffects}. */ @@ -568,7 +569,7 @@ export class AgentService extends Disposable implements IAgentService { ) { super(); this._logService.info('AgentService initialized'); - this._authService = new AgentHostAuthenticationService(_logService); + this._authService = this._register(new AgentHostAuthenticationService(_logService)); const databasePath = this._rootConfigResource ? joinPath(resourcesDirname(this._rootConfigResource), 'agent-host.db').fsPath : ':memory:'; diff --git a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts index a939455d5f1bcb..b9eaa1046568d9 100644 --- a/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts +++ b/src/vs/platform/agentHost/node/claude/claudeAgentSession.ts @@ -55,6 +55,12 @@ import { ClaudeSdkPipeline, IRematerializer, type ISdkResolvedCustomizations } f import { SubagentRegistry } from './claudeSubagentRegistry.js'; import { ClaudePermissionKind } from './claudeToolDisplay.js'; import { getSdkMcpServerEnablement, isCustomizationSdkEligible, resolveCustomizationEnablement } from '../shared/customizationEnablementGate.js'; +import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js'; +import { AgentHostGitHubMcpServerEnabledConfigKey, platformRootSchema } from '../../common/agentHostSchema.js'; +import { GITHUB_MCP_SERVER_NAME, resolveGitHubMcpServerConfiguration } from '../shared/githubMcpServer.js'; +import { ICopilotApiService } from '../shared/copilotApiService.js'; +import { IAgentHostAuthenticationService } from '../agentHostAuthenticationService.js'; +import { IAgentHostGitHubEndpointService } from '../agentHostGitHubEndpointService.js'; // Re-export for callers that import IRematerializer from the session. export type { IRematerializer } from './claudeSdkPipeline.js'; @@ -121,6 +127,12 @@ function resolveCurrentPermissionMode( return readClaudePermissionMode(configurationService, resource) ?? inheritedPermissionMode ?? permissionModeFallback; } +function isGitHubMcpServerDefinition(definition: IMcpServerDefinition, gitHubMcpServerConfiguration: IMcpServerConfiguration): boolean { + return definition.configuration.type === McpServerType.REMOTE + && gitHubMcpServerConfiguration.type === McpServerType.REMOTE + && isEqual(URI.parse(definition.configuration.url), URI.parse(gitHubMcpServerConfiguration.url)); +} + function toClaudeDeniedMcpServer(definition: IMcpServerDefinition): ClaudeDeniedMcpServerSpec { return { serverName: definition.name }; } @@ -440,10 +452,19 @@ export class ClaudeAgentSession extends Disposable { @IFileService private readonly _fileService: IFileService, @INativeEnvironmentService private readonly _environmentService: INativeEnvironmentService, @IAgentHostCustomizationEnablementService private readonly _customizationEnablementService: IAgentHostCustomizationEnablementService, + @ICopilotApiService private readonly _copilotApiService: ICopilotApiService, + @IAgentHostAuthenticationService private readonly _authenticationService: IAgentHostAuthenticationService, + @IAgentHostGitHubEndpointService private readonly _gitHubEndpointService: IAgentHostGitHubEndpointService, ) { super(); this._chatChannelUri = chatChannelUri; this.project = project; + this._register(this._configurationService.onDidRootConfigChange(() => this.markMcpConfigurationDirty())); + this._register(this._authenticationService.onDidChangeAuthToken(event => { + if (event.resource === this._gitHubEndpointService.getCopilotResource().resource) { + this.markMcpConfigurationDirty(); + } + })); this._provisionalModel = model; this._provisionalAgent = agent; this.provisionalConfig = config; @@ -471,6 +492,12 @@ export class ClaudeAgentSession extends Disposable { this._hostCustomizations = customizations; } + markMcpConfigurationDirty(): void { + if (this._pipeline) { + this.clientCustomizationsDiff.markDirty(); + } + } + private _watchCustomizations(directories: readonly URI[] | undefined): void { const store = new DisposableStore(); const watcher = store.add(new ClaudeCustomizationWatcher( @@ -803,7 +830,7 @@ export class ClaudeAgentSession extends Disposable { resource: URI, serverToolHost: IAgentServerToolHost | undefined, ): Promise<{ mcpServers: Record | undefined; deniedMcpServers: readonly ClaudeDeniedMcpServerSpec[]; allowedTools: readonly string[] | undefined }> { - const externalServers = await this._buildExternalMcpServers(); + const externalServers = await this._buildExternalMcpServers(await this._getGitHubMcpServerConfiguration()); const clientServers = await buildClientMcpServers(this.toolDiff, this._pendingClientToolCalls, this._sdkService); const serverToolServer = serverToolHost ? await buildServerToolMcpServer(serverToolHost, this._chatChannelUri.toString(), this._sdkService) @@ -832,13 +859,33 @@ export class ClaudeAgentSession extends Disposable { }; } - private async _buildExternalMcpServers(): Promise<{ readonly servers: Record; readonly deniedServers: readonly ClaudeDeniedMcpServerSpec[] }> { + private async _getGitHubMcpServerConfiguration(): Promise { + const resource = this._gitHubEndpointService.getCopilotResource(); + const token = this._authenticationService.getAuthToken({ + resource: resource.resource, + scopes: resource.scopes_supported, + }); + if (!token || this._configurationService.getRootValue(platformRootSchema, AgentHostGitHubMcpServerEnabledConfigKey) === false) { + return undefined; + } + try { + return await resolveGitHubMcpServerConfiguration(this._copilotApiService, token); + } catch (error) { + this._logService.warn(`[Claude:${this.sessionId}] Failed to resolve the GitHub MCP server endpoint: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + private async _buildExternalMcpServers(gitHubMcpServerConfiguration: IMcpServerConfiguration | undefined): Promise<{ readonly servers: Record; readonly deniedServers: readonly ClaudeDeniedMcpServerSpec[] }> { const primaryCwd = this.workingDirectory; if (!primaryCwd) { return { servers: {}, deniedServers: [] }; } const definitions = new Map(); const discoveredDefinitions = await this._mcpDiscovery?.refresh() ?? []; + let hasGitHubMcpServer = gitHubMcpServerConfiguration + ? discoveredDefinitions.some(definition => isGitHubMcpServerDefinition(definition, gitHubMcpServerConfiguration)) + : false; const discoveredCandidates = discoveredDefinitions.map(definition => definition.customization); const discoveredResolution = resolveCustomizationEnablement(this._customizationEnablementService, this._configurationResource, discoveredCandidates); const discoveredEnablement = getSdkMcpServerEnablement(discoveredResolution); @@ -861,6 +908,9 @@ export class ClaudeAgentSession extends Disposable { } try { const parsed = await parsePlugin(synced.pluginDir, this._fileService, primaryCwd, this._environmentService.userHome, synced.pluginDir); + if (gitHubMcpServerConfiguration && parsed.mcpServers.some(definition => isGitHubMcpServerDefinition(definition, gitHubMcpServerConfiguration))) { + hasGitHubMcpServer = true; + } const candidate = { ...synced.customization, children: parsed.mcpServers.map(definition => definition.customization) }; const resolved = resolveCustomizationEnablement(this._customizationEnablementService, this._configurationResource, [candidate], this._clientChildEnablement, this._clientPluginEnablement); if (!isCustomizationSdkEligible(resolved, candidate)) { @@ -885,6 +935,21 @@ export class ClaudeAgentSession extends Disposable { this._logService.warn(`[Claude:${this.sessionId}] Failed to parse MCP servers from '${synced.customization.uri}': ${error instanceof Error ? error.message : String(error)}`); } } + if (gitHubMcpServerConfiguration && !hasGitHubMcpServer) { + const customization = createClaudeInternalMcpServerCustomization(GITHUB_MCP_SERVER_NAME); + const definition: IMcpServerDefinition = { + name: GITHUB_MCP_SERVER_NAME, + configuration: gitHubMcpServerConfiguration, + uri: URI.parse(customization.uri), + customization, + }; + const resolution = resolveCustomizationEnablement(this._customizationEnablementService, this._configurationResource, [customization]); + if (getSdkMcpServerEnablement(resolution).get(customization.id) === true) { + definitions.set(GITHUB_MCP_SERVER_NAME, definition); + } else { + deniedServers.push(toClaudeDeniedMcpServer(definition)); + } + } const converted = toClaudeMcpServers([...definitions.values()], primaryCwd); for (const name of converted.skipped) { this._logService.warn(`[Claude:${this.sessionId}] Skipping MCP server '${name}' because its stdio working directory cannot be represented by the Claude SDK`); diff --git a/src/vs/platform/agentHost/node/codex/codexAgent.ts b/src/vs/platform/agentHost/node/codex/codexAgent.ts index 10993ac0b83bbd..79f419b054ffe4 100644 --- a/src/vs/platform/agentHost/node/codex/codexAgent.ts +++ b/src/vs/platform/agentHost/node/codex/codexAgent.ts @@ -21,7 +21,7 @@ import { IInstantiationService } from '../../../instantiation/common/instantiati import { localize } from '../../../../nls.js'; import { ILogService } from '../../../log/common/log.js'; import { IProductService } from '../../../product/common/productService.js'; -import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; +import { createSchema, platformRootSchema, platformSessionSchema, schemaProperty, AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostCodexMultiRootEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMcpServersConfigKey, type ISchemaProperty, type SessionMode } from '../../common/agentHostSchema.js'; import { createPricingMetaFromBilling, normalizeCAPIBilling } from '../../common/agentModelPricing.js'; import { CHATGPT_SUBSCRIPTION_MODEL_SOURCE_ID, createAgentModelSourceMeta } from '../../common/agentModelSource.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema } from '../../common/agentHostCustomizationConfig.js'; @@ -39,7 +39,7 @@ import { buildDefaultChatUri, isDefaultChatUri, parseRequiredSessionUriFromChatU import type { IAgentServerToolHost } from '../../common/agentServerTools.js'; import { ActiveClientToolSet } from '../activeClientState.js'; import { McpCustomizationController } from '../shared/mcpCustomizationController.js'; -import { buildCodexMcpReadResult, CodexMcpInventory, codexMcpListToInventory, codexMcpServersFromConfig, codexMcpToolsChanged, codexStartupErrorNeedsAuth, injectCodexMcpAuthTokens, inventoryToSdkServers, normalizeCodexMcpResourceUrl, translateCodexMcpStartupState, type ICodexMcpServerConfigJson } from './codexMcpServers.js'; +import { buildCodexMcpReadResult, CodexMcpInventory, codexMcpListToInventory, codexMcpServersFromConfig, codexMcpToolsChanged, codexStartupErrorNeedsAuth, injectCodexMcpAuthTokens, inventoryToSdkServers, normalizeCodexMcpResourceUrl, toCodexMcpServerJson, translateCodexMcpStartupState, type ICodexMcpServerConfigJson } from './codexMcpServers.js'; import { codexHooksToContainers, codexSelectedCapabilityRootCandidates, codexSkillsToContainers, discoverCodexWorkspaceAgents } from './codexCustomizations.js'; import { CodexClientCustomizationStore, codexAgentRoleToml, codexCustomizationConfig, codexMcpServersFromDefinitions, codexMcpServersFromPlugins, codexPluginMcpServerSources, codexSkillCapabilityRoots, codexSkillRootsFromPlugins, parsedPluginChildren, type ICodexClientPlugin } from './codexClientCustomizations.js'; import { IAgentHostCustomizationEnablementService, targetForUnownedMcpServer } from '../agentHostCustomizationEnablementService.js'; @@ -50,6 +50,7 @@ import { McpAuthRequiredReason, McpServerStatus, type AhpMcpUiHostCapabilities, import { IAgentConfigurationService } from '../agentConfigurationService.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { FileOperationResult, IFileService, toFileOperationResult } from '../../../files/common/files.js'; +import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js'; import { computeFolderPickerDecisionForRoots } from '../shared/folderPickerDecision.js'; import { codexDirectoryHasHooks } from './codexFolderPickerCriteria.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; @@ -68,6 +69,7 @@ import { PendingRequestRegistry } from '../../common/pendingRequestRegistry.js'; import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { CodexAppServerClient, JsonRpcError, transportFromChildProcess, type ICodexAppServerClient, type ServerRequestHandlerResult } from './codexAppServerClient.js'; import { ICodexProxyService, type ICodexProxyHandle } from './codexProxyService.js'; +import { GITHUB_MCP_SERVER_NAME, resolveGitHubMcpServerConfiguration } from '../shared/githubMcpServer.js'; import { createCodexSessionMapState, extractUserInputText, finalizeCodexTurnMapState, mapAgentMessageDelta, mapCommandExecutionOutputDelta, mapFileChangeOutputDelta, mapFileChangePatchUpdated, mapItemCompleted, mapItemStarted, mapMcpToolCallProgress, mapReasoningSummaryPartAdded, mapReasoningSummaryTextDelta, mapReasoningTextDelta, mapTokenUsageModelCallCompleted, mapTokenUsageUpdated, mapTurnCompleted, mapTurnStarted, type ICodexSessionMapState } from './codexMapAppServerEvents.js'; import type { ThreadTokenUsageUpdatedNotification } from './protocol/generated/v2/ThreadTokenUsageUpdatedNotification.js'; import { unwrapShellInvocation } from './codexShellCommand.js'; @@ -1044,6 +1046,9 @@ export class CodexAgent extends Disposable implements IAgent { */ private readonly _mcpAuthServerUrlsByResource = new Map>(); private _githubToken: string | undefined; + private _gitHubMcpServerConfiguration: IMcpServerConfiguration | undefined; + private _githubAuthenticationGeneration = 0; + private _githubMcpServerEnabled = true; private _connection: ConnectionState = { kind: 'idle' }; private _connectionGeneration = 0; private readonly _onDidDiscoverChats = this._register(new Emitter({ @@ -1086,6 +1091,7 @@ export class CodexAgent extends Disposable implements IAgent { ) { super(); this._metadataStore = this._instantiationService.createInstance(CodexSessionMetadataStore); + this._githubMcpServerEnabled = this._isGitHubMcpServerEnabled(); this._publishAccountInfo({ status: 'unknown' }); // Session titles are host-owned; Codex only observes them to correlate a @@ -1097,6 +1103,7 @@ export class CodexAgent extends Disposable implements IAgent { this._otelService.emitSessionTitleChanged(conversationId, session.toString(), title); } })); + this._register(this._gitHubEndpointService.onDidChange(() => this._handleGitHubEndpointChange())); this._register(this._customizationEnablementService.onDidChange(event => { const affectedConfigurations = new Map(); for (const session of this._sessions.values()) { @@ -1120,6 +1127,13 @@ export class CodexAgent extends Disposable implements IAgent { })); this._register(this._configurationService.onDidRootConfigChange(() => { + const githubMcpServerEnabled = this._isGitHubMcpServerEnabled(); + if (this._githubMcpServerEnabled !== githubMcpServerEnabled) { + this._githubMcpServerEnabled = githubMcpServerEnabled; + for (const session of this._sessions.values()) { + void this._reconcileMaterializedCustomizations(session); + } + } const signInRequest = this._configurationService.getRootConfigValues?.()[CODEX_ACCOUNT_SIGN_IN_REQUEST_KEY]; if (typeof signInRequest === 'string' && signInRequest !== this._lastSignInRequest) { this._lastSignInRequest = signInRequest; @@ -1239,22 +1253,61 @@ export class CodexAgent extends Disposable implements IAgent { return false; } const normalizedToken = token || undefined; + const generation = ++this._githubAuthenticationGeneration; const changed = this._githubToken !== normalizedToken; + const gitHubMcpServerConfiguration = changed + ? await this._resolveGitHubMcpServerConfiguration(normalizedToken) + : this._gitHubMcpServerConfiguration; + if (generation !== this._githubAuthenticationGeneration) { + return true; + } this._githubToken = normalizedToken; + this._gitHubMcpServerConfiguration = gitHubMcpServerConfiguration; if (changed && this._connection.kind === 'ready' && this._connection.proxyHandle) { - // Codex stays running — proxy reads the new token from its - // own cell on the next request (Decision 4). + // The app-server stays running. The proxy reads the new token from its + // own cell, while MCP-backed threads reconcile their per-thread config. this._connection.proxyHandle.setToken(normalizedToken ?? ''); this._queueModelRefresh(); } else if (changed) { // Defer model refresh until the connection comes up. this._queueModelRefresh(); } + if (changed) { + for (const session of this._sessions.values()) { + await this._reconcileMaterializedCustomizations(session); + } + } this._logService.info(normalizedToken ? '[Codex] Auth token updated' : '[Codex] Auth token cleared'); void this._refreshProviderConfiguration(); return true; } + private _isGitHubMcpServerEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostGitHubMcpServerEnabledConfigKey) !== false; + } + + private async _resolveGitHubMcpServerConfiguration(token: string | undefined): Promise { + try { + return await resolveGitHubMcpServerConfiguration(this._copilotApiService, token); + } catch (error) { + this._logService.warn(`[Codex] Failed to resolve the GitHub MCP server endpoint: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + } + + private _handleGitHubEndpointChange(): void { + this._githubAuthenticationGeneration++; + this._githubToken = undefined; + this._gitHubMcpServerConfiguration = undefined; + if (this._connection.kind === 'ready' && this._connection.proxyHandle) { + this._connection.proxyHandle.setToken(''); + } + this._queueModelRefresh(); + for (const session of this._sessions.values()) { + void this._reconcileMaterializedCustomizations(session); + } + } + /** * Receives a bearer token the workbench acquired for a protected resource * (the `authenticate` command is fanned out to every agent). If the @@ -2038,14 +2091,37 @@ export class CodexAgent extends Disposable implements IAgent { * header so codex connects authenticated. */ private _buildSessionMcpServers(session: ICodexSession): Record { + const configuredRoot = codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey)); const root = Object.fromEntries( - Object.entries(codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey))) + Object.entries(configuredRoot) .filter(([name]) => this._isMcpServerEnabledForSdk(session, name)), ); const workspace = codexMcpServersFromDefinitions(this._sessionMcpDiscoveries.get(session.sessionId)?.discovery.definitions ?? []); const enabledWorkspace = Object.fromEntries(Object.entries(workspace).filter(([name]) => this._isMcpServerEnabledForSdk(session, name))); const clientPlugins = codexMcpServersFromPlugins(this._enabledClientPlugins(session), session.workingDirectory); - return injectCodexMcpAuthTokens({ ...root, ...enabledWorkspace, ...clientPlugins }, this._mcpAuthTokens); + const enabledConfiguredServers = { ...root, ...enabledWorkspace, ...clientPlugins }; + const builtInGitHub = this._builtInGitHubMcpServer(session, enabledConfiguredServers); + return injectCodexMcpAuthTokens({ ...builtInGitHub, ...enabledConfiguredServers }, this._mcpAuthTokens); + } + + private _builtInGitHubMcpServer(session: ICodexSession, configuredServers: Record): Record { + if (!this._githubMcpServerEnabled + || !this._githubToken + || !this._gitHubMcpServerConfiguration + || !this._isMcpServerEnabledForSdk(session, GITHUB_MCP_SERVER_NAME) + || this._hasConfiguredGitHubMcpServer(configuredServers)) { + return {}; + } + return { [GITHUB_MCP_SERVER_NAME]: toCodexMcpServerJson(this._gitHubMcpServerConfiguration) }; + } + + private _hasConfiguredGitHubMcpServer(configuredServers: Record): boolean { + const builtInUrl = this._gitHubMcpServerConfiguration?.type === McpServerType.REMOTE + ? normalizeCodexMcpResourceUrl(this._gitHubMcpServerConfiguration.url) + : undefined; + return Object.hasOwn(configuredServers, GITHUB_MCP_SERVER_NAME) + || builtInUrl !== undefined && Object.values(configuredServers).some(server => + server.url !== undefined && normalizeCodexMcpResourceUrl(server.url) === builtInUrl); } private async _refreshSessionMcpDiscovery(session: ICodexSession): Promise { @@ -2086,11 +2162,17 @@ export class CodexAgent extends Disposable implements IAgent { * Computed from a token-free build so the URLs are the bare server URLs. */ private _httpMcpServerUrls(session: ICodexSession): Map { - const root = codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey)); + const root = Object.fromEntries( + Object.entries(codexMcpServersFromConfig(this._configurationService.getRootValue(platformRootSchema, AgentHostMcpServersConfigKey))) + .filter(([name]) => this._isMcpServerEnabledForSdk(session, name)), + ); const workspace = codexMcpServersFromDefinitions(this._sessionMcpDiscoveries.get(session.sessionId)?.discovery.definitions ?? []); + const enabledWorkspace = Object.fromEntries(Object.entries(workspace).filter(([name]) => this._isMcpServerEnabledForSdk(session, name))); const clientPlugins = codexMcpServersFromPlugins(this._enabledClientPlugins(session), session.workingDirectory); + const configuredServers = { ...root, ...enabledWorkspace, ...clientPlugins }; + const builtInGitHub = this._builtInGitHubMcpServer(session, configuredServers); const urls = new Map(); - for (const [name, server] of Object.entries({ ...root, ...workspace, ...clientPlugins })) { + for (const [name, server] of Object.entries({ ...builtInGitHub, ...configuredServers })) { const normalized = server.url !== undefined ? normalizeCodexMcpResourceUrl(server.url) : undefined; if (normalized !== undefined) { urls.set(name, normalized); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index 6c2725d39c5ed9..cc8d4f3291c288 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -33,14 +33,13 @@ import { ITelemetryService } from '../../../telemetry/common/telemetry.js'; import { INativeEnvironmentService } from '../../../../platform/environment/common/environment.js'; import { workspacelessScratchDir } from '../workspacelessScratchDir.js'; import { IAgentHostCheckpointService } from '../../common/agentHostCheckpointService.js'; -import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; import type { IAgentHostClientTelemetryContext } from '../../common/agentHostTelemetry.js'; import { IAgentHostReviewService } from '../../common/agentHostReviewService.js'; import { createPricingMetaFromBilling, hasLongContextSurcharge, normalizeCAPIBilling, type ICAPIModelBilling } from '../../common/agentModelPricing.js'; import { createAgentModelByokMeta } from '../../common/agentModelByokMeta.js'; import { AgentHostConfigKey, agentHostCustomizationConfigSchema, DEFAULT_SESSION_CUSTOMIZATION_DISCOVERY_MODE, toContainerCustomization } from '../../common/agentHostCustomizationConfig.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey, copilotCliConfigSchema, DEFAULT_COPILOT_RUBBER_DUCK_ENABLED, type CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostMcpServersConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostSessionSyncEnabledConfigKey, AgentHostSystemProxyEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, agentHostProxyConfigSchema, AutoApproveLevel, SessionMode, migrateLegacyAutopilotConfig, platformRootSchema, platformSessionSchema, type AgentHostMcpServers } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { decodeProviderData, encodeProviderData, type IPersistedChat } from '../agentChatBackings.js'; import { prepareSideChatPrompt, sliceSideChatTurns } from '../agentPeerChats.js'; @@ -82,6 +81,7 @@ import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitP import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js'; import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js'; import { CopilotSessionLauncher, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js'; +import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js'; import { ShellManager } from './copilotShellTools.js'; import { getServerManagedSandboxEnabled } from './sandboxConfigForSdk.js'; import { isAgentHostTelemetryService } from '../agentHostTelemetryService.js'; @@ -89,6 +89,7 @@ import { ICopilotApiService, type IRestrictedTelemetryContext } from '../shared/ import { AgentHostGitHubTelemetryRouter } from '../agentHostGitHubTelemetryRouter.js'; import { AgentHostClientType } from '../../common/agentHostClientInfo.js'; import { CopilotSlashCommandCompletionProvider, ICopilotRuntimeSlashCommandQueryOptions } from './copilotSlashCommandCompletionProvider.js'; +import { GITHUB_MCP_SERVER_NAME } from '../shared/githubMcpServer.js'; import { DiscoveredType, SessionCustomizationDiscovery, areDiscoveredDirectoriesEqual, workspaceDirectoryHasHooks, type IDiscoveredDirectory } from './sessionCustomizationDiscovery.js'; import { computeFolderPickerDecisionForRoots } from '../shared/folderPickerDecision.js'; import { COPILOT_INTEGRATION_ID } from '../../../endpoint/common/licenseAgreement.js'; @@ -110,6 +111,18 @@ interface ICopilotRuntimeManagedSettingsSdk { const COPILOT_MANAGED_SETTINGS_QUERY_TIMEOUT_MS = 3500; const COPILOT_MANAGED_SETTINGS_DIAGNOSTICS_TIMEOUT_MS = 4500; +const COPILOT_ENABLE_BUILTIN_GITHUB_MCP_ENV_VAR = 'COPILOT_ENABLE_BUILTIN_GITHUB_MCP'; + +function setCopilotBuiltinGitHubMcpEnvironment(env: Record, enabled: boolean): void { + for (const key of Object.keys(env)) { + if (key.toUpperCase() === COPILOT_ENABLE_BUILTIN_GITHUB_MCP_ENV_VAR) { + delete env[key]; + } + } + if (enabled) { + env[COPILOT_ENABLE_BUILTIN_GITHUB_MCP_ENV_VAR] = 'true'; + } +} function isCopilotRuntimeManagedSettingsSdk(value: unknown): value is ICopilotRuntimeManagedSettingsSdk { return typeof value === 'object' && value !== null && 'getManagedSettings' in value @@ -787,7 +800,7 @@ export class CopilotAgent extends Disposable implements IAgent { @IFileService private readonly _fileService: IFileService, ) { super(); - this._lastManagedSettingsPermissions = this._managedSettingsService.permissions; + this._lastStartupConfig = this._readClientStartupConfig(); this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient())); this._sessionLauncher = this._instantiationService.createInstance(CopilotSessionLauncher); this._configurationService.publishRootTransientValues?.({ [CopilotCliVSCodeAssignmentContextKey]: undefined }); @@ -893,12 +906,7 @@ export class CopilotAgent extends Disposable implements IAgent { } } - private _lastSessionSyncEnabled: boolean = this._isSessionSyncEnabled(); - private _lastRubberDuckEnabled: boolean = this._isRubberDuckEnabled(); - private _lastCopilotSdkLogLevelSetting: CopilotSdkLogLevelSetting = this._getCopilotSdkLogLevelSetting(); - private _lastEnterpriseHost: string | undefined = this._getEnterpriseHost(); - private _lastSystemProxyEnabled: boolean = this._isSystemProxyEnabled(); - private _lastManagedSettingsPermissions: IAgentHostManagedSettingsPermissions; + private _lastStartupConfig: CopilotAgentStartupConfig; private _lastMigrateLegacyEnabled: boolean = this._isMigrateLegacyCopilotCliEnabled(); private _isSessionSyncEnabled(): boolean { @@ -925,10 +933,26 @@ export class CopilotAgent extends Disposable implements IAgent { return this._configurationService.getRootValue(platformRootSchema, AgentHostSystemProxyEnabledConfigKey) !== false; } + private _isGitHubMcpServerEnabled(): boolean { + return this._configurationService.getRootValue(platformRootSchema, AgentHostGitHubMcpServerEnabledConfigKey) !== false; + } + private _isMigrateLegacyCopilotCliEnabled(): boolean { return this._configurationService.getRootValue(platformRootSchema, AgentHostMigrateLegacyCopilotCliEnabledConfigKey) === true; } + private _readClientStartupConfig(): CopilotAgentStartupConfig { + return new CopilotAgentStartupConfig( + this._isSessionSyncEnabled(), + this._isRubberDuckEnabled(), + this._getCopilotSdkLogLevelSetting(), + this._getEnterpriseHost(), + this._isSystemProxyEnabled(), + this._isGitHubMcpServerEnabled(), + this._managedSettingsService.permissions, + ); + } + /** * A key absent from root config (e.g. dropped by a schema-filtered replace) * keeps the last-known context sticky; an explicit empty-string dispatch @@ -948,31 +972,14 @@ export class CopilotAgent extends Disposable implements IAgent { * An in-flight start aborts if any startup value changes. */ private async _restartClientIfStartupConfigChanged(): Promise { - const sessionSync = this._isSessionSyncEnabled(); - const rubberDuck = this._isRubberDuckEnabled(); - const copilotSdkLogLevelSetting = this._getCopilotSdkLogLevelSetting(); - const enterpriseHost = this._getEnterpriseHost(); - const systemProxyEnabled = this._isSystemProxyEnabled(); - const managedSettingsPermissions = this._managedSettingsService.permissions; - const proxyTargetChanged = this._lastEnterpriseHost !== enterpriseHost || this._lastSystemProxyEnabled !== systemProxyEnabled; - if (this._lastSessionSyncEnabled === sessionSync && this._lastRubberDuckEnabled === rubberDuck && this._lastCopilotSdkLogLevelSetting === copilotSdkLogLevelSetting && this._lastEnterpriseHost === enterpriseHost && this._lastSystemProxyEnabled === systemProxyEnabled && equals(this._lastManagedSettingsPermissions, managedSettingsPermissions)) { + const previous = this._lastStartupConfig; + const current = this._readClientStartupConfig(); + if (current.equals(previous)) { return; } - const changed = [ - this._lastSessionSyncEnabled !== sessionSync ? `sessionSync=${sessionSync}` : undefined, - this._lastRubberDuckEnabled !== rubberDuck ? `rubberDuck=${rubberDuck}` : undefined, - this._lastCopilotSdkLogLevelSetting !== copilotSdkLogLevelSetting ? `copilotSdkLogLevel=${copilotSdkLogLevelSetting}` : undefined, - this._lastEnterpriseHost !== enterpriseHost ? `enterpriseHost=${enterpriseHost}` : undefined, - this._lastSystemProxyEnabled !== systemProxyEnabled ? `systemProxy=${systemProxyEnabled}` : undefined, - !equals(this._lastManagedSettingsPermissions, managedSettingsPermissions) ? 'managedSettingsPermissions' : undefined, - ].filter((v): v is string => v !== undefined).join(', '); - this._lastSessionSyncEnabled = sessionSync; - this._lastRubberDuckEnabled = rubberDuck; - this._lastCopilotSdkLogLevelSetting = copilotSdkLogLevelSetting; - this._lastEnterpriseHost = enterpriseHost; - this._lastSystemProxyEnabled = systemProxyEnabled; - this._lastManagedSettingsPermissions = managedSettingsPermissions; - if (proxyTargetChanged) { + const changed = current.describeChangesFrom(previous); + this._lastStartupConfig = current; + if (current.proxyTargetChangedFrom(previous)) { this._refreshProxy(); } if (this._client) { @@ -1841,11 +1848,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Snapshot the startup config so we can detect a change that lands while the // client is still starting and abort the stale start (the values are baked // into the client options / subprocess env below). - const sessionSyncAtStartup = this._isSessionSyncEnabled(); - const rubberDuckAtStartup = this._isRubberDuckEnabled(); - const copilotSdkLogLevelSettingAtStartup = this._getCopilotSdkLogLevelSetting(); - const enterpriseHostAtStartup = this._getEnterpriseHost(); - const systemProxyEnabledAtStartup = this._isSystemProxyEnabled(); + const startupConfig = this._readClientStartupConfig(); const attemptNumber = ++this._clientStartupAttemptCount; const startupStopWatch = StopWatch.create(); const startClient = async () => { @@ -1859,6 +1862,7 @@ export class CopilotAgent extends Disposable implements IAgent { // re-introduce a process-wide alias for every session behind its back. delete env['COPILOT_MODEL_FAMILY']; this._applyProxyEnv(env); + setCopilotBuiltinGitHubMcpEnvironment(env, startupConfig.githubMcpServer); // On Linux the MXC bubblewrap sandbox backend does not forward a PTY into // the container, so the CLI's default PTY-backed interactive shell can @@ -1885,7 +1889,7 @@ export class CopilotAgent extends Disposable implements IAgent { // authentication and CAPI endpoint discovery. `COPILOT_GH_HOST` is // Copilot-CLI-specific (it does not affect the `gh` CLI). Unset for // github.com so the CLI uses its default host. - const enterpriseHost = this._getEnterpriseHost(); + const enterpriseHost = startupConfig.enterpriseHost; if (enterpriseHost) { env['COPILOT_GH_HOST'] = enterpriseHost; this._logService.info(`[Copilot] Set CLI env: COPILOT_GH_HOST=${enterpriseHost}`); @@ -1894,7 +1898,7 @@ export class CopilotAgent extends Disposable implements IAgent { // Enable the rubber duck critic subagent in the CLI when the agent host // config opts in. `RUBBER_DUCK_AGENT` is the SDK's required interface for // gating this experimental feature - if (this._isRubberDuckEnabled()) { + if (startupConfig.rubberDuck) { env['RUBBER_DUCK_AGENT'] = 'true'; } else { delete env['RUBBER_DUCK_AGENT']; @@ -1944,7 +1948,7 @@ export class CopilotAgent extends Disposable implements IAgent { } else if (nativeTelemetry) { env['OTEL_METRICS_EXPORTER'] = 'none'; } - const copilotSdkLogLevelAtStartup = this._resolveCopilotSdkLogLevel(copilotSdkLogLevelSettingAtStartup); + const copilotSdkLogLevelAtStartup = this._resolveCopilotSdkLogLevel(startupConfig.copilotSdkLogLevel); const clientOptions: CopilotClientOptions = { useLoggedInUser: false, @@ -1952,7 +1956,7 @@ export class CopilotAgent extends Disposable implements IAgent { env, telemetry, logLevel: copilotSdkLogLevelAtStartup, - enableRemoteSessions: sessionSyncAtStartup, + enableRemoteSessions: startupConfig.sessionSync, onGetTraceContext: () => this._otelService.getCurrentTraceContext() ?? {}, onGitHubTelemetry: notification => { void this._routeGitHubTelemetry(notification).catch(err => this._logService.trace(`[Copilot] GitHub telemetry routing failed: ${err instanceof Error ? err.message : String(err)}`)); }, }; @@ -1961,7 +1965,7 @@ export class CopilotAgent extends Disposable implements IAgent { if (this._shutdownPromise) { return this._stopClientAfterStartupTermination(client, new CancellationError()); } - if (this._isSessionSyncEnabled() !== sessionSyncAtStartup || this._isRubberDuckEnabled() !== rubberDuckAtStartup || this._getCopilotSdkLogLevelSetting() !== copilotSdkLogLevelSettingAtStartup || this._getEnterpriseHost() !== enterpriseHostAtStartup || this._isSystemProxyEnabled() !== systemProxyEnabledAtStartup) { + if (!this._readClientStartupConfig().equals(startupConfig)) { return this._stopClientAfterStartupTermination(client, new CopilotClientStartupConfigChangedError()); } this._logService.info('[Copilot] CopilotClient started successfully'); @@ -3155,7 +3159,7 @@ export class CopilotAgent extends Disposable implements IAgent { additionalDirectories: this._additionalCustomizationDirectories(resolvedWorkingDirectories), resolvedAgentName: resolvedAgent?.name, snapshot, - disabledRootMcpServers: this._disabledRootMcpServers(sessionUri, sdkSessionId, snapshot), + disabledRootMcpServers: await this._disabledRootMcpServers(sessionUri, sdkSessionId, snapshot), activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, @@ -3377,9 +3381,12 @@ export class CopilotAgent extends Disposable implements IAgent { const rootsChanged = !!entry && workingDirectories !== undefined && !areAdditionalWorkingDirectoriesEqual(entry.appliedAdditionalDirectories, this._additionalCustomizationDirectories(workingDirectories)); const currentSnapshot = entry && activeClient ? await activeClient.snapshot(current.chatKey) : undefined; const structuralConfigChanged = !!entry && !!activeClient && !!currentSnapshot && await activeClient.requiresRestart(entry.appliedSnapshot, current.chatKey, currentSnapshot); - const disabledRootMcpServersChanged = !!entry && !!currentSnapshot && !equals( + const currentDisabledRootMcpServers = entry && currentSnapshot + ? await this._disabledRootMcpServers(current.configurationResource, entry.sessionId, currentSnapshot) + : undefined; + const disabledRootMcpServersChanged = !!entry && !!currentDisabledRootMcpServers && !equals( [...new Set(entry.appliedDisabledRootMcpServers)].sort(), - [...new Set(this._disabledRootMcpServers(current.configurationResource, entry.sessionId, currentSnapshot))].sort(), + [...new Set(currentDisabledRootMcpServers)].sort(), ); if (entry && (rootsChanged || structuralConfigChanged || disabledRootMcpServersChanged || entry.requiresMcpLaunchConfigurationRefresh)) { this._logService.info(`[Copilot:${current.configurationId}] Session configuration changed, refreshing session. clients=[${activeClient ? [...activeClient.toolSet.clientIds()].join(', ') || '(none)' : '(none)'}]`); @@ -3660,7 +3667,7 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, resolvedAgentName: undefined, snapshot, - disabledRootMcpServers: this._disabledRootMcpServers(session, sdkSessionId, snapshot), + disabledRootMcpServers: await this._disabledRootMcpServers(session, sdkSessionId, snapshot), activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, @@ -3690,7 +3697,7 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, resolvedAgentName: undefined, snapshot, - disabledRootMcpServers: this._disabledRootMcpServers(session, sdkSessionId, snapshot), + disabledRootMcpServers: await this._disabledRootMcpServers(session, sdkSessionId, snapshot), activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, @@ -3706,7 +3713,7 @@ export class CopilotAgent extends Disposable implements IAgent { workingDirectory, resolvedAgentName: undefined, snapshot, - disabledRootMcpServers: this._disabledRootMcpServers(session, chatSdkId, snapshot), + disabledRootMcpServers: await this._disabledRootMcpServers(session, chatSdkId, snapshot), activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, @@ -4108,7 +4115,7 @@ export class CopilotAgent extends Disposable implements IAgent { additionalDirectories: launchWorkingDirectories?.slice(1), resolvedAgentName: info.agent ? this._resolveAgentName(snapshot, info.agent) : undefined, snapshot, - disabledRootMcpServers: this._disabledRootMcpServers(configurationResource, info.sdkSessionId, snapshot), + disabledRootMcpServers: await this._disabledRootMcpServers(configurationResource, info.sdkSessionId, snapshot), activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, @@ -4430,8 +4437,13 @@ export class CopilotAgent extends Disposable implements IAgent { } /** Resolves root-configured MCP servers that must be disabled when the SDK session starts. */ - private _disabledRootMcpServers(session: URI, sessionId: string, snapshot: IActiveClientSnapshot): readonly string[] { - const rootServers: McpServerCustomization[] = Object.keys(snapshot.mcpServers).map(name => { + private async _disabledRootMcpServers(session: URI, sessionId: string, snapshot: IActiveClientSnapshot): Promise { + await this._customizationEnablementService.initializeSession(session.toString()); + const serverNames = new Set(Object.keys(snapshot.mcpServers)); + if (this._isGitHubMcpServerEnabled()) { + serverNames.add(GITHUB_MCP_SERVER_NAME); + } + const rootServers: McpServerCustomization[] = [...serverNames].map(name => { const id = buildMcpTopLevelCustomizationId(this.id, sessionId, name); return { type: CustomizationType.McpServer, @@ -4575,7 +4587,7 @@ export class CopilotAgent extends Disposable implements IAgent { additionalDirectories: this._additionalCustomizationDirectories(launchWorkingDirectories), resolvedAgentName, snapshot, - disabledRootMcpServers: this._disabledRootMcpServers(sessionUri, sessionId, snapshot), + disabledRootMcpServers: await this._disabledRootMcpServers(sessionUri, sessionId, snapshot), activeClientToolSet: activeClient.toolSet, shellManager, githubToken: this._githubToken, diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts b/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts new file mode 100644 index 00000000000000..c3cb3dc1ed3237 --- /dev/null +++ b/src/vs/platform/agentHost/node/copilot/copilotAgentStartupConfig.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { equals } from '../../../../base/common/objects.js'; +import type { IAgentHostManagedSettingsPermissions } from '../../common/agentHostManagedSettings.js'; +import type { CopilotSdkLogLevelSetting } from '../../common/copilotCliConfig.js'; + +export class CopilotAgentStartupConfig { + constructor( + readonly sessionSync: boolean, + readonly rubberDuck: boolean, + readonly copilotSdkLogLevel: CopilotSdkLogLevelSetting, + readonly enterpriseHost: string | undefined, + readonly systemProxy: boolean, + readonly githubMcpServer: boolean, + readonly managedSettingsPermissions: IAgentHostManagedSettingsPermissions, + ) { } + + equals(other: CopilotAgentStartupConfig): boolean { + return this.changedKeysFrom(other).length === 0; + } + + proxyTargetChangedFrom(other: CopilotAgentStartupConfig): boolean { + return this.enterpriseHost !== other.enterpriseHost || this.systemProxy !== other.systemProxy; + } + + describeChangesFrom(other: CopilotAgentStartupConfig): string { + const values = new Map(Object.entries(this)); + return this.changedKeysFrom(other) + .map(key => key === 'managedSettingsPermissions' ? key : `${key}=${String(values.get(key))}`) + .join(', '); + } + + private changedKeysFrom(other: CopilotAgentStartupConfig): string[] { + const otherEntries = new Map(Object.entries(other)); + return Object.entries(this) + .filter(([key, value]) => key === 'managedSettingsPermissions' + ? !equals(value, otherEntries.get(key)) + : value !== otherEntries.get(key)) + .map(([key]) => key); + } +} diff --git a/src/vs/platform/agentHost/node/shared/githubMcpServer.ts b/src/vs/platform/agentHost/node/shared/githubMcpServer.ts new file mode 100644 index 00000000000000..69841746ddbfa0 --- /dev/null +++ b/src/vs/platform/agentHost/node/shared/githubMcpServer.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { McpServerType, type IMcpServerConfiguration } from '../../../mcp/common/mcpPlatformTypes.js'; +import { gitHubMcpServerUrl } from '../../common/githubEndpoints.js'; +import type { ICopilotApiService } from './copilotApiService.js'; +import { findExecutable } from '../../../../base/node/processes.js'; + +export const GITHUB_MCP_SERVER_NAME = 'github-mcp-server'; +export const GITHUB_MCP_FEATURES_HEADER = 'X-MCP-Features'; +export const GITHUB_MCP_FEATURES = 'remote_mcp_ui_apps,mcp_apps_disable_form_deferral'; +export const GITHUB_MCP_TOOLS_HEADER = 'X-MCP-Tools'; + +/** + * The following tool logic mirrors that of the Copilot SDK for the + * built-in GH MCP as exposed for other harnesses. + */ +export const GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS = [ + 'get_file_contents', + 'search_code', + 'get_copilot_space', + 'list_copilot_spaces', + 'web_search', + 'search_users', +] as const; + +export const GITHUB_MCP_TOOLS_WITH_GH_EQUIVALENTS = [ + 'search_repositories', + 'list_branches', + 'list_commits', + 'get_commit', + 'issue_read', + 'list_issues', + 'search_issues', + 'pull_request_read', + 'list_pull_requests', + 'search_pull_requests', + 'actions_list', + 'actions_get', + 'get_job_logs', +] as const; + +export const GITHUB_MCP_DEPRECATED_WORKFLOW_TOOLS = [ + 'list_workflow_runs', + 'get_workflow_run', + 'list_workflows', + 'get_workflow_run_logs', + 'get_workflow', +] as const; + +let ghCliAvailable: Promise | undefined; + +async function isGhCliAvailable(): Promise { + ghCliAvailable ??= (async () => await findExecutable('gh').catch(() => undefined) !== undefined)(); + return ghCliAvailable; +} + +export function getGitHubMcpTools(hasGhCli: boolean): readonly string[] { + return hasGhCli + ? GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS + : [ + ...GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS, + ...GITHUB_MCP_TOOLS_WITH_GH_EQUIVALENTS, + ...GITHUB_MCP_DEPRECATED_WORKFLOW_TOOLS, + ]; +} + +export function createGitHubMcpServerConfiguration(copilotApiBaseUri: string, hasGhCli = false): IMcpServerConfiguration { + const url = gitHubMcpServerUrl(copilotApiBaseUri); + if (!url) { + throw new Error('Unable to resolve the GitHub MCP server URL'); + } + const headers = { + [GITHUB_MCP_FEATURES_HEADER]: GITHUB_MCP_FEATURES, + [GITHUB_MCP_TOOLS_HEADER]: getGitHubMcpTools(hasGhCli).join(','), + }; + return { + type: McpServerType.REMOTE, + url, + headers, + }; +} + +export async function resolveGitHubMcpServerConfiguration(copilotApiService: ICopilotApiService, token: string | undefined): Promise { + if (!token) { + return undefined; + } + const [copilotApiBaseUri, hasGhCli] = await Promise.all([ + copilotApiService.resolveApiEndpoint(token), + isGhCliAvailable(), + ]); + if (!copilotApiBaseUri) { + return undefined; + } + return createGitHubMcpServerConfiguration(copilotApiBaseUri, hasGhCli); +} diff --git a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts index 5eecfdbdef618a..340d3c13476326 100644 --- a/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts +++ b/src/vs/platform/agentHost/test/common/agentHostSchema.test.ts @@ -6,7 +6,7 @@ import assert from 'assert'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; import type { IConfigurationValue } from '../../../configuration/common/configuration.js'; -import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; +import { AgentHostActiveAgentTitleGenerationConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostMarkdownPlanRichLinksEnabledConfigKey, createSchema, migrateLegacyAutopilotConfig, normalizeAgentHostTerminalAutoApproveRulesConfig, platformRootSchema, platformSessionSchema, schemaProperty, type AgentHostTerminalAutoApproveRules, type AutoApproveLevel, type IPermissionsValue, type SessionMode } from '../../common/agentHostSchema.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { JsonRpcErrorCodes, ProtocolError } from '../../common/state/sessionProtocol.js'; @@ -42,6 +42,12 @@ suite('agentHostSchema', () => { assert.strictEqual(property.default, false); }); + test('GitHub MCP is an additive enabled-by-default root setting', () => { + const property = platformRootSchema.toProtocol().properties[AgentHostGitHubMcpServerEnabledConfigKey]; + assert.strictEqual(property.type, 'boolean'); + assert.strictEqual(property.default, true); + }); + // ---- schemaProperty / individual validators --------------------------- suite('schemaProperty', () => { diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 218ec0e8463664..c1a50d5c3bf7ad 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -5708,13 +5708,16 @@ suite('AgentService (node dispatcher)', () => { test('stores GitHub Copilot token for operation handlers', async () => { service.registerProvider(copilotAgent); + const changes: { resource: string; token: string | undefined }[] = []; + disposables.add(service.authenticationService.onDidChangeAuthToken(event => changes.push({ resource: event.resource, token: event.token }))); const result = await service.authenticate({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }); - assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported }), authenticateCalls: copilotAgent.authenticateCalls }, { + assert.deepStrictEqual({ result, token: service.getAuthToken({ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, scopes: GITHUB_COPILOT_PROTECTED_RESOURCE.scopes_supported }), authenticateCalls: copilotAgent.authenticateCalls, changes }, { result: { authenticated: true }, token: 'copilot-token', authenticateCalls: [{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }], + changes: [{ resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'copilot-token' }], }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts index 8182893722ee3f..69166cf147cb46 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.integrationTest.ts @@ -39,6 +39,7 @@ import type { CCAModel } from '@vscode/copilot-api'; import assert from 'assert'; import type * as http from 'http'; import { DeferredPromise } from '../../../../base/common/async.js'; +import { Event } from '../../../../base/common/event.js'; import { URI } from '../../../../base/common/uri.js'; import { generateUuid } from '../../../../base/common/uuid.js'; import { Schemas } from '../../../../base/common/network.js'; @@ -65,6 +66,7 @@ import { IAgentHostGitService } from '../../common/agentHostGitService.js'; import { IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; import { IAgentHostCustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { createNoopCustomizationEnablementService } from './testCustomizationEnablementService.js'; +import { IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js'; import { ClaudeAgent } from '../../node/claude/claudeAgent.js'; import { IClaudeAgentSdkService } from '../../node/claude/claudeAgentSdkService.js'; import { IAgentPluginManager } from '../../common/agentPluginManager.js'; @@ -115,6 +117,14 @@ function claudeFileEnvServices(disposables: Pick): [type ]; } +function createTestAuthenticationService(): IAgentHostAuthenticationService { + return { + _serviceBrand: undefined, + onDidChangeAuthToken: Event.None, + getAuthToken: request => request.resource === GITHUB_COPILOT_PROTECTED_RESOURCE.resource ? 'gh-int-test-token' : undefined, + }; +} + const ANTHROPIC_MODEL: CCAModel = { id: 'claude-opus-4.6', name: 'Claude Opus 4.6', @@ -723,6 +733,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [IAgentHostCustomizationEnablementService, createNoopCustomizationEnablementService()], + [IAgentHostAuthenticationService, createTestAuthenticationService()], ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); @@ -861,6 +872,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [IAgentHostCustomizationEnablementService, createNoopCustomizationEnablementService()], + [IAgentHostAuthenticationService, createTestAuthenticationService()], ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); @@ -941,6 +953,7 @@ suite('ClaudeAgent integration (proxy-backed)', function () { [IAgentHostGitService, createNoopGitService()], [IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE], [IAgentHostCustomizationEnablementService, createNoopCustomizationEnablementService()], + [IAgentHostAuthenticationService, createTestAuthenticationService()], ...claudeFileEnvServices(disposables), ); const instantiationService = disposables.add(new InstantiationService(services)); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 65626daff0e9c2..13026fe576e599 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -47,7 +47,7 @@ import { InMemoryFileSystemProvider } from '../../../files/common/inMemoryFilesy import { Schemas } from '../../../../base/common/network.js'; import { INativeEnvironmentService } from '../../../environment/common/environment.js'; import { IActiveClient, IAgent, IAgentChatContext, IAgentChatDataChange, IAgentChatMetadata, IAgentCreateChatOptions, IAgentCreateChatResult, IAgentCreateSessionConfig, IAgentCreateSessionResult, IAgentMaterializeChatEvent, IAgentSpawnChatEvent, AgentSession, AgentSignal, GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../common/agent.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostClaudeMultiRootEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostClaudeMultiRootEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey } from '../../common/agentHostSchema.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; import { AgentFeedbackAttachmentDisplayKind } from '../../common/meta/agentFeedbackAttachments.js'; import { toClientPluginMcpDefaultCwdsMeta } from '../../common/meta/clientPluginCustomizationMeta.js'; @@ -66,6 +66,7 @@ import { AgentHostStateManager, IAgentHostStateManager } from '../../node/agentH import { IAgentHostCustomizationEnablementService, type IAgentHostCustomizationEnablementService as ICustomizationEnablementService } from '../../node/agentHostCustomizationEnablementService.js'; import { AgentHostSessionTitleSignal, IAgentHostSessionTitleSignal } from '../../node/agentHostSessionTitleSignal.js'; import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; +import { IAgentHostAuthenticationService, type IAgentHostAuthTokenChangeEvent } from '../../node/agentHostAuthenticationService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { makeMcpServerCustomization } from '../../../agentPlugins/common/pluginParsers.js'; @@ -367,6 +368,45 @@ class FakeClaudeProxyService implements IClaudeProxyService { dispose(): void { this.onDidReportCreditsEmitter.dispose(); } } +class FakeAgentHostAuthenticationService implements IAgentHostAuthenticationService { + declare readonly _serviceBrand: undefined; + private readonly _tokens = new Map(); + private readonly _onDidChangeAuthToken = new Emitter(); + readonly onDidChangeAuthToken = this._onDidChangeAuthToken.event; + + setToken(resource: string, token: string): void { + const previous = this._tokens.get(resource); + if (token) { + this._tokens.set(resource, token); + } else { + this._tokens.delete(resource); + } + const current = this._tokens.get(resource); + if (previous !== current) { + this._onDidChangeAuthToken.fire({ resource, scopes: [], token: current }); + } + } + + getAuthToken(request: Parameters[0]): string | undefined { + return this._tokens.get(request.resource); + } + + dispose(): void { + this._onDidChangeAuthToken.dispose(); + } +} + +function connectAuthentication(agent: ClaudeAgent, authenticationService: FakeAgentHostAuthenticationService): void { + const authenticate = agent.authenticate.bind(agent); + agent.authenticate = async (resource, token) => { + const authenticated = await authenticate(resource, token); + if (authenticated) { + authenticationService.setToken(resource, token); + } + return authenticated; + }; +} + class FakeCopilotApiService implements ICopilotApiService { declare readonly _serviceBrand: undefined; @@ -378,7 +418,7 @@ class FakeCopilotApiService implements ICopilotApiService { responses(): Promise { throw new Error('not used in ClaudeAgent tests'); } utilityChatCompletion(): Promise { throw new Error('not used in ClaudeAgent tests'); } resolveRestrictedTelemetryContext() { return Promise.resolve({ restrictedTelemetryEnabled: false, trackingId: undefined, telemetryEndpoint: undefined }); } - resolveApiEndpoint() { return Promise.resolve(undefined); } + resolveApiEndpoint() { return Promise.resolve('https://api.githubcopilot.com'); } } const FakeProductService: IProductService = { @@ -1058,6 +1098,7 @@ function createTestContext( const logService = overrides?.logService ?? new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const authenticationService = disposables.add(new FakeAgentHostAuthenticationService()); // In-memory file service the session's customization scan / agent-name // resolution runs against; exposed so tests can seed `.claude/**` files. @@ -1083,6 +1124,7 @@ function createTestContext( [IAgentHostOTelService, otelService], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, overrides?.gitHubEndpointService ?? createTestGitHubEndpointService()], + [IAgentHostAuthenticationService, authenticationService], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); // Seed root config (e.g. `allowSignedOutWhenUsable`) BEFORE the agent @@ -1091,6 +1133,7 @@ function createTestContext( configService.updateRootConfig(overrides.rootConfig); } const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + connectAuthentication(agent, authenticationService); // Mirrors exactly what Agent Host stamps on every addressed chat // operation: `createAgentChatContext` is the orchestrator's single // derivation, so the agent under test always receives the same exhaustive @@ -4138,7 +4181,6 @@ suite('ClaudeAgent', () => { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); - const services = new ServiceCollection( ...claudeFileEnvServices(disposables), [ILogService, logService], @@ -4157,6 +4199,7 @@ suite('ClaudeAgent', () => { [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); + services.set(IAgentHostAuthenticationService, disposables.add(new FakeAgentHostAuthenticationService())); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent: ClaudeAgent = disposables.add(instantiationService.createInstance(ClaudeAgent)); @@ -5461,6 +5504,7 @@ suite('ClaudeAgent', () => { [IAgentHostGitService, createNoopGitService()], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], + [IAgentHostAuthenticationService, disposables.add(new FakeAgentHostAuthenticationService())], ); const instantiationService = disposables.add(new InstantiationService(services)); const agent = instantiationService.createInstance(ClaudeAgent); @@ -5522,6 +5566,7 @@ suite('ClaudeAgent', () => { [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], ); + services.set(IAgentHostAuthenticationService, disposables.add(new FakeAgentHostAuthenticationService())); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent: ClaudeAgent = instantiationService.createInstance(ClaudeAgent); @@ -5624,7 +5669,7 @@ suite('ClaudeAgent', () => { secondMcpToolNames: lastBuild?.toolNames, }, { startupCount: 2, - firstMcp: false, + firstMcp: true, secondMcpToolNames: ['echo'], }); }); @@ -6452,6 +6497,7 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { const sdk = new FakeClaudeAgentSdkService(); const workingDirectoryPendingChange = disposables.add(new Emitter()); const fakeConfigService: IAgentConfigurationService = { + onDidRootConfigChange: Event.None, onDidSessionConfigChange: Event.None, getSessionConfigValues: () => undefined, onDidChangeWorkingDirectoryPending: workingDirectoryPendingChange.event, @@ -6466,6 +6512,9 @@ suite('ClaudeAgentSession (Phase 7 §3.2)', () => { [IAgentHostCustomizationEnablementService, reducerBackedEnablementService(stateManager)], [IAgentHostOTelService, new RecordingOTelService()], [IClaudeAgentSdkService, sdk], + [ICopilotApiService, new FakeCopilotApiService()], + [IAgentHostAuthenticationService, disposables.add(new FakeAgentHostAuthenticationService())], + [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], [IAgentPluginManager, new FakeAgentPluginManager()], [ISessionDataService, sessionData], ); @@ -8039,6 +8088,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { const logService = new NullLogService(); const stateManager = disposables.add(new AgentHostStateManager(logService)); const configService = disposables.add(new AgentConfigurationService(stateManager, logService)); + const authenticationService = disposables.add(new FakeAgentHostAuthenticationService()); const resolveReducerEnablement = (session: string, target: { readonly id: string }) => { const findCustomization = (customizations: readonly (Customization | ChildCustomization)[]): PluginCustomization | McpServerCustomization | undefined => { for (const customization of customizations) { @@ -8103,9 +8153,11 @@ suite('ClaudeAgent — Phase 11 customizations', () => { } satisfies ICustomizationEnablementService], [IProductService, FakeProductService], [IAgentHostGitHubEndpointService, createTestGitHubEndpointService()], + [IAgentHostAuthenticationService, authenticationService], ); const instantiationService: IInstantiationService = disposables.add(new InstantiationService(services)); const agent = disposables.add(instantiationService.createInstance(ClaudeAgent)); + connectAuthentication(agent, authenticationService); const chats = agent.chats as { sendMessage: typeof agent.chats.sendMessage }; const sendMessage = chats.sendMessage.bind(agent.chats); chats.sendMessage = (chat, prompt, workingDirectoriesOrDirectory, attachments, turnId, senderClientId, clientTypeOrContext, context) => { @@ -8171,6 +8223,89 @@ suite('ClaudeAgent — Phase 11 customizations', () => { assert.deepStrictEqual(pm.syncCalls, []); }); + test('GitHub MCP is enabled by default and respects customization disablement', async () => { + const pm = new FakeAgentPluginManager(); + const { agent, sdk, stateManager } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const enabled = await createSession(agent, { workingDirectories: [URI.file('/enabled')] }); + sdk.supportedAgentsResult = []; + sdk.mcpServerStatusResult = []; + sdk.nextQueryMessages = [makeSystemInitMessage(enabled.sdkSessionId), makeResultSuccess(enabled.sdkSessionId)]; + await agent.chats.sendMessage(defaultChatUri(enabled.session), 'first', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(enabled.session))); + + const disabled = await createSession(agent, { workingDirectories: [URI.file('/disabled')] }); + const customization = createClaudeInternalMcpServerCustomization('github-mcp-server'); + publishReducerCustomizations(stateManager, disabled.session, [customization]); + stateManager.dispatchServerAction(disabled.session.toString(), { + type: ActionType.SessionCustomizationToggled, + id: customization.id, + enablement: [{ kind: CustomizationEnablementKind.Session, enabled: false }], + }); + sdk.nextQueryMessages = [makeSystemInitMessage(disabled.sdkSessionId), makeResultSuccess(disabled.sdkSessionId)]; + await agent.chats.sendMessage(defaultChatUri(disabled.session), 'first', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(disabled.session))); + + const enabledOptions = sdk.capturedStartupOptions[0]; + const disabledOptions = sdk.capturedStartupOptions[1]; + const enabledServer = enabledOptions.mcpServers?.['github-mcp-server']; + const enabledRemoteServer = enabledServer?.type === 'http' || enabledServer?.type === 'sse' ? enabledServer : undefined; + assert.deepStrictEqual({ + enabled: enabledServer ? { + type: enabledServer.type, + url: enabledRemoteServer?.url, + features: enabledRemoteServer?.headers?.['X-MCP-Features'], + authorization: enabledRemoteServer?.headers?.Authorization, + webSearchEnabled: enabledRemoteServer?.headers?.['X-MCP-Tools']?.split(',').includes('web_search'), + } : undefined, + disabled: disabledOptions.mcpServers?.['github-mcp-server'], + denied: typeof disabledOptions.settings === 'string' ? undefined : disabledOptions.settings?.deniedMcpServers, + }, { + enabled: { + type: 'http', + url: 'https://api.githubcopilot.com/mcp', + features: 'remote_mcp_ui_apps,mcp_apps_disable_form_deferral', + authorization: undefined, + webSearchEnabled: true, + }, + disabled: undefined, + denied: [{ serverName: 'github-mcp-server' }], + }); + }); + + test('GitHub MCP root setting disables server injection', async () => { + const pm = new FakeAgentPluginManager(); + const { agent, sdk, configService } = buildCtxWith(pm); + configService.updateRootConfig({ [AgentHostGitHubMcpServerEnabledConfigKey]: false }); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const created = await createSession(agent, { workingDirectories: [URI.file('/work')] }); + sdk.supportedAgentsResult = []; + sdk.mcpServerStatusResult = []; + sdk.nextQueryMessages = [makeSystemInitMessage(created.sdkSessionId), makeResultSuccess(created.sdkSessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(created.session))); + + assert.strictEqual(sdk.capturedStartupOptions[0].mcpServers?.['github-mcp-server'], undefined); + }); + + test('GitHub MCP injection deduplicates an existing server by endpoint URI', async () => { + const pm = new FakeAgentPluginManager(); + const { agent, sdk, fileService } = buildCtxWith(pm); + await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'tok'); + const workspace = URI.file('/work'); + await fileService.createFolder(workspace); + await fileService.writeFile( + URI.joinPath(workspace, '.mcp.json'), + VSBuffer.fromString(JSON.stringify({ + existingGitHub: { type: 'http', url: 'https://api.githubcopilot.com/mcp' }, + })), + ); + const created = await createSession(agent, { workingDirectories: [workspace] }); + sdk.supportedAgentsResult = []; + sdk.mcpServerStatusResult = []; + sdk.nextQueryMessages = [makeSystemInitMessage(created.sdkSessionId), makeResultSuccess(created.sdkSessionId)]; + await agent.chats.sendMessage(defaultChatUri(created.session), 'first', undefined, undefined, 'turn-1', undefined, undefined, chatContext(defaultChatUri(created.session))); + + assert.strictEqual(sdk.capturedStartupOptions[0].mcpServers?.['github-mcp-server'], undefined); + }); + test('disabled bundled MCP children are excluded from initial SDK startup', async () => { const pm = new FakeAgentPluginManager(); const { agent, sdk, fileService, stateManager } = buildCtxWith(pm); @@ -8232,7 +8367,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { explicitServers: Object.keys(startupOptions.mcpServers ?? {}).sort(), deniedServers: typeof startupOptions.settings === 'string' ? undefined : startupOptions.settings?.deniedMcpServers, }, { - explicitServers: ['enabled'], + explicitServers: ['enabled', 'github-mcp-server'], deniedServers: [{ serverName: 'disabled', }], @@ -8287,7 +8422,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { explicitServers: Object.keys(options.mcpServers ?? {}).sort(), deniedServers: settings.deniedMcpServers, }, { - explicitServers: ['additional-enabled'], + explicitServers: ['additional-enabled', 'github-mcp-server'], deniedServers: [{ serverName: 'primary-disabled', }], @@ -8312,7 +8447,7 @@ suite('ClaudeAgent — Phase 11 customizations', () => { explicitServers: Object.keys(rebuiltOptions.mcpServers ?? {}).sort(), deniedServers: typeof rebuiltOptions.settings === 'string' ? undefined : rebuiltOptions.settings?.deniedMcpServers, }, { - explicitServers: ['additional-disabled', 'additional-enabled'], + explicitServers: ['additional-disabled', 'additional-enabled', 'github-mcp-server'], deniedServers: undefined, }); }); diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index 43a64b42691359..9c06122a80968d 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -17,9 +17,10 @@ import { AgentHostStateManager } from '../../../node/agentHostStateManager.js'; import { getCustomizationEnablementKey, type CustomizationEnablementResolution, type ICustomizationEnablementTarget } from '../../../node/agentHostCustomizationEnablementService.js'; import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { CodexClientCustomizationStore, type ICodexClientPlugin } from '../../../node/codex/codexClientCustomizations.js'; -import type { ICodexMcpServerEntry } from '../../../node/codex/codexMcpServers.js'; +import type { ICodexMcpServerConfigJson, ICodexMcpServerEntry } from '../../../node/codex/codexMcpServers.js'; import { targetForMcpServer } from '../../../node/shared/customizationEnablementGate.js'; import { McpCustomizationController, type IMcpCustomizationControllerOptions } from '../../../node/shared/mcpCustomizationController.js'; +import { createGitHubMcpServerConfiguration, getGitHubMcpTools } from '../../../node/shared/githubMcpServer.js'; /** * Exactly the state `_resolveConversationSession` reads: the provider id it @@ -63,6 +64,21 @@ interface ICodexMcpRequestHarness { }; } +interface ICodexGitHubMcpHarness { + _buildSessionMcpServers(session: { + readonly sessionId: string; + readonly workingDirectory: URI; + }): Record; +} + +interface ICodexGitHubEndpointChangeHarness { + _handleGitHubEndpointChange(): void; +} + +interface ICodexAuthenticateHarness { + authenticate(resource: string, token: string): Promise; +} + function resolveConversationSession(harness: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined { const resolver = (CodexAgent.prototype as unknown as { _resolveConversationSession(this: ICodexConversationResolverHarness, address: URI, context?: URI | IAgentChatContext): URI | undefined; @@ -92,6 +108,123 @@ suite('CodexAgent', () => { ensureNoDisposablesAreLeakedInTestSuite(); + test('GitHub MCP injection respects unowned server enablement', () => { + const createHarness = (enabled: boolean, customizationEnabled: boolean, token: string | undefined): ICodexGitHubMcpHarness => Object.assign(Object.create(CodexAgent.prototype), { + _configurationService: { getRootValue: () => undefined }, + _sessionMcpDiscoveries: new Map(), + _enabledClientPlugins: () => [], + _mcpAuthTokens: new Map(), + _githubMcpServerEnabled: enabled, + _githubToken: token, + _gitHubMcpServerConfiguration: token ? createGitHubMcpServerConfiguration('https://api.githubcopilot.com') : undefined, + _isMcpServerEnabledForSdk: (_session: unknown, name: string) => name !== 'github-mcp-server' || customizationEnabled, + }); + + const enabledServers = createHarness(true, true, 'token')._buildSessionMcpServers({ sessionId: 'enabled', workingDirectory: URI.file('/work') }); + const customizationDisabledServers = createHarness(true, false, 'token')._buildSessionMcpServers({ sessionId: 'customization-disabled', workingDirectory: URI.file('/work') }); + const settingDisabledServers = createHarness(false, true, 'token')._buildSessionMcpServers({ sessionId: 'setting-disabled', workingDirectory: URI.file('/work') }); + const unauthenticatedServers = createHarness(true, true, undefined)._buildSessionMcpServers({ sessionId: 'unauthenticated', workingDirectory: URI.file('/work') }); + + assert.deepStrictEqual({ + enabled: enabledServers['github-mcp-server'], + customizationDisabled: customizationDisabledServers['github-mcp-server'], + settingDisabled: settingDisabledServers['github-mcp-server'], + unauthenticated: unauthenticatedServers['github-mcp-server'], + }, { + enabled: { + url: 'https://api.githubcopilot.com/mcp', + http_headers: { + 'X-MCP-Features': 'remote_mcp_ui_apps,mcp_apps_disable_form_deferral', + 'X-MCP-Tools': getGitHubMcpTools(false).join(','), + }, + }, + customizationDisabled: undefined, + settingDisabled: undefined, + unauthenticated: undefined, + }); + + const aliasedServers = Object.assign(Object.create(CodexAgent.prototype), { + _configurationService: { getRootValue: () => ({ alias: { type: 'http', url: 'https://api.githubcopilot.com/mcp/' } }) }, + _sessionMcpDiscoveries: new Map(), + _enabledClientPlugins: () => [], + _mcpAuthTokens: new Map(), + _githubMcpServerEnabled: true, + _githubToken: 'token', + _gitHubMcpServerConfiguration: createGitHubMcpServerConfiguration('https://api.githubcopilot.com'), + _isMcpServerEnabledForSdk: () => true, + }) as ICodexGitHubMcpHarness; + assert.deepStrictEqual(aliasedServers._buildSessionMcpServers({ sessionId: 'alias', workingDirectory: URI.file('/work') }), { + alias: { url: 'https://api.githubcopilot.com/mcp/' }, + }); + }); + + test('clears GitHub MCP credentials when the GitHub endpoint changes', () => { + const proxyTokens: string[] = []; + let modelRefreshes = 0; + let reconciliations = 0; + const harness = Object.assign(Object.create(CodexAgent.prototype), { + _githubToken: 'token', + _gitHubMcpServerConfiguration: createGitHubMcpServerConfiguration('https://api.enterprise.githubcopilot.com'), + _connection: { kind: 'ready', proxyHandle: { setToken: (token: string) => proxyTokens.push(token) } }, + _queueModelRefresh: () => { modelRefreshes++; }, + _sessions: new Map([['session', {}]]), + _reconcileMaterializedCustomizations: async () => { reconciliations++; }, + }) as ICodexGitHubEndpointChangeHarness & { _githubToken?: string; _gitHubMcpServerConfiguration?: object }; + + harness._handleGitHubEndpointChange(); + + assert.deepStrictEqual({ + token: harness._githubToken, + configuration: harness._gitHubMcpServerConfiguration, + proxyTokens, + modelRefreshes, + reconciliations, + }, { + token: undefined, + configuration: undefined, + proxyTokens: [''], + modelRefreshes: 1, + reconciliations: 1, + }); + }); + + test('does not commit stale GitHub authentication after an endpoint change', async () => { + const resolution = new DeferredPromise>(); + const proxyTokens: string[] = []; + let reconciliations = 0; + const copilotResource = { resource: 'https://api.github.com/copilot_internal/user' }; + const harness = Object.assign(Object.create(CodexAgent.prototype), { + _gitHubEndpointService: { getCopilotResource: () => copilotResource, getRepoResource: () => ({ resource: 'https://api.github.com' }) }, + _githubAuthenticationGeneration: 0, + _githubToken: undefined, + _gitHubMcpServerConfiguration: undefined, + _resolveGitHubMcpServerConfiguration: async () => resolution.p, + _connection: { kind: 'ready', proxyHandle: { setToken: (token: string) => proxyTokens.push(token) } }, + _queueModelRefresh: () => { }, + _sessions: new Map([['session', {}]]), + _reconcileMaterializedCustomizations: async () => { reconciliations++; }, + _logService: new NullLogService(), + _refreshProviderConfiguration: async () => { }, + }) as ICodexAuthenticateHarness & ICodexGitHubEndpointChangeHarness & { _githubToken?: string; _gitHubMcpServerConfiguration?: object }; + + const authenticating = harness.authenticate(copilotResource.resource, 'old-token'); + harness._handleGitHubEndpointChange(); + resolution.complete(createGitHubMcpServerConfiguration('https://api.enterprise.githubcopilot.com')); + await authenticating; + + assert.deepStrictEqual({ + token: harness._githubToken, + configuration: harness._gitHubMcpServerConfiguration, + proxyTokens, + reconciliations, + }, { + token: undefined, + configuration: undefined, + proxyTokens: [''], + reconciliations: 1, + }); + }); + test('prefers transient host context over conversation URI shape', () => { const session = AgentSession.uri('codex', 'session-1'); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts index 23d16825d9b18d..c4e6a27014573e 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionConfigKeys.test.ts @@ -25,6 +25,8 @@ import { SessionConfigKey } from '../../../common/sessionConfigKeys.js'; import { IAgentHostOTelService } from '../../../common/otel/agentHostOTelService.js'; import { IAgentHostSessionTitleSignal } from '../../../node/agentHostSessionTitleSignal.js'; import { createNoopCustomizationEnablementService } from '../testCustomizationEnablementService.js'; +import { IAgentHostGitHubEndpointService } from '../../../node/agentHostGitHubEndpointService.js'; +import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; function createAgent(disposables: Pick): CodexAgent { const instantiationService = new TestInstantiationService(); @@ -42,6 +44,7 @@ function createAgent(disposables: Pick): CodexAgent { instantiationService.stub(IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE); instantiationService.stub(IAgentHostOTelService, { _serviceBrand: undefined, getNativeSdkTelemetryConfig: async () => undefined }); instantiationService.stub(IAgentHostSessionTitleSignal, { _serviceBrand: undefined, onDidChangeSessionTitle: Event.None }); + instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); instantiationService.stub(ILogService, logService); diff --git a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts index 88af923ef52a7b..88b2996b9ad51a 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexSessionTitleSpans.test.ts @@ -27,6 +27,8 @@ import { CodexAgent } from '../../../node/codex/codexAgent.js'; import { ICodexProxyService } from '../../../node/codex/codexProxyService.js'; import { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; import { createNoopCustomizationEnablementService } from '../testCustomizationEnablementService.js'; +import { IAgentHostGitHubEndpointService } from '../../../node/agentHostGitHubEndpointService.js'; +import { createTestGitHubEndpointService } from '../testGitHubEndpointService.js'; /** * Records `emitSessionTitleChanged` invocations so the OTel title-span wiring @@ -73,6 +75,7 @@ function createTestContext(disposables: Pick): { stateMa instantiationService.stub(IAgentHostCheckpointService, NULL_CHECKPOINT_SERVICE); instantiationService.stub(IAgentHostOTelService, otelService); instantiationService.stub(IAgentHostSessionTitleSignal, disposables.add(new AgentHostSessionTitleSignal(stateManager))); + instantiationService.stub(IAgentHostGitHubEndpointService, createTestGitHubEndpointService()); instantiationService.stub(IProductService, { _serviceBrand: undefined, version: '1.0.0-test' } as IProductService); instantiationService.stub(INativeEnvironmentService, { userHome: URI.file('/tmp') }); instantiationService.stub(ILogService, logService); diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index dbc8dae9cf4dd4..6a86a310ca9807 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -36,7 +36,7 @@ import { NullTelemetryService, NullTelemetryServiceShape } from '../../../teleme import { AgentHostTelemetryService } from '../../node/agentHostTelemetryService.js'; import { CopilotCliConfigKey, CopilotCliVSCodeAssignmentContextKey } from '../../common/copilotCliConfig.js'; import { AgentHostConfigKey } from '../../common/agentHostCustomizationConfig.js'; -import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; +import { AgentHostAutoApprovePolicyRestrictedConfigKey, AgentHostByokModelsEnabledConfigKey, AgentHostGitHubMcpServerEnabledConfigKey, AgentHostCopilotMultiRootEnabledConfigKey, AgentHostMigrateLegacyCopilotCliEnabledConfigKey, AgentHostProxyConfigKey, AgentHostSystemProxyEnabledConfigKey } from '../../common/agentHostSchema.js'; import { IAgentPluginManager, ISyncedCustomization } from '../../common/agentPluginManager.js'; import { getTelemetryChatSessionId } from '../../common/agentTelemetryCorrelation.js'; import { AgentSession, GITHUB_COPILOT_PROTECTED_RESOURCE, type AgentSignal, type IAgentChatContext, type IAgentChatMetadata, type IAgentCreateChatForkSource, type IAgentCreateChatOptions, type IAgentCreateChatResult, type IAgentCreateSessionConfig, type IAgentDiscoveredChat, type IAgentMaterializeChatEvent, type IAgentSpawnChatEvent } from '../../common/agent.js'; @@ -58,6 +58,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js'; import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js'; import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotManagedSettingsDiagnostics, rebaseUnder, REFRESH_DEBOUNCE_MS, resolveCopilotOtlpMetricsEndpoint } from '../../node/copilot/copilotAgent.js'; +import { GITHUB_MCP_SERVER_NAME } from '../../node/shared/githubMcpServer.js'; import { COPILOT_AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/copilot/prompts/systemMessage.js'; import { COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION } from '../../node/copilot/prompts/toolInstructions.js'; import { NULL_CHECKPOINT_SERVICE } from '../../common/agentHostCheckpointService.js'; @@ -67,6 +68,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { join } from '../../../../base/common/path.js'; import { IAgentHostGitHubEndpointService } from '../../node/agentHostGitHubEndpointService.js'; import { createTestGitHubEndpointService } from './testGitHubEndpointService.js'; +import { createNoopCustomizationEnablementService } from './testCustomizationEnablementService.js'; import { CopilotAgentSession } from '../../node/copilot/copilotAgentSession.js'; import { AgentBranchNameGenerator, getAgentBranchNameHintFromMessage, normalizeAgentBranchName } from '../../node/shared/agentBranchNameGenerator.js'; import type { CopilotSessionLaunchPlan, IActiveClientSnapshot } from '../../node/copilot/copilotSessionLauncher.js'; @@ -866,7 +868,7 @@ function getCreatedClientOptions(agent: CopilotAgent): readonly CopilotClientOpt return agent.createdClientOptions; } -function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService; rootConfig?: Record; now?: () => number }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { +function createTestAgentContext(disposables: Pick, options?: { sessionDataService?: ISessionDataService; copilotClient?: ITestCopilotClient; useRealResumePath?: boolean; gitService?: TestAgentHostGitService; environmentServiceRegistration?: 'native' | 'none'; pluginManager?: IAgentPluginManager; fileService?: FileService; copilotApiService?: ICopilotApiService; gitHubEndpointService?: IAgentHostGitHubEndpointService; telemetryService?: ITelemetryService; userHome?: URI; logService?: ILogService; proxyResolver?: IAgentHostProxyResolver; byokBridgeRegistry?: IByokLmBridgeRegistry; otelService?: IAgentHostOTelService; customizationEnablementService?: ICustomizationEnablementService; rootConfig?: Record; now?: () => number }): { agent: CopilotAgent; instantiationService: IInstantiationService; configurationService: IAgentConfigurationService; managedSettingsService: IAgentHostManagedSettingsService; fileService: FileService; stateManager: AgentHostStateManager } { const services = new ServiceCollection(); const logService = options?.logService ?? new NullLogService(); const fileService = options?.fileService ?? disposables.add(new FileService(logService)); @@ -908,17 +910,7 @@ function createTestAgentContext(disposables: Pick, optio }); services.set(IAgentHostCompletions, disposables.add(new AgentHostCompletions(logService))); services.set(IAgentHostProxyResolver, options?.proxyResolver ?? new TestProxyResolver()); - services.set(IAgentHostCustomizationEnablementService, { - _serviceBrand: undefined, - onDidChange: Event.None, - initializeSession: async () => { }, - getWorkingDirectoryState: () => ({ kind: 'pending' }), - resolve: () => ({ kind: 'pending', reason: 'session' }), - applyClientGlobalEnablement: () => ({ kind: 'pending', reason: 'session' }), - replaceEnablement: () => ({ kind: 'pending', reason: 'session' }), - setEnablement: () => ({ kind: 'pending', reason: 'session' }), - whenIdle: async () => { }, - } satisfies ICustomizationEnablementService); + services.set(IAgentHostCustomizationEnablementService, options?.customizationEnablementService ?? createNoopCustomizationEnablementService()); services.set(IByokLmBridgeRegistry, options?.byokBridgeRegistry ?? new ByokLmBridgeRegistry()); const copilotApiService = options?.copilotApiService ?? new TestCopilotApiService(); services.set(ICopilotApiService, copilotApiService); @@ -1049,6 +1041,36 @@ suite('CopilotAgent', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('initializes enablement before disabling the built-in GitHub MCP server at launch', async () => { + let initializedSession: string | undefined; + const disabledRootMcpServers = (CopilotAgent.prototype as unknown as { + _disabledRootMcpServers(this: { + readonly id: string; + _isGitHubMcpServerEnabled(): boolean; + readonly _customizationEnablementService: { + initializeSession(session: string): Promise; + resolve(session: string, target: { readonly name: string }): { kind: 'resolved'; enablement: readonly [{ kind: CustomizationEnablementKind.Session; enabled: boolean }]; enabled: boolean; workingDirectory: { kind: 'workspaceless' } }; + }; + }, session: URI, sessionId: string, snapshot: { readonly mcpServers: Record }): Promise; + })._disabledRootMcpServers; + const result = await disabledRootMcpServers.call({ + id: 'copilotcli', + _isGitHubMcpServerEnabled: () => true, + _customizationEnablementService: { + initializeSession: async session => { initializedSession = session; }, + resolve: (_session, target) => { + const enabled = target.name !== GITHUB_MCP_SERVER_NAME; + return { kind: 'resolved', enablement: [{ kind: CustomizationEnablementKind.Session, enabled }], enabled, workingDirectory: { kind: 'workspaceless' } }; + }, + }, + }, AgentSession.uri('copilotcli', 'session'), 'sdk-session', { mcpServers: {} }); + + assert.deepStrictEqual({ initializedSession, result }, { + initializedSession: AgentSession.uri('copilotcli', 'session').toString(), + result: [GITHUB_MCP_SERVER_NAME], + }); + }); + test('selects provider-native autonomous session config and respects policy', async () => { const { agent, configurationService } = createTestAgentContext(disposables); try { @@ -3676,6 +3698,62 @@ suite('CopilotAgent', () => { } }); + test('enables the built-in GitHub MCP server by default and removes its environment variable when disabled', async () => { + const enabledClient = new TestCopilotClient([]); + const { agent: enabledAgent } = createTestAgentContext(disposables, { copilotClient: enabledClient }); + const previousEnvValue = process.env['COPILOT_ENABLE_BUILTIN_GITHUB_MCP']; + try { + await enabledAgent.listChatsToMigrate(); + process.env['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'] = 'true'; + + const disabledClient = new TestCopilotClient([]); + const { agent: disabledAgent } = createTestAgentContext(disposables, { + copilotClient: disabledClient, + rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, + }); + try { + await disabledAgent.listChatsToMigrate(); + assert.deepStrictEqual([ + getCreatedClientOptions(enabledAgent).at(-1)?.env?.['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'], + getCreatedClientOptions(disabledAgent).at(-1)?.env?.['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'], + ], ['true', undefined]); + } finally { + await disposeAgent(disabledAgent); + } + } finally { + if (previousEnvValue === undefined) { + delete process.env['COPILOT_ENABLE_BUILTIN_GITHUB_MCP']; + } else { + process.env['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'] = previousEnvValue; + } + await disposeAgent(enabledAgent); + } + }); + + test('restarts the client when built-in GitHub MCP support is enabled', async () => { + const client = new StopCountingClient([]); + const { agent, configurationService } = createTestAgentContext(disposables, { + copilotClient: client, + rootConfig: { [AgentHostGitHubMcpServerEnabledConfigKey]: false }, + }); + try { + await agent.listChatsToMigrate(); + configurationService.updateRootConfig({ [AgentHostGitHubMcpServerEnabledConfigKey]: true }); + await Promise.resolve(); + await agent.listChatsToMigrate(); + + assert.deepStrictEqual({ + stopCount: client.stopCount, + env: getCreatedClientOptions(agent).at(-1)?.env?.['COPILOT_ENABLE_BUILTIN_GITHUB_MCP'], + }, { + stopCount: 1, + env: 'true', + }); + } finally { + await disposeAgent(agent); + } + }); + test('restarts the client when the Copilot SDK log level changes', async () => { const client = new StopCountingClient([]); const { agent, configurationService } = createTestAgentContext(disposables, { copilotClient: client }); @@ -6060,7 +6138,18 @@ suite('CopilotAgent', () => { } } - const { agent, stateManager } = createTestAgentContext(disposables, { pluginManager: new PassthroughPluginManager() }); + const pendingEnablementService: ICustomizationEnablementService = { + _serviceBrand: undefined, + onDidChange: Event.None, + initializeSession: async () => { }, + getWorkingDirectoryState: () => ({ kind: 'pending' }), + resolve: () => ({ kind: 'pending', reason: 'session' }), + applyClientGlobalEnablement: () => ({ kind: 'pending', reason: 'session' }), + replaceEnablement: () => ({ kind: 'pending', reason: 'session' }), + setEnablement: () => ({ kind: 'pending', reason: 'session' }), + whenIdle: async () => { }, + }; + const { agent, stateManager } = createTestAgentContext(disposables, { pluginManager: new PassthroughPluginManager(), customizationEnablementService: pendingEnablementService }); try { const firstSession = AgentSession.uri('copilotcli', 'first-enable-state'); const secondSession = AgentSession.uri('copilotcli', 'second-enable-state'); diff --git a/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts b/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts new file mode 100644 index 00000000000000..a217459a26b06b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/copilotAgentStartupConfig.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { CopilotAgentStartupConfig } from '../../node/copilot/copilotAgentStartupConfig.js'; + +suite('CopilotAgentStartupConfig', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('compares and describes startup configuration changes', () => { + const previous = new CopilotAgentStartupConfig(false, true, 'info', undefined, true, true, {}); + const same = new CopilotAgentStartupConfig(false, true, 'info', undefined, true, true, {}); + const changed = new CopilotAgentStartupConfig(true, true, 'trace', 'github.example.com', false, false, { deny: ['shell(*)'] }); + + assert.deepStrictEqual({ + same: same.equals(previous), + changed: changed.equals(previous), + proxyTargetChanged: changed.proxyTargetChangedFrom(previous), + description: changed.describeChangesFrom(previous), + }, { + same: true, + changed: false, + proxyTargetChanged: true, + description: 'sessionSync=true, copilotSdkLogLevel=trace, enterpriseHost=github.example.com, systemProxy=false, githubMcpServer=false, managedSettingsPermissions', + }); + }); +}); diff --git a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md index 7d2c6dbf5e55f1..e77b44f4a90157 100644 --- a/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md +++ b/src/vs/platform/agentHost/test/node/e2e/KNOWN_ISSUES.md @@ -878,3 +878,21 @@ The entire Claude or Codex suite also skips when its bundled SDK package is unav 2. Reevaluate broad provider gates one title at a time and check whether a capture exists. 3. Re-record narrowly after SDK/CLI behavior changes and review every generated artifact. 4. Remove fixed gates, entries, comments, and orphaned captures together. +### Codex provider context restoration can time out + +A user can restart Agent Host and continue an existing Codex conversation. The restored turn can start +without ever completing, so the user cannot continue the conversation after the host restart. This has +reproduced in replay on Windows and Linux. + +- Test: `session metadata history and provider context survive a host restart` +- Scope: Codex. +- Expected: the restored session retains its transcript and provider context, and a follow-up turn completes. +- Observed: the follow-up emits `chat/turnStarted` but no completion before the 90-second timeout. +- Gate: skipped for Codex unless `AGENT_HOST_RUN_KNOWN_ISSUES=1`. +- Reproduce: + + ```bash + AGENT_HOST_RUN_KNOWN_ISSUES=1 ./scripts/test-integration.sh --run \ + src/vs/platform/agentHost/test/node/e2e/providers/codexAgentHostE2E.integrationTest.ts \ + --grep "session metadata history and provider context survive" + ``` diff --git a/src/vs/platform/agentHost/test/node/e2e/README.md b/src/vs/platform/agentHost/test/node/e2e/README.md index 7e52b0accac5c8..55a0d97ad22eb7 100644 --- a/src/vs/platform/agentHost/test/node/e2e/README.md +++ b/src/vs/platform/agentHost/test/node/e2e/README.md @@ -445,6 +445,7 @@ File-operation capability and coverage are separate concerns. A provider with no - `POST /models/session`, `POST /models/session/intent` — auto-mode selection. Deliberately answered with a `500 + x-should-retry:false` so the SDK falls back to the configured model (auto-mode isn't wanted in replay). Not counted as a cache miss. - `/copilot_internal/*token*`, `/copilot_internal/*user*` — fake token + generic user/identity. - `GET /copilot/mcp_registry` — enterprise MCP registry policy. The Copilot CLI fetches this only when the developer has local MCP servers configured (`~/.copilot/mcp-config.json`) on an org/enterprise plan, so whether it's called varies per machine. Served as an empty registry (`{ mcp_registries: [] }`) so a developer's local MCP config never breaks replay (issue #325248). +- `POST /mcp`, `POST /mcp/readonly`, and the subsequent GitHub MCP OAuth metadata probes — built-in GitHub MCP bootstrap. These suites do not exercise GitHub MCP tools, so replay returns `404` instead of recording ancillary traffic or changing the fixture's model-visible tool inventory. - `/telemetry`, `/agents*` — empty bodies. Everything else — i.e. the model endpoints `/v1/messages` and `/responses` — is recorded/replayed as turns. diff --git a/src/vs/platform/agentHost/test/node/e2e/harness/capiStubs.ts b/src/vs/platform/agentHost/test/node/e2e/harness/capiStubs.ts index bfb8b08c511fa8..12f55228af495e 100644 --- a/src/vs/platform/agentHost/test/node/e2e/harness/capiStubs.ts +++ b/src/vs/platform/agentHost/test/node/e2e/harness/capiStubs.ts @@ -177,6 +177,19 @@ export function getAncillaryStub(method: string, path: string, body?: string): I if (path === '/copilot/mcp_registry' && method === 'GET') { return { status: 200, headers: JSON_HEADERS, body: JSON.stringify({ mcp_registries: [] }) }; } + // The built-in GitHub MCP server shares the CAPI origin and starts alongside + // each provider. These E2E scenarios do not exercise its tools, so keep it + // unavailable in replay instead of recording unrelated MCP bootstrap traffic + // or changing the model-visible tool inventory. + if ((path === '/mcp' || path === '/mcp/readonly') && method === 'POST') { + return { status: 404, headers: { 'content-type': 'text/plain', 'x-should-retry': 'false' }, body: 'GitHub MCP is not available in replay' }; + } + // Codex follows an unavailable MCP response with standard OAuth protected + // resource and authorization-server discovery. Keep those probes ancillary + // and unavailable as well; they do not participate in model replay. + if (method === 'GET' && (path === '/mcp' || path.startsWith('/.well-known/') || path.includes('/.well-known/'))) { + return { status: 404, headers: { 'content-type': 'text/plain' }, body: 'OAuth metadata is not available in replay' }; + } if (path.startsWith('/copilot_internal/')) { if (path.includes('/token') || path.includes('/nltoken')) { return { status: 200, headers: JSON_HEADERS, body: tokenStubBody() }; diff --git a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts index 2425ce25dd8d02..6d6dc47c2cb152 100644 --- a/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts +++ b/src/vs/platform/agentHost/test/node/e2e/suites/sessionPersistenceSuite.ts @@ -16,6 +16,7 @@ import { PROTOCOL_VERSION } from '../../../../common/state/protocol/version/regi import { createRealSession, driveTurnToCompletion, resolveGitHubToken } from '../harness/agentHostE2ETestHarness.js'; import { fetchSessionWithChat, getActionEnvelope, isActionNotification } from '../../serverIntegrationTestHelpers.js'; import type { IAgentHostE2ETestContext } from './e2eTestContext.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../../common/agent.js'; const RECORDING = process.env['AGENT_HOST_REPLAY_RECORD'] === '1' || process.env['AGENT_HOST_UPDATE_SNAPSHOTS'] === '1'; @@ -31,7 +32,7 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) await context.client.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId }, 30_000); await context.client.call('authenticate', { channel: ROOT_STATE_URI, - resource: 'https://api.github.com', + resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: config.githubToken ?? resolveGitHubToken(), }, 30_000); } @@ -92,7 +93,9 @@ export function defineSessionPersistenceTests(context: IAgentHostE2ETestContext) }, 50, 20); } - test('session metadata history and provider context survive a host restart', async function () { + // Codex starts the restored follow-up but intermittently never completes it across replay platforms. + const sessionPersistenceEnabled = config.provider !== 'codex' || context.runKnownIssueTests; + (sessionPersistenceEnabled ? test : test.skip)('session metadata history and provider context survive a host restart', async function () { this.timeout(240_000); const workspace = fs.mkdtempSync(`${tmpdir()}/ahp-persistence-`); tempDirs.push(workspace); diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts index f059dd51b3e764..e779a8f7217af2 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/codexCustomizations.integrationTest.ts @@ -14,8 +14,9 @@ import { tmpdir } from 'os'; import { join } from '../../../../../base/common/path.js'; import { URI } from '../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../base/common/uuid.js'; -import { ActionType } from '../../../common/state/sessionActions.js'; +import { ActionType, type RootAgentsChangedAction } from '../../../common/state/sessionActions.js'; import { AgentHostCodexEnabledConfigKey } from '../../../common/agentHostSchema.js'; +import { GITHUB_COPILOT_PROTECTED_RESOURCE } from '../../../common/agent.js'; import { PROTOCOL_VERSION } from '../../../common/state/protocol/version/registry.js'; import { type SubscribeResult } from '../../../common/state/protocol/commands.js'; import { buildDefaultChatUri, customizationId, CustomizationType, MessageKind, ROOT_STATE_URI, type ClientPluginCustomization, type DirectoryCustomization, type McpServerCustomization, type PluginCustomization, type URI as ProtocolURI } from '../../../common/state/sessionState.js'; @@ -357,7 +358,6 @@ suite('Agent Host Provider Integration — Codex Customizations', function () { try { await runtimeClient.connect(); await runtimeClient.call('initialize', { channel: ROOT_STATE_URI, protocolVersions: [PROTOCOL_VERSION], clientId: 'codex-runtime-enablement-client' }, 30_000); - await runtimeClient.call('authenticate', { channel: ROOT_STATE_URI, resource: 'https://api.github.com', token: 'not-a-real-token' }, 30_000); await runtimeClient.call('subscribe', { channel: ROOT_STATE_URI }); runtimeClient.clearReceived(); runtimeClient.dispatch({ @@ -370,6 +370,12 @@ suite('Agent Host Provider Integration — Codex Customizations', function () { && (getActionEnvelope(notification).action as { readonly config?: Readonly> }).config?.[AgentHostCodexEnabledConfigKey] === true, 30_000, ); + await runtimeClient.waitForNotification(notification => + isActionNotification(notification, ActionType.RootAgentsChanged) + && (getActionEnvelope(notification).action as RootAgentsChangedAction).agents.some(agent => agent.provider === 'codex'), + 30_000, + ); + await runtimeClient.call('authenticate', { channel: ROOT_STATE_URI, resource: GITHUB_COPILOT_PROTECTED_RESOURCE.resource, token: 'not-a-real-token' }, 30_000); const sessionUri = URI.from({ scheme: 'codex', path: `/${generateUuid()}` }).toString(); await runtimeClient.call('createSession', { diff --git a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts index b739be4a8a61a4..a839b45b8667e6 100644 --- a/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts +++ b/src/vs/platform/agentHost/test/node/providerIntegration/copilotCustomizations.integrationTest.ts @@ -354,7 +354,13 @@ suite('Agent Host Provider Integration — Copilot Customizations', function () // Filter out skills shipped inside the Copilot CLI package (node_modules/@github/copilot-/builtin/), // e.g. `customize-cloud-agent` and `github-pr-media`. These vary with the bundled CLI version and are not part of // the workspace/user customizations under test. - return !(customization.type === CustomizationType.Directory && customization.contents === CustomizationType.Skill && /\/builtin\/[^/]+$/.test(customization.uri)); + const isBuiltInSkill = customization.type === CustomizationType.Directory + && customization.contents === CustomizationType.Skill + && /\/builtin\/[^/]+$/.test(customization.uri); + const isBuiltInGitHubMcpServer = customization.type === CustomizationType.McpServer + && customization.uri.startsWith('mcp-top-level:copilotcli:') + && customization.uri.endsWith(':github-mcp-server'); + return !isBuiltInSkill && !isBuiltInGitHubMcpServer; }; async function runEmptyWorkspaceCustomizationsTest(discoveryMode: SessionCustomizationDiscoveryMode): Promise { diff --git a/src/vs/platform/agentHost/test/node/shared/githubMcpServer.test.ts b/src/vs/platform/agentHost/test/node/shared/githubMcpServer.test.ts new file mode 100644 index 00000000000000..9dc519b963d37b --- /dev/null +++ b/src/vs/platform/agentHost/test/node/shared/githubMcpServer.test.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; +import { McpServerType } from '../../../../mcp/common/mcpPlatformTypes.js'; +import type { ICopilotApiService } from '../../../node/shared/copilotApiService.js'; +import { createGitHubMcpServerConfiguration, getGitHubMcpTools, GITHUB_MCP_DEPRECATED_WORKFLOW_TOOLS, GITHUB_MCP_TOOLS_WITH_GH_EQUIVALENTS, GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS, resolveGitHubMcpServerConfiguration } from '../../../node/shared/githubMcpServer.js'; + +suite('githubMcpServer', () => { + ensureNoDisposablesAreLeakedInTestSuite(); + + test('keeps globally required tools and excludes gh-replaceable tools when gh is available', () => { + const withGh = getGitHubMcpTools(true); + const withoutGh = getGitHubMcpTools(false); + const config = createGitHubMcpServerConfiguration('https://api.githubcopilot.com', true); + + assert.deepStrictEqual({ + withGh, + withoutGh, + toolsHeader: config.type === McpServerType.REMOTE ? config.headers?.['X-MCP-Tools'] : undefined, + authorization: config.type === McpServerType.REMOTE ? config.headers?.Authorization : undefined, + }, { + withGh: GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS, + withoutGh: [ + ...GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS, + ...GITHUB_MCP_TOOLS_WITH_GH_EQUIVALENTS, + ...GITHUB_MCP_DEPRECATED_WORKFLOW_TOOLS, + ], + toolsHeader: GITHUB_MCP_TOOLS_WITHOUT_GH_EQUIVALENTS.join(','), + authorization: undefined, + }); + }); + + test('does not pair a token with the public endpoint when account endpoint discovery fails', async () => { + const copilotApiService = { + resolveApiEndpoint: async () => undefined, + } as Partial as ICopilotApiService; + + assert.strictEqual(await resolveGitHubMcpServerConfiguration(copilotApiService, 'enterprise-token'), undefined); + }); +}); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index 626cf7ce2dad29..cee3b067fc4b1d 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -169,6 +169,7 @@ Key properties on the harness descriptor: | `sectionOverrides` | Per-section `ISectionOverride` map for button behavior | | `requiredAgentId` | Agent ID that must be registered for harness to appear | | `instructionFileFilter` | Filename/path patterns to filter instruction items | +| `hiddenMcpServerCollectionIds` | Local MCP collections that do not apply to the harness; host-published servers remain visible | ### IStorageSourceFilter @@ -218,6 +219,8 @@ Claude additionally applies: - `workspaceSubpaths: ['.claude']` (instruction files matching `instructionFileFilter` are exempt) - `sectionOverrides`: Instructions → "Add CLAUDE.md" primary, "Rule" type label, `.md` file extension +Copilot, Claude, and Codex Agent Host harnesses hide the Copilot Chat extension's local GitHub MCP collection because that duplicate is intentionally excluded from synchronization; the provider's host-published GitHub MCP server remains visible. + ### Built-in Extension Grouping (Core VS Code) In core VS Code, customization items contributed by the default chat extension (`productService.defaultChatAgent.chatExtensionId`, typically `GitHub.copilot-chat`) are grouped under the "Built-in" header in the management editor list widget, separate from third-party "Extensions". 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 ad05e3de432ebd..5aeb4a434b8211 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostChatContribution.ts @@ -34,6 +34,7 @@ import { ILanguageModelsService } from '../../../common/languageModels.js'; import { languageModelSourcePresentationRegistry } from '../../../common/languageModelSourcePresentation.js'; import { Target } from '../../../common/promptSyntax/promptTypes.js'; import { AgentCustomizationItemProvider } from './agentCustomizationItemProvider.js'; +import { agentHostProviderHasBuiltInGitHubMcpServer, COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID } from './agentHostLocalCustomizations.js'; import { AgentHostDownloadProgress } from './agentHostDownloadProgress.js'; import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from './agentHostAuth.js'; import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from './agentHostLanguageModelProvider.js'; @@ -330,6 +331,7 @@ export class AgentHostContribution extends Disposable implements IWorkbenchContr hideGenerateButton: true, syncProvider, itemProvider, + hiddenMcpServerCollectionIds: agentHostProviderHasBuiltInGitHubMcpServer(agent.provider) ? [COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID] : undefined, })); // Session handler diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts index 4a899f511524b3..3974a4493edcd6 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLocalCustomizations.ts @@ -9,7 +9,6 @@ import { ResourceSet } from '../../../../../../base/common/map.js'; import { basename, isEqualOrParent } from '../../../../../../base/common/resources.js'; import { URI } from '../../../../../../base/common/uri.js'; import { CustomizationEnablementKind, type AgentCustomization, CustomizationType, type URI as ProtocolURI } from '../../../../../../platform/agentHost/common/state/protocol/state.js'; -import { parseRemoteAgentHostHarness } from '../../../../../../platform/agentHost/common/agentHostSessionType.js'; import { customizationId, type ClientPluginCustomization } from '../../../../../../platform/agentHost/common/state/sessionState.js'; import { withCustomizationEnablement } from '../../../../../../platform/agentHost/common/customizationEnablement.js'; import { ExtensionIdentifier } from '../../../../../../platform/extensions/common/extensions.js'; @@ -25,16 +24,24 @@ import { IConfigurationResolverService } from '../../../../../services/configura import { ConfigurationResolverExpression } from '../../../../../services/configurationResolver/common/configurationResolverExpression.js'; import { IWorkspaceFolderData } from '../../../../../../platform/workspace/common/workspace.js'; import type { ISyncableFile, ISyncableMcpServer, SyncedCustomizationBundler } from './syncedCustomizationBundler.js'; -import { AGENT_HOST_COPILOT_CLI_SESSION_TYPE } from './agentHostToolSetEnablementService.js'; import { IFileService } from '../../../../../../platform/files/common/files.js'; import { isDefined } from '../../../../../../base/common/types.js'; import { PromptFileParser } from '../../../common/promptSyntax/promptFileParser.js'; const COPILOT_CHAT_EXTENSION_ID = 'github.copilot-chat'; -const COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID = extensionPrefixedIdentifier(new ExtensionIdentifier(COPILOT_CHAT_EXTENSION_ID), 'github'); +export const COPILOT_CHAT_GITHUB_MCP_COLLECTION_ID = extensionPrefixedIdentifier(new ExtensionIdentifier(COPILOT_CHAT_EXTENSION_ID), 'github'); +const LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX = 'agent-host-'; +const AGENT_HOST_PROVIDERS_WITH_GITHUB_MCP = new Set(['copilotcli', 'claude', 'codex']); + +export function agentHostProviderHasBuiltInGitHubMcpServer(provider: string): boolean { + return AGENT_HOST_PROVIDERS_WITH_GITHUB_MCP.has(provider); +} function hasBuiltInGitHubMcpServer(sessionType: string): boolean { - return sessionType === AGENT_HOST_COPILOT_CLI_SESSION_TYPE || parseRemoteAgentHostHarness(sessionType) === 'copilotcli'; + const localProvider = sessionType.startsWith(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX) + ? sessionType.slice(LOCAL_AGENT_HOST_SESSION_TYPE_PREFIX.length) + : undefined; + return agentHostProviderHasBuiltInGitHubMcpServer(localProvider ?? ''); } /** @@ -291,9 +298,8 @@ async function resolveConfigurationForSync( * exception is `.vscode/mcp.json`, which the agent host does not discover * (despite what the SDK's `enableConfigDiscovery` docs imply) — those are * synced, but only when their config can be resolved without requiring user - * interaction. For Copilot CLI agent-host sessions, the Copilot Chat - * extension's GitHub MCP provider is excluded because the SDK supplies its own - * built-in GitHub server. + * interaction. For agent-host providers with their own GitHub MCP server, the + * Copilot Chat extension's duplicate provider is excluded. */ export async function collectNonPluginMcpServers(mcpService: IMcpService, configurationResolverService: IConfigurationResolverService, sessionType: string, workingDirectories: readonly URI[]): Promise { const result: ISyncableMcpServer[] = []; diff --git a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts index 18c56f19b64f04..112ac89e6655ad 100644 --- a/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/aiCustomization/mcpListWidget.ts @@ -115,6 +115,10 @@ export function createBuiltinActiveSessionMcpEntries(servers: readonly AgentHost return servers.map(server => ({ type: 'session-server-item', server })); } +export function isMcpServerCollectionVisible(collectionId: string, hiddenCollectionIds: readonly string[] | undefined): boolean { + return !hiddenCollectionIds?.includes(collectionId); +} + type IMcpListEntry = IMcpGroupHeaderEntry | IMcpServerItemEntry | IMcpSessionServerItemEntry | IMcpBuiltinItemEntry; export type McpStatusKind = McpConnectionState.Kind | McpServerStatus | 'disabled'; @@ -1403,8 +1407,10 @@ export class McpListWidget extends Disposable { // Find extension-provided servers not in the local list (e.g. GitHub MCP) const localIds = new Set(this.filteredServers.map(s => s.id)); + const hiddenCollectionIds = this.customizationHarnessService.getActiveDescriptor().hiddenMcpServerCollectionIds; const builtinServers = this.mcpService.servers.get() .filter(s => !localIds.has(s.definition.id)) + .filter(s => isMcpServerCollectionVisible(s.collection.id, hiddenCollectionIds)) .filter(s => !query || s.definition.label.toLowerCase().includes(query)); const groups: { scope: LocalMcpServerScope; label: string; icon: ThemeIcon; description: string; entries: Array }[] = [ diff --git a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts index 10b25c470df61d..73c6596cf30f95 100644 --- a/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts +++ b/src/vs/workbench/contrib/chat/common/customizationHarnessService.ts @@ -133,6 +133,12 @@ export interface IHarnessDescriptor { * a remote agent host). The create action remains a separate toolbar button. */ readonly pluginActions?: readonly ICustomizationItemAction[]; + /** + * Local MCP collection identifiers that do not apply to this harness. + * Host-published MCP servers remain visible even when their local counterpart + * belongs to a hidden collection. + */ + readonly hiddenMcpServerCollectionIds?: readonly string[]; } /** diff --git a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts index 9ff0478860c7df..dbd5ae2d5952dd 100644 --- a/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/agentSessions/resolveCustomizationRefs.test.ts @@ -580,7 +580,7 @@ suite('resolveCustomizationRefs - built-in skills', () => { ]]); }); - test('excludes the Copilot Chat GitHub MCP provider from remote Copilot agent hosts', async () => { + test('retains the Copilot Chat GitHub MCP provider for remote hosts without an advertised capability', async () => { const bundler = new FakeBundler(); await resolveCustomizationRefs( @@ -595,29 +595,36 @@ suite('resolveCustomizationRefs - built-in skills', () => { undefined, ); - assert.deepStrictEqual(bundler.receivedMcp, []); - }); - - test('retains the Copilot Chat GitHub MCP provider for agent hosts without a built-in server', async () => { - const bundler = new FakeBundler(); - - await resolveCustomizationRefs( - makeFileService(), - makePromptsService(new Map()), - new FakeSyncProvider(), - makeAgentPluginService(), - makeMcpService([makeCopilotChatGitHubMcpServer()]), - makeConfigurationResolverService(), - bundler as unknown as SyncedCustomizationBundler, - 'agent-host-claude', - undefined, - ); - assert.deepStrictEqual(bundler.receivedMcp, [[ { name: 'GitHub', configuration: { type: McpServerType.LOCAL, command: 'my-server', args: ['--flag'], env: undefined, envFile: undefined, cwd: undefined }, enablement: globalEnablement(true) }, ]]); }); + test('excludes the Copilot Chat GitHub MCP provider for all built-in agent-host providers', async () => { + const receivedMcpByProvider: Record = {}; + for (const provider of ['copilotcli', 'claude', 'codex']) { + const bundler = new FakeBundler(); + await resolveCustomizationRefs( + makeFileService(), + makePromptsService(new Map()), + new FakeSyncProvider(), + makeAgentPluginService(), + makeMcpService([makeCopilotChatGitHubMcpServer()]), + makeConfigurationResolverService(), + bundler as unknown as SyncedCustomizationBundler, + `agent-host-${provider}`, + undefined, + ); + receivedMcpByProvider[provider] = bundler.receivedMcp; + } + + assert.deepStrictEqual(receivedMcpByProvider, { + copilotcli: [], + claude: [], + codex: [], + }); + }); + test('excludes plugin-sourced MCP servers from the bundle', async () => { const bundler = new FakeBundler(); const mcpService = makeMcpService([ diff --git a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts index e086d4d97979ec..9c95bb1c8af7b3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/aiCustomization/mcpListWidget.test.ts @@ -35,6 +35,7 @@ import { getLocalMcpServerEnablementActions, getMcpServerOutputHandler, getMcpStatusPresentation, + isMcpServerCollectionVisible, getMcpStatusRenderSignature, getServerItemContextMenuActions, McpServerItemRenderer, @@ -112,6 +113,18 @@ suite('mcpListWidget', () => { }]); }); + test('filters local MCP collections hidden by the active harness', () => { + assert.deepStrictEqual({ + defaultVisible: isMcpServerCollectionVisible('extension/github', undefined), + visible: isMcpServerCollectionVisible('extension/context7', ['extension/github']), + hidden: isMcpServerCollectionVisible('extension/github', ['extension/github']), + }, { + defaultVisible: true, + visible: true, + hidden: false, + }); + }); + test('renders host-published disabled reasons without changing legacy rows', () => { assert.deepStrictEqual([ getMcpStatusPresentation('disabled', { source: 'scope', scope: CustomizationEnablementKind.Global })?.label, From 57ca46fc0df93295920e2037805fb150bfc87bc9 Mon Sep 17 00:00:00 2001 From: Bryan Chen <41454397+bryanchen-d@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:13:42 -0700 Subject: [PATCH 06/28] Render evidence chapters from the automation harness (#331574) * Render evidence chapters from the automation harness Record when video recording starts so captured step timestamps can be expressed as offsets into the recording, and add a script that composes step chapter cards onto a finished run. This lets a clean capture be annotated afterwards instead of drawing a banner into the window under test, and gives local runs and CI one shared implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Write the chapter renderer in TypeScript Hygiene rejects new JavaScript files, so move the renderer into the compiled MCP sources and invoke it from out/ instead of adding an eslint-allowed-javascript-files exception. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 * Address review of evidence chaptering Sample the recording origin at page creation instead of context or application creation, state the residual imprecision in the contract, render manifest text literally, point the report at the annotated recording, and refuse multi-window runs rather than chaptering onto only the first recording. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: adb443eb-11e5-40a1-8608-7f593fa79485 --- .../skills/ui-scenario-validation/SKILL.md | 35 ++- test/automation/src/playwrightBrowser.ts | 10 +- test/automation/src/playwrightDriver.ts | 14 +- test/automation/src/playwrightElectron.ts | 9 +- test/mcp/src/evidence.ts | 5 + test/mcp/src/renderEvidenceChapters.ts | 263 ++++++++++++++++++ 6 files changed, 326 insertions(+), 10 deletions(-) create mode 100644 test/mcp/src/renderEvidenceChapters.ts diff --git a/.github/skills/ui-scenario-validation/SKILL.md b/.github/skills/ui-scenario-validation/SKILL.md index 8f7fb400fc0297..f091d33aa94042 100644 --- a/.github/skills/ui-scenario-validation/SKILL.md +++ b/.github/skills/ui-scenario-validation/SKILL.md @@ -21,8 +21,27 @@ npm --prefix test/mcp run compile ``` The automation MCP server is `test/mcp` (`out/stdio.js`). Add it to your MCP configuration so the -`vscode_automation_*` tools are available; append `--web --headless` to the args to record the web -build instead of Electron. +`vscode_automation_*` tools are available. + +| Target | Args | Use for | +|--------|------|---------| +| Dev build from this checkout | *(none)* | Verifying a local change | +| Installed Insiders | `--build ` | Reproducing a report against shipped behavior | +| Web | `--web --headless` | Browser-only behavior | + +`--build` takes the application root — the install directory on Windows and Linux, or the `.app` +bundle on macOS. For example: + +```bash +# Windows +--build "C:/Users//AppData/Local/Programs/Microsoft VS Code Insiders" +# macOS +--build "/Applications/Visual Studio Code - Insiders.app" +``` + +An installed build runs with an isolated profile, so your own extensions and settings do not leak +into the recording. Note that Insiders only reproduces **shipped** behavior — to validate an +unmerged change you must run the dev build from a checkout that contains it. ## Record a clean capture @@ -33,6 +52,16 @@ DOM of the product under test, so it can shift layout and affect focus and selec capture enabled the recording shows unmodified UI, and step boundaries are still recorded in `manifest.json` with timestamps and screenshots. +Add the step titles back afterwards, once the recording is finished: + +```bash +node test/mcp/out/renderEvidenceChapters.js .build/vscode-playwright-mcp/evidence/ +``` + +This writes `videos/annotated.mp4` with a full-screen card before each step, inserted between +segments so no recorded frame is hidden. It needs `ffmpeg` and `ffprobe` on `PATH`; without them it +prints a warning and leaves the raw recording untouched. + ## Run a scenario 1. Choose a **disposable** workspace folder. Never point a scenario at real work: the run types, @@ -71,7 +100,7 @@ Evidence is written to `.build/vscode-playwright-mcp/evidence//`: |------|----------| | `report.html` | Step table, outcome, embedded video | | `manifest.json` | Step timestamps, statuses, artifact paths, environment | -| `videos/` | Screen recording of the run | +| `videos/` | Screen recording, plus `annotated.mp4` once chapters are rendered | | `*.png` | Per-step screenshots | | `logs/` | Playwright trace, window and server logs | diff --git a/test/automation/src/playwrightBrowser.ts b/test/automation/src/playwrightBrowser.ts index 084e3eaa1b8aad..a0459eed009db3 100644 --- a/test/automation/src/playwrightBrowser.ts +++ b/test/automation/src/playwrightBrowser.ts @@ -22,11 +22,11 @@ export async function launch(options: LaunchOptions): Promise<{ serverProcess: C const { serverProcess, endpoint } = await launchServer(options); // Launch browser - const { browser, context, page, pageLoadedPromise } = await launchBrowser(options, endpoint); + const { browser, context, page, pageLoadedPromise, videoStartedAt } = await launchBrowser(options, endpoint); return { serverProcess, - driver: new PlaywrightDriver(browser, context, page, serverProcess, pageLoadedPromise, options) + driver: new PlaywrightDriver(browser, context, page, serverProcess, pageLoadedPromise, options, videoStartedAt) }; } @@ -128,6 +128,10 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) { } } + // Recording is per page and starts when the page is created, so sample the + // origin here rather than at context creation: tracing startup above can take + // long enough to visibly skew offsets measured against it. + const videoStartedAt = options.videosPath ? Date.now() : undefined; const page = await measureAndLog(() => context.newPage(), 'context.newPage()', logger); await measureAndLog(() => page.setViewportSize({ width: 1440, height: 900 }), 'page.setViewportSize', logger); @@ -182,7 +186,7 @@ async function launchBrowser(options: LaunchOptions, endpoint: string) { await gotoPromise; - return { browser, context, page, pageLoadedPromise }; + return { browser, context, page, pageLoadedPromise, videoStartedAt }; } function waitForEndpoint(server: ChildProcess, logger: Logger): Promise { diff --git a/test/automation/src/playwrightDriver.ts b/test/automation/src/playwrightDriver.ts index b98f6256cf9bfb..b997241378d024 100644 --- a/test/automation/src/playwrightDriver.ts +++ b/test/automation/src/playwrightDriver.ts @@ -70,7 +70,19 @@ export class PlaywrightDriver { private _currentPage: playwright.Page, private readonly serverProcess: ChildProcess | undefined, private readonly whenLoaded: Promise, - private readonly options: LaunchOptions + private readonly options: LaunchOptions, + /** + * Wall-clock time sampled when the first recorded page was created, used to + * express captured timestamps as offsets into the recording. + * + * Playwright rebases each video to its first screencast frame, which arrives + * shortly after page creation, so this is an approximation rather than an + * exact origin. Consumers should treat derived offsets as accurate to a + * fraction of a second and must not rely on frame-exact alignment. + * + * Undefined when the run is not recording. + */ + readonly videoStartedAt?: number ) { } diff --git a/test/automation/src/playwrightElectron.ts b/test/automation/src/playwrightElectron.ts index 6f4f9933e7cd37..c161c5d29b93df 100644 --- a/test/automation/src/playwrightElectron.ts +++ b/test/automation/src/playwrightElectron.ts @@ -25,12 +25,12 @@ export async function launch(options: LaunchOptions): Promise<{ electronProcess: args.push('--enable-smoke-test-driver'); // Launch electron via playwright - const { electron, context, page } = await launchElectron({ electronPath, args, env }, options); + const { electron, context, page, videoStartedAt } = await launchElectron({ electronPath, args, env }, options); const electronProcess = electron.process(); return { electronProcess, - driver: new PlaywrightDriver(electron, context, page, undefined /* no server process */, Promise.resolve() /* Window is open already */, options) + driver: new PlaywrightDriver(electron, context, page, undefined /* no server process */, Promise.resolve() /* Window is open already */, options, videoStartedAt) }; } @@ -63,6 +63,9 @@ async function launchElectron(configuration: IElectronConfiguration, options: La throw enrichLaunchError(error, options); } } + // Recording is per page, so sample the origin once the first window exists + // rather than when the application finished launching. + const videoStartedAt = options.videosPath ? Date.now() : undefined; const context = window.context(); @@ -99,7 +102,7 @@ async function launchElectron(configuration: IElectronConfiguration, options: La } }); - return { electron, context, page: window }; + return { electron, context, page: window, videoStartedAt }; } /** diff --git a/test/mcp/src/evidence.ts b/test/mcp/src/evidence.ts index bb45143f2e9181..aa124aa0e1f028 100644 --- a/test/mcp/src/evidence.ts +++ b/test/mcp/src/evidence.ts @@ -55,6 +55,7 @@ interface EvidenceRun { scenarioPath?: string; workspacePath?: string; startedAt: string; + videoStartedAt?: string; completedAt?: string; outcome?: RunOutcome; notes?: string; @@ -145,6 +146,9 @@ export class EvidenceService { throw error; } run.environment.quality = qualityNames[app.quality] ?? String(app.quality); + if (app.code.driver.videoStartedAt !== undefined) { + run.videoStartedAt = new Date(app.code.driver.videoStartedAt).toISOString(); + } try { run.pageListener = page => { const video = page.video(); @@ -473,6 +477,7 @@ export class EvidenceService { scenarioPath: run.scenarioPath, workspacePath: run.workspacePath, startedAt: run.startedAt, + videoStartedAt: run.videoStartedAt, completedAt: run.completedAt, outcome: run.outcome, notes: run.notes, diff --git a/test/mcp/src/renderEvidenceChapters.ts b/test/mcp/src/renderEvidenceChapters.ts new file mode 100644 index 00000000000000..ac7e4081cccad6 --- /dev/null +++ b/test/mcp/src/renderEvidenceChapters.ts @@ -0,0 +1,263 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// Composes chapter cards onto an evidence recording. +// +// Step titles are rendered onto the video *after* the run instead of being drawn +// into the window while it is recorded, so the capture shows unmodified product +// UI. Cards are inserted between segments rather than overlaid, so no recorded +// frame is ever hidden. +// +// Usage: node out/renderEvidenceChapters.js + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +interface Capture { + status?: string; + timestamp?: string; + details?: string; +} + +interface Step { + id?: string; + title?: string; + captures?: Capture[]; +} + +interface Manifest { + scenarioId?: string; + title?: string; + outcome?: string; + videoStartedAt?: string; + artifacts?: { videos?: string[] }; + steps?: Step[]; +} + +interface DrawnText { + filter: string; + lines: number; +} + +const ffmpeg = process.env.FFMPEG_PATH ?? 'ffmpeg'; +const ffprobe = process.env.FFPROBE_PATH ?? 'ffprobe'; +const fontCandidates = process.env.CHAPTER_FONT ? [process.env.CHAPTER_FONT] : [ + '/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf', + '/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf', + '/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', + '/System/Library/Fonts/Supplemental/Arial Bold.ttf', + 'C:/Windows/Fonts/arialbd.ttf' +]; + +export function renderChapters(runRoot: string): void { + const manifestPath = path.join(runRoot, 'manifest.json'); + const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Manifest; + const videoStartedAtMs = Date.parse(manifest.videoStartedAt ?? ''); + const videos = (manifest.artifacts?.videos ?? []).filter(video => /\.webm$/iu.test(video)); + const relativeVideo = videos[0]; + + if (!relativeVideo) { + console.log('No recorded video is available, so no chapters were rendered.'); + return; + } + if (videos.length > 1) { + // One recording is written per captured page. Aligning every step onto the + // first one would silently drop the other windows from the evidence, so + // refuse rather than publish an incomplete recording. + console.log(`The run captured ${videos.length} recordings (one per window), which cannot be chaptered onto a single timeline.`); + return; + } + if (!Number.isFinite(videoStartedAtMs)) { + console.log('The run has no video start time, so chapters cannot be aligned.'); + return; + } + const font = fontCandidates.find(candidate => candidate && fs.existsSync(candidate)); + if (!font) { + console.log('No usable font was found, so no chapters were rendered.'); + return; + } + + const videoPath = path.join(runRoot, relativeVideo); + const outputRelative = 'videos/annotated.mp4'; + const outputPath = path.join(runRoot, outputRelative); + const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vscode-evidence-chapters-')); + // Filters run from workDir with relative file names so no path escaping + // (drive letters, colons, backslashes) can corrupt the filter description. + fs.copyFileSync(font, path.join(workDir, 'font.ttf')); + + try { + const probe = JSON.parse(execFileSync(ffprobe, [ + '-v', 'error', + '-select_streams', 'v:0', + '-show_entries', 'stream=width,height', + '-show_entries', 'format=duration', + '-of', 'json', + videoPath + ], { encoding: 'utf8' })) as { streams?: { width?: number; height?: number }[]; format?: { duration?: string } }; + const width = Number(probe.streams?.[0]?.width); + const height = Number(probe.streams?.[0]?.height); + const duration = Number(probe.format?.duration); + if (!Number.isInteger(width) || !Number.isInteger(height) || !Number.isFinite(duration) || duration <= 0) { + throw new Error('the recorded video could not be probed'); + } + + const boundaries: { step: Step; at: number }[] = []; + let previous = 0; + for (const step of manifest.steps ?? []) { + const started = step.captures?.find(capture => capture.status === 'started') ?? step.captures?.[0]; + const offset = (Date.parse(started?.timestamp ?? '') - videoStartedAtMs) / 1000; + if (!Number.isFinite(offset)) { + continue; + } + const at = Math.min(Math.max(offset, previous), duration); + boundaries.push({ step, at }); + previous = at; + } + + const inputs = ['-i', videoPath]; + const filters: string[] = []; + const concat: string[] = []; + let inputIndex = 1; + let textIndex = 0; + + const addSegment = (from: number, to: number): void => { + if (!(to > from + 0.05)) { + return; + } + const label = `v${concat.length}`; + filters.push(`[0:v]trim=start=${from.toFixed(3)}:end=${to.toFixed(3)},setpts=PTS-STARTPTS,fps=30,scale=${width}:${height},setsar=1,format=yuv420p[${label}]`); + concat.push(`[${label}]`); + }; + + const drawText = (value: string, size: number, y: number, color: string, wrapAt: number): DrawnText => { + const text = wrap(String(value ?? '').trim(), wrapAt); + if (!text) { + return { filter: '', lines: 0 }; + } + const name = `text-${textIndex++}.txt`; + fs.writeFileSync(path.join(workDir, name), text); + return { + // Expansion is on by default even for `textfile`, which would make + // ordinary evidence text containing `%` fail to parse. + filter: `drawtext=fontfile=font.ttf:textfile=${name}:expansion=none:fontcolor=${color}:fontsize=${size}:line_spacing=${Math.round(size * 0.4)}:x=(w-text_w)/2:y=${y}`, + lines: text.split('\n').length + }; + }; + + const addCard = (eyebrow: string, title: string, subtitle: string, seconds: number, status?: string): void => { + const label = `v${concat.length}`; + inputs.push('-f', 'lavfi', '-t', String(seconds), '-i', `color=c=0x0D1117:s=${width}x${height}:r=30`); + const accent = status === 'failed' ? '0xF85149' : status === 'passed' ? '0x3FB950' : '0x58A6FF'; + const eyebrowSize = Math.max(14, Math.round(height * 0.026)); + const titleSize = Math.max(20, Math.round(height * 0.052)); + const subtitleSize = Math.max(13, Math.round(height * 0.024)); + const parts: string[] = []; + const push = (drawn: DrawnText, size: number): number => { + if (drawn.filter) { + parts.push(drawn.filter); + } + return Math.round(drawn.lines * size * 1.4); + }; + let y = Math.round(height * 0.34); + y += push(drawText(eyebrow, eyebrowSize, y, accent, 62), eyebrowSize) + Math.round(height * 0.03); + y += push(drawText(title, titleSize, y, '0xFFFFFF', 44), titleSize) + Math.round(height * 0.035); + push(drawText(subtitle, subtitleSize, y, '0xC9D1D9', 76), subtitleSize); + filters.push(`[${inputIndex}:v]${parts.join(',')},setsar=1,format=yuv420p[${label}]`); + concat.push(`[${label}]`); + inputIndex++; + }; + + addCard('CHAPTERED VALIDATION EVIDENCE', manifest.title ?? 'UI validation', `${manifest.scenarioId ?? ''} - outcome: ${manifest.outcome ?? 'unknown'}`, 3); + if (boundaries.length === 0 || boundaries[0].at > 0.05) { + addSegment(0, boundaries.length ? boundaries[0].at : duration); + } + boundaries.forEach((boundary, index) => { + const step = boundary.step; + addCard( + `STEP ${index + 1} OF ${boundaries.length} - ${String(step.id ?? '').toUpperCase()}`, + step.title ?? '', + step.captures?.[0]?.details ?? '', + 2.5, + step.captures?.at(-1)?.status + ); + addSegment(boundary.at, index + 1 < boundaries.length ? boundaries[index + 1].at : duration); + }); + + if (concat.length < 2) { + console.log('No chapter boundaries were derived, so no chapters were rendered.'); + return; + } + + filters.push(`${concat.join('')}concat=n=${concat.length}:v=1:a=0[out]`); + fs.mkdirSync(path.dirname(outputPath), { recursive: true }); + execFileSync(ffmpeg, [ + '-y', '-hide_banner', '-loglevel', 'error', + ...inputs, + '-filter_complex', filters.join(';'), + '-map', '[out]', + '-an', + '-c:v', 'libx264', + '-preset', 'veryfast', + '-crf', '30', + '-pix_fmt', 'yuv420p', + '-movflags', '+faststart', + outputPath + ], { cwd: workDir, stdio: ['ignore', 'inherit', 'inherit'] }); + + manifest.artifacts!.videos = [...new Set([outputRelative, ...(manifest.artifacts?.videos ?? [])])]; + fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, undefined, 2)}\n`); + pointReportAtAnnotatedVideo(runRoot, relativeVideo, outputRelative); + console.log(`Rendered ${boundaries.length} step chapters into ${outputRelative}`); + } finally { + fs.rmSync(workDir, { recursive: true, force: true }); + } +} + +/** + * Point the generated report at the annotated recording. + * + * `EvidenceService.finish()` writes `report.html` before chapters exist, so the + * report would otherwise keep showing the unannotated capture. + */ +function pointReportAtAnnotatedVideo(runRoot: string, rawVideo: string, annotatedVideo: string): void { + const reportPath = path.join(runRoot, 'report.html'); + if (!fs.existsSync(reportPath)) { + return; + } + const report = fs.readFileSync(reportPath, 'utf8'); + if (!report.includes(`src="${rawVideo}"`)) { + return; + } + fs.writeFileSync(reportPath, report.replace(`src="${rawVideo}"`, `src="${annotatedVideo}"`)); +} + +function wrap(value: string, limit: number): string { + const lines: string[] = []; + let line = ''; + for (const word of value.split(/\s+/u).filter(Boolean)) { + if (line && `${line} ${word}`.length > limit) { + lines.push(line); + line = word; + } else { + line = line ? `${line} ${word}` : word; + } + } + if (line) { + lines.push(line); + } + return lines.slice(0, 4).join('\n'); +} + +if (require.main === module) { + try { + renderChapters(path.resolve(process.argv[2] ?? process.env.RUN_ROOT ?? '.')); + } catch (error) { + // Chapters are a presentation aid, so never fail a validation run because + // the recording could not be annotated. The raw recording is authoritative. + console.warn(`Unable to render evidence chapters: ${error instanceof Error ? error.message : error}`); + } +} From e145e083f0f98fb2aa22fbeb4b59d7bbeedab8d7 Mon Sep 17 00:00:00 2001 From: Jade Ferreira Vieira Date: Wed, 19 Aug 2026 15:11:17 -0300 Subject: [PATCH 07/28] Fix file URL to path conversion in html-language-features esbuild script (#328557) import.meta.resolve(...).replace('file://', '') is not a correct way to convert a file:// URL to a filesystem path. It always breaks on Windows (leaves a leading slash before the drive letter, e.g. /C:/Users/..., which downstream path.join/fs calls mangle into C:\C:\Users\...), and it also breaks on any OS whenever the resolved path contains a character that gets percent-encoded in a URL, most commonly a space (e.g. file:///home/jane%20doe/... never gets decoded back to "jane doe", so fs.readFileSync fails with ENOENT there too). Use fileURLToPath() from node:url instead, which is Node's own built-in, spec-correct URL-to-path converter and handles both cases. --- extensions/html-language-features/esbuild.browser.mts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/extensions/html-language-features/esbuild.browser.mts b/extensions/html-language-features/esbuild.browser.mts index 0e4c1ec91b8136..08673a6f8da151 100644 --- a/extensions/html-language-features/esbuild.browser.mts +++ b/extensions/html-language-features/esbuild.browser.mts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; import type * as esbuild from 'esbuild'; import { run } from '../esbuild-extension-common.mts'; @@ -19,7 +20,7 @@ function javaScriptLibsPlugin(): esbuild.Plugin { name: 'javascript-libs', setup(build) { build.onLoad({ filter: /javascriptLibs\.ts$/ }, () => { - const TYPESCRIPT_LIB_SOURCE = path.dirname(import.meta.resolve('typescript').replace('file://', '')); + const TYPESCRIPT_LIB_SOURCE = path.dirname(fileURLToPath(import.meta.resolve('typescript'))); const JQUERY_DTS = path.join(extensionRoot, 'server', 'lib', 'jquery.d.ts'); function getFileName(name: string): string { From ed09c517332869844031b017ed02208ead7e06a8 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Wed, 19 Aug 2026 11:14:09 -0700 Subject: [PATCH 08/28] Agent Host changes for agents/log-analysis-error-fix-prioritization-37e6feba --- .../byokUtilityModel.contribution.ts | 5 +++- .../byokUtilityModel.contribution.spec.ts | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts index 040656e48a88f1..a666760ed33550 100644 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/byokUtilityModel.contribution.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import { IAuthenticationService } from '../../../platform/authentication/common/authentication'; import { IConfigurationService } from '../../../platform/configuration/common/configurationService'; import { ILogService } from '../../../platform/log/common/logService'; +import { isCancellationError } from '../../../util/vs/base/common/errors'; import { Disposable } from '../../../util/vs/base/common/lifecycle'; const NOTIFICATION_ID = 'copilot.byokUtilityModelHint'; @@ -68,7 +69,9 @@ export class ByokUtilityModelNotificationContribution extends Disposable { const models = await vscode.lm.selectChatModels({}); this._hasByokModels = models.some(m => m.vendor !== 'copilot'); } catch (err) { - this._logService.warn(`[ByokUtilityModelNotification] Failed to query language models: ${err}`); + if (!isCancellationError(err)) { + this._logService.warn(`[ByokUtilityModelNotification] Failed to query language models: ${err}`); + } } finally { this._refreshing = false; } diff --git a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts index c75041300505cc..5c1fae6fa3e39f 100644 --- a/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts +++ b/extensions/copilot/src/extension/chatInputNotification/vscode-node/test/byokUtilityModel.contribution.spec.ts @@ -182,6 +182,30 @@ describe('ByokUtilityModelNotificationContribution', () => { expect(mockNotification.show).not.toHaveBeenCalled(); }); + test('does not warn when querying models is canceled', async () => { + const cancellationError = new Error('Canceled'); + cancellationError.name = 'Canceled'; + selectChatModelsMock.mockRejectedValue(cancellationError); + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(noopLog.warn).not.toHaveBeenCalled(); + }); + + test('warns when querying models fails unexpectedly', async () => { + selectChatModelsMock.mockRejectedValue(new Error('model query failed')); + const { authService } = createAuthService({ anyGitHubSession: undefined }); + const { configService } = createConfigService(); + contribution = new ByokUtilityModelNotificationContribution(authService, configService, noopLog); + + await flushAsync(); + + expect(noopLog.warn).toHaveBeenCalledWith('[ByokUtilityModelNotification] Failed to query language models: Error: model query failed'); + }); + test('does not show notification when both utility settings are configured', async () => { const { authService } = createAuthService({ anyGitHubSession: undefined }); const { configService } = createConfigService({ From e0e045b76946135741043e9c8711235a63f77327 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:21:04 +0000 Subject: [PATCH 09/28] Polish external session banner picker (#331625) * Initial plan * fix: polish external session banner picker Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> * fix: preserve accessible select navigation Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: benibenj <44439583+benibenj@users.noreply.github.com> --- src/vs/base/browser/ui/selectBox/selectBox.ts | 4 + .../browser/ui/selectBox/selectBoxCustom.ts | 178 ++++++++++++------ .../browser/ui/selectBox/selectBox.test.ts | 89 +++++++++ .../chat/browser/externalSessionBanner.ts | 4 +- 4 files changed, 220 insertions(+), 55 deletions(-) create mode 100644 src/vs/base/test/browser/ui/selectBox/selectBox.test.ts diff --git a/src/vs/base/browser/ui/selectBox/selectBox.ts b/src/vs/base/browser/ui/selectBox/selectBox.ts index e70edcbac5f573..b0bebe5c4f0bed 100644 --- a/src/vs/base/browser/ui/selectBox/selectBox.ts +++ b/src/vs/base/browser/ui/selectBox/selectBox.ts @@ -40,6 +40,10 @@ export interface ISelectBoxOptions { ariaDescription?: string; minBottomMargin?: number; optionsAsChildren?: boolean; + /** Hide disabled options from the custom-drawn dropdown. */ + hideDisabledOptions?: boolean; + /** Show option descriptions in right-side hovers instead of the details pane. */ + showOptionDescriptionHovers?: boolean; } // Utilize optionItem interface to capture all option parameters diff --git a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts index b7cbca1525069f..a9d87182919629 100644 --- a/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts +++ b/src/vs/base/browser/ui/selectBox/selectBoxCustom.ts @@ -7,7 +7,7 @@ import { localize } from '../../../../nls.js'; import * as arrays from '../../../common/arrays.js'; import { Emitter, Event } from '../../../common/event.js'; import { KeyCode, KeyCodeUtils } from '../../../common/keyCodes.js'; -import { Disposable, DisposableStore, IDisposable } from '../../../common/lifecycle.js'; +import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../common/lifecycle.js'; import { isMacintosh } from '../../../common/platform.js'; import { ScrollbarVisibility } from '../../../common/scrollable.js'; import * as cssJs from '../../cssValue.js'; @@ -16,8 +16,9 @@ import * as domStylesheetsJs from '../../domStylesheets.js'; import { DomEmitter } from '../../event.js'; import { StandardKeyboardEvent } from '../../keyboardEvent.js'; import { IRenderedMarkdown, MarkdownActionHandler, renderMarkdown } from '../../markdownRenderer.js'; +import { HoverPosition } from '../hover/hoverWidget.js'; import { AnchorPosition, IContextViewProvider } from '../contextview/contextview.js'; -import type { IManagedHover } from '../hover/hover.js'; +import type { IHoverWidget, IManagedHover } from '../hover/hover.js'; import { getBaseLayerHoverDelegate } from '../hover/hoverDelegate2.js'; import { getDefaultHoverDelegate } from '../hover/hoverDelegateFactory.js'; import { IListEvent, IListRenderer, IListVirtualDelegate } from '../list/list.js'; @@ -29,16 +30,20 @@ import './selectBoxCustom.css'; const $ = dom.$; const SELECT_OPTION_ENTRY_TEMPLATE_ID = 'selectOption.entry.template'; +const SELECT_OPTION_HEIGHT = 22; interface ISelectListTemplateData { root: HTMLElement; text: HTMLElement; detail: HTMLElement; decoratorRight: HTMLElement; + element?: ISelectOptionItem; } class SelectListRenderer implements IListRenderer { + private readonly elements = new Map(); + get templateId(): string { return SELECT_OPTION_ENTRY_TEMPLATE_ID; } renderTemplate(container: HTMLElement): ISelectListTemplateData { @@ -53,6 +58,11 @@ class SelectListRenderer implements IListRenderer; private selectDropDownListContainer!: HTMLElement; private widthControlElement!: HTMLElement; + private listOptionIndexes: number[] = []; private _currentSelection = 0; private _dropDownPosition!: AnchorPosition; private _hasDetails: boolean = false; private selectionDetailsPane!: HTMLElement; private readonly _selectionDetailsDisposables = this._register(new DisposableStore()); + private readonly _optionDescriptionHover = this._register(new MutableDisposable()); private _skipLayout: boolean = false; private _cachedMaxDetailsHeight?: number; private _hover?: IManagedHover; @@ -169,8 +187,8 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // IDelegate - List renderer - getHeight(): number { - return 22; + getHeight(_element: ISelectOptionItem): number { + return SELECT_OPTION_HEIGHT; } getTemplateId(): string { @@ -228,7 +246,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi dom.EventHelper.stop(e); if (this._isVisible) { - this.hideSelectDropDown(true); + this.cancelSelectDropDown(true); } else { this.showSelectDropDown(); } @@ -249,7 +267,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi dom.EventHelper.stop(e); if (listIsVisibleOnTouchStart) { - this.hideSelectDropDown(true); + this.cancelSelectDropDown(true); } else { this.showSelectDropDown(); } @@ -292,7 +310,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this.options.forEach((option, index) => { this.selectElement.add(this.createOption(option.text, index, option.isDisabled)); - if (typeof option.description === 'string') { + if (typeof option.description === 'string' && !this.selectBoxOptions.showOptionDescriptionHovers) { this._hasDetails = true; } }); @@ -313,7 +331,29 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Mirror options in drop-down // Populate select list for non-native select mode - this.selectList?.splice(0, this.selectList.length, this.options); + this.listOptionIndexes = []; + for (let index = 0; index < this.options.length; index++) { + if (!this.selectBoxOptions.hideDisabledOptions || !this.options[index].isDisabled) { + this.listOptionIndexes.push(index); + } + } + this.selectList?.splice(0, this.selectList.length, this.listOptionIndexes.map(index => this.options[index])); + } + + private getListIndex(optionIndex: number): number { + return this.listOptionIndexes.indexOf(optionIndex); + } + + private getOptionIndex(listIndex: number): number { + return this.listOptionIndexes[listIndex]; + } + + private focusOption(optionIndex: number): void { + const listIndex = this.getListIndex(optionIndex); + if (listIndex >= 0) { + this.selectList.reveal(listIndex); + this.selectList.setFocus([listIndex]); + } } public select(index: number): void { @@ -461,6 +501,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Lazily create and populate list only at open, moved from constructor this.createSelectList(this.selectDropDownContainer); this.setOptionsList(); + this._currentSelection = this.selected; // This allows us to flip the position based on measurement // Set drop-down position above/below from required height and margins @@ -492,10 +533,12 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi anchorPosition: this._dropDownPosition }, this.selectBoxOptions.optionsAsChildren ? this.container : undefined); - // Track initial selection the case user escape, blur - this._currentSelection = this.selected; this._isVisible = true; this.selectElement.setAttribute('aria-expanded', 'true'); + const focusedListIndex = this.selectList.getFocus()[0]; + if (focusedListIndex !== undefined) { + this.showOptionDescriptionHover(focusedListIndex); + } } private hideSelectDropDown(focusSelect: boolean) { @@ -504,6 +547,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi } this._isVisible = false; + this._optionDescriptionHover.clear(); this.selectElement.setAttribute('aria-expanded', 'false'); if (focusSelect) { @@ -513,6 +557,11 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this.contextViewProvider.hideContextView(); } + private cancelSelectDropDown(focusSelect: boolean): void { + this.select(this._currentSelection); + this.hideSelectDropDown(focusSelect); + } + private renderSelectDropDown(container: HTMLElement, preLayoutPosition?: boolean): IDisposable { container.appendChild(this.selectDropDownContainer); @@ -586,8 +635,8 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi const maxDetailsPaneHeight = this._hasDetails ? this._cachedMaxDetailsHeight! : 0; const minRequiredDropDownHeight = listHeight + maxDetailsPaneHeight; - const maxVisibleOptionsBelow = ((Math.floor((maxSelectDropDownHeightBelow - maxDetailsPaneHeight) / this.getHeight()))); - const maxVisibleOptionsAbove = ((Math.floor((maxSelectDropDownHeightAbove - maxDetailsPaneHeight) / this.getHeight()))); + const maxVisibleOptionsBelow = ((Math.floor((maxSelectDropDownHeightBelow - maxDetailsPaneHeight) / SELECT_OPTION_HEIGHT))); + const maxVisibleOptionsAbove = ((Math.floor((maxSelectDropDownHeightAbove - maxDetailsPaneHeight) / SELECT_OPTION_HEIGHT))); // If we are only doing pre-layout check/adjust position only // Calculate vertical space available, flip up if insufficient @@ -610,7 +659,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Always show complete list items - never more than Max available vertical height if (maxVisibleOptionsBelow < SelectBoxList.DEFAULT_MINIMUM_VISIBLE_OPTIONS && maxVisibleOptionsAbove > maxVisibleOptionsBelow - && this.options.length > maxVisibleOptionsBelow + && this.selectList.length > maxVisibleOptionsBelow ) { this._dropDownPosition = AnchorPosition.ABOVE; this.selectDropDownListContainer.remove(); @@ -657,11 +706,11 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Adjust list height to max from select bottom to margin (default/minBottomMargin) if (minRequiredDropDownHeight > maxSelectDropDownHeightBelow) { - listHeight = (maxVisibleOptionsBelow * this.getHeight()); + listHeight = (maxVisibleOptionsBelow * SELECT_OPTION_HEIGHT); } } else { if (minRequiredDropDownHeight > maxSelectDropDownHeightAbove) { - listHeight = (maxVisibleOptionsAbove * this.getHeight()); + listHeight = (maxVisibleOptionsAbove * SELECT_OPTION_HEIGHT); } } @@ -671,8 +720,14 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Finally set focus on selected item if (this.selectList.length > 0) { - this.selectList.setFocus([this.selected || 0]); - this.selectList.reveal(this.selectList.getFocus()[0] || 0); + let selectedListIndex = this.getListIndex(this.selected); + if (selectedListIndex < 0) { + selectedListIndex = 0; + this.selected = this.getOptionIndex(selectedListIndex); + this.select(this.selected); + } + this.selectList.reveal(selectedListIndex); + this.selectList.setFocus([selectedListIndex]); } if (this._hasDetails) { @@ -683,7 +738,11 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this.selectDropDownContainer.style.height = `${listHeight}px`; } - this.updateDetail(this.selected); + if (this._hasDetails) { + this.updateDetail(this.selected); + } else { + this.selectionDetailsPane.style.display = 'none'; + } this.selectDropDownContainer.style.width = selectOptimalWidth; this.selectDropDownListContainer.setAttribute('tabindex', '0'); @@ -788,7 +847,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // SetUp list mouse controller - control navigation, disabled items, focus this._register(dom.addDisposableListener(this.selectList.getHTMLElement(), dom.EventType.POINTER_UP, e => this.onPointerUp(e))); - this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && !this.options[e.index]?.isDisabled && this.selectList.setFocus([e.index]))); + this._register(this.selectList.onMouseOver(e => typeof e.index !== 'undefined' && !e.element?.isDisabled && this.selectList.setFocus([e.index]))); this._register(this.selectList.onDidChangeFocus(e => this.onListFocus(e))); this._register(dom.addDisposableListener(this.selectDropDownContainer, dom.EventType.FOCUS_OUT, e => { @@ -831,7 +890,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi if (!listRowElement) { return; } - const index = Number(listRowElement.getAttribute('data-index')); + const index = this.getOptionIndex(Number(listRowElement.getAttribute('data-index'))); const disabled = listRowElement.classList.contains('option-disabled'); // Ignore mouse selection of disabled options @@ -839,8 +898,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi this.selected = index; this.select(this.selected); - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selectList.getFocus()[0]); + this.focusOption(this.selected); // Only fire if selection change if (this.selected !== this._currentSelection) { @@ -864,12 +922,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // List Exit - passive - implicit no selection change, hide drop-down private onListBlur(): void { if (this._sticky) { return; } - if (this.selected !== this._currentSelection) { - // Reset selected to current if no change - this.select(this._currentSelection); - } - - this.hideSelectDropDown(false); + this.cancelSelectDropDown(false); } @@ -898,11 +951,32 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // List Focus Change - passive - update details pane with newly focused element's data private onListFocus(e: IListEvent) { // Skip during initial layout - if (!this._isVisible || !this._hasDetails) { + if (!this._isVisible) { return; } - this.updateDetail(e.indexes[0]); + const listIndex = e.indexes[0]; + const optionIndex = this.getOptionIndex(listIndex); + if (this._hasDetails) { + this.updateDetail(optionIndex); + } + this.showOptionDescriptionHover(listIndex); + } + + private showOptionDescriptionHover(listIndex: number): void { + if (!this.selectBoxOptions.showOptionDescriptionHovers) { + return; + } + const option = this.options[this.getOptionIndex(listIndex)]; + const description = option?.description; + const target = option ? this.listRenderer.getElement(option) : undefined; + this._optionDescriptionHover.value = description && target + ? getBaseLayerHoverDelegate().showDelayedHover({ + content: description, + target, + position: { hoverPosition: HoverPosition.RIGHT }, + }, { groupId: 'select-box-option-description' }) + : undefined; } private updateDetail(selectedIndex: number): void { @@ -940,8 +1014,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi dom.EventHelper.stop(e); // Reset selection to value when opened - this.select(this._currentSelection); - this.hideSelectDropDown(true); + this.cancelSelectDropDown(true); } // List exit - active - hide ContextView dropdown, return focus to parent select, fire onDidSelect if change @@ -988,8 +1061,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Set focus/selection - only fire event when closing drop-down or on blur this.select(this.selected); - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selectList.getFocus()[0]); + this.focusOption(this.selected); } } @@ -1011,8 +1083,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi // Set focus/selection - only fire event when closing drop-down or on blur this.select(this.selected); - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selectList.getFocus()[0]); + this.focusOption(this.selected); } } @@ -1026,15 +1097,16 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi let candidate = this.selectList.getFocus()[0]; // Shift selection up if we land on a disabled option - while (candidate > 0 && this.options[candidate].isDisabled) { + while (candidate > 0 && this.options[this.getOptionIndex(candidate)].isDisabled) { candidate--; } - if (this.options[candidate].isDisabled) { + const optionIndex = this.getOptionIndex(candidate); + if (this.options[optionIndex].isDisabled) { return; } - this.selected = candidate; - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selected); + this.selected = optionIndex; + this.selectList.reveal(candidate); + this.selectList.setFocus([candidate]); this.select(this.selected); }, 1); } @@ -1049,15 +1121,16 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi let candidate = this.selectList.getFocus()[0]; // Shift selection down if we land on a disabled option - while (candidate < this.options.length - 1 && this.options[candidate].isDisabled) { + while (candidate < this.selectList.length - 1 && this.options[this.getOptionIndex(candidate)].isDisabled) { candidate++; } - if (this.options[candidate].isDisabled) { + const optionIndex = this.getOptionIndex(candidate); + if (this.options[optionIndex].isDisabled) { return; } - this.selected = candidate; - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selected); + this.selected = optionIndex; + this.selectList.reveal(candidate); + this.selectList.setFocus([candidate]); this.select(this.selected); }, 1); } @@ -1076,8 +1149,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi return; } this.selected = candidate; - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selected); + this.focusOption(this.selected); this.select(this.selected); } @@ -1095,8 +1167,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi return; } this.selected = candidate; - this.selectList.setFocus([this.selected]); - this.selectList.reveal(this.selected); + this.focusOption(this.selected); this.select(this.selected); } @@ -1109,8 +1180,7 @@ export class SelectBoxList extends Disposable implements ISelectBoxDelegate, ILi optionIndex = (i + this.selected + 1) % this.options.length; if (this.options[optionIndex].text.charAt(0).toUpperCase() === ch && !this.options[optionIndex].isDisabled) { this.select(optionIndex); - this.selectList.setFocus([optionIndex]); - this.selectList.reveal(this.selectList.getFocus()[0]); + this.focusOption(optionIndex); dom.EventHelper.stop(e); break; } diff --git a/src/vs/base/test/browser/ui/selectBox/selectBox.test.ts b/src/vs/base/test/browser/ui/selectBox/selectBox.test.ts new file mode 100644 index 00000000000000..427e7a4ffc8afb --- /dev/null +++ b/src/vs/base/test/browser/ui/selectBox/selectBox.test.ts @@ -0,0 +1,89 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { IContextViewProvider, IDelegate } from '../../../../browser/ui/contextview/contextview.js'; +import { ISelectOptionItem, unthemedSelectBoxStyles } from '../../../../browser/ui/selectBox/selectBox.js'; +import { SelectBoxList } from '../../../../browser/ui/selectBox/selectBoxCustom.js'; +import { Disposable, IDisposable, MutableDisposable, toDisposable } from '../../../../common/lifecycle.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../common/utils.js'; + +class TestContextViewProvider extends Disposable implements IContextViewProvider { + + readonly container = document.createElement('div'); + + private readonly view = this._register(new MutableDisposable()); + private delegate: IDelegate | undefined; + + constructor() { + super(); + document.body.appendChild(this.container); + this._register(toDisposable(() => this.container.remove())); + } + + showContextView(delegate: IDelegate): void { + this.view.clear(); + this.container.replaceChildren(); + this.delegate = delegate; + this.view.value = delegate.render(this.container) ?? undefined; + delegate.layout?.(); + } + + hideContextView(): void { + this.view.clear(); + this.delegate?.onHide?.(); + this.delegate = undefined; + } + + layout(): void { + this.delegate?.layout?.(); + } +} + +suite('SelectBoxList', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + test('hides disabled options from the custom dropdown while retaining the closed value', () => { + const options: ISelectOptionItem[] = [ + { text: 'Pick an option', isDisabled: true }, + { text: 'None', description: 'Do not show external sessions.' }, + { text: 'All', description: 'Show all external sessions.' }, + ]; + const contextViewProvider = disposables.add(new TestContextViewProvider()); + const selectBox = disposables.add(new SelectBoxList( + options, + 0, + contextViewProvider, + unthemedSelectBoxStyles, + { hideDisabledOptions: true, showOptionDescriptionHovers: true } + )); + const container = document.createElement('div'); + container.style.position = 'absolute'; + container.style.top = '100px'; + document.body.appendChild(container); + disposables.add(toDisposable(() => container.remove())); + selectBox.render(container); + const closedText = container.querySelector('select')?.selectedOptions[0]?.text; + container.querySelector('select')?.click(); + const openText = container.querySelector('select')?.selectedOptions[0]?.text; + const optionTexts = Array.from(contextViewProvider.container.querySelectorAll('.option-text'), element => element.textContent); + const detailsDisplay = contextViewProvider.container.querySelector('.select-box-details-pane')?.style.display; + container.querySelector('select')?.click(); + + assert.deepStrictEqual({ + closedText, + openText, + optionTexts, + detailsDisplay, + cancelledText: container.querySelector('select')?.selectedOptions[0]?.text, + }, { + closedText: 'Pick an option', + openText: 'None', + optionTexts: ['None', 'All'], + detailsDisplay: 'none', + cancelledText: 'Pick an option', + }); + }); +}); diff --git a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts index 965cb50ec3dba7..ad38790bb8097f 100644 --- a/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts +++ b/src/vs/sessions/contrib/chat/browser/externalSessionBanner.ts @@ -136,7 +136,7 @@ export class ExternalSessionBanner extends Disposable { ); dom.append(content, dom.$('.external-session-banner-description', { role: 'status' })).textContent = localize( 'externalSessionBanner.description', - "Choose which external sessions you want to see in {0}. You can change this later in Settings.", + "Choose how you want external sessions to appear in {0}. You can change this later in Settings.", this._productService.nameShort ); @@ -150,6 +150,8 @@ export class ExternalSessionBanner extends Disposable { defaultSelectBoxStyles, { ariaLabel: localize('externalSessionBanner.select.ariaLabel', "External sessions to show"), + hideDisabledOptions: true, + showOptionDescriptionHovers: true, useCustomDrawn: true, } )); From 589012a5f2390bf1d2dcf3f58c03b4fb28576cb6 Mon Sep 17 00:00:00 2001 From: vritant24 Date: Wed, 19 Aug 2026 11:21:37 -0700 Subject: [PATCH 10/28] byok: omit store for custom Responses endpoints Let Custom Endpoint Responses implementations apply their own storage default when zero data retention is not configured. Keep explicit ZDR requests stateless and preserve ordinary OpenAI BYOK behavior. Fixes #331636 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../byok/node/test/openAIEndpoint.spec.ts | 5 +- .../vscode-node/customEndpointProvider.ts | 19 ++++ .../test/customEndpointProvider.spec.ts | 98 ++++++++++++++++++- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts index 716e2135ff399a..d6cd9799336848 100644 --- a/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts +++ b/extensions/copilot/src/extension/byok/node/test/openAIEndpoint.spec.ts @@ -548,14 +548,15 @@ describe('OpenAIEndpoint - Reasoning Properties', () => { expect(response.type === ChatFetchResponseType.Failed && response.reason).toBe('{"code":0,"message":"something broke","metadata":{"code":"server_error"}}'); }); - it('disables marker reuse and store for ZDR Responses requests', () => { + it('keeps store and marker reuse disabled for ordinary OpenAI BYOK ZDR Responses requests', () => { const endpoint = instaService.createInstance(OpenAIEndpoint, { ...modelMetadata, + vendor: 'OpenAI', zeroDataRetentionEnabled: true, }, 'test-api-key', - 'https://api.openai.com/v1/chat/completions'); + 'https://api.openai.com/v1/responses'); const messages: Raw.ChatMessage[] = [ { role: Raw.ChatRole.User, diff --git a/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts b/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts index 4a451122a99be7..2e1b4b9d7c13d7 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/customEndpointProvider.ts @@ -9,6 +9,7 @@ import { IDomainService } from '../../../platform/endpoint/common/domainService' import { EndpointEditToolName, IChatModelInformation, IChatModelRequestOptions, ModelSupportedEndpoint } from '../../../platform/endpoint/common/endpointProvider'; import { ILogService } from '../../../platform/log/common/logService'; import { IFetcherService } from '../../../platform/networking/common/fetcherService'; +import { ICreateEndpointBodyOptions, IEndpointBody } from '../../../platform/networking/common/networking'; import { IChatWebSocketManager } from '../../../platform/networking/node/chatWebSocketManager'; import { IExperimentationService } from '../../../platform/telemetry/common/nullExperimentationService'; import { ITokenizerProvider } from '../../../platform/tokenizer/node/tokenizer'; @@ -203,6 +204,8 @@ export class CustomEndpointBYOKModelProvider extends AbstractOpenAICompatibleLMP * `${input:...}` secret storage. When the user supplies any well-known auth * header, the default inferred auth header is suppressed to avoid sending * conflicting credentials. + * 4. Omits the Responses API `store` property when Zero Data Retention was not + * explicitly configured, allowing custom implementations to use their own default. */ export class CustomEndpointOAIEndpoint extends OpenAIEndpoint { /** @@ -253,6 +256,14 @@ export class CustomEndpointOAIEndpoint extends OpenAIEndpoint { return !!this.modelMetadata.supported_endpoints?.includes(ModelSupportedEndpoint.Messages); } + override createRequestBody(options: ICreateEndpointBodyOptions): IEndpointBody { + const body = super.createRequestBody(options); + if (this.useResponsesApi && this.modelMetadata.zeroDataRetentionEnabled === undefined) { + delete body.store; + } + return body; + } + protected override _isReservedHeader(lowerKey: string): boolean { if (CustomEndpointOAIEndpoint._overridableReservedAuthHeaders.has(lowerKey)) { return false; @@ -293,6 +304,14 @@ export class CustomEndpointOAIEndpoint extends OpenAIEndpoint { return false; } + /** + * Preserve Custom Endpoint request shaping when a context-size override clones the endpoint. + */ + override cloneWithTokenOverride(modelMaxPromptTokens: number): CustomEndpointOAIEndpoint { + const newModelInfo = { ...this.modelMetadata, maxInputTokens: modelMaxPromptTokens }; + return this.instantiationService.createInstance(CustomEndpointOAIEndpoint, newModelInfo, this._apiKey, this._modelUrl); + } + private _interpolateApiKey(value: string): string { // Replace the literal token `${apiKey}` with the configured API key so // users can keep the secret in VS Code's secret storage via diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts index f4f25cdbff8901..5641d746684e45 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts @@ -11,7 +11,8 @@ import { IChatMLFetcher, type IFetchMLOptions } from '../../../../platform/chat/ import { ChatLocation, type ChatResponse, type ChatResponses } from '../../../../platform/chat/common/commonTypes'; import { MockChatMLFetcher } from '../../../../platform/chat/test/common/mockChatMLFetcher'; import { IChatModelInformation, ModelSupportedEndpoint } from '../../../../platform/endpoint/common/endpointProvider'; -import type { IChatEndpoint } from '../../../../platform/networking/common/networking'; +import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes'; +import type { IChatEndpoint, IEndpointBody } from '../../../../platform/networking/common/networking'; import { ITestingServicesAccessor } from '../../../../platform/test/node/services'; import { TokenizerType } from '../../../../util/common/tokenizer'; import { Event } from '../../../../util/vs/base/common/event'; @@ -21,7 +22,10 @@ import { IInstantiationService } from '../../../../util/vs/platform/instantiatio import { createExtensionUnitTestingServices } from '../../../test/node/services'; import type { OpenAICompatibleLanguageModelChatInformation } from '../abstractLanguageModelChatProvider'; import type { IBYOKStorageService } from '../byokStorageService'; -import { CustomEndpointBYOKModelProvider, type CustomEndpointModelProviderConfig, CustomEndpointOAIEndpoint, hasExplicitApiPath, resolveCustomEndpointUrl } from '../customEndpointProvider'; +import { CustomEndpointBYOKModelProvider, type CustomEndpointModelConfig, type CustomEndpointModelProviderConfig, CustomEndpointOAIEndpoint, hasExplicitApiPath, resolveCustomEndpointUrl } from '../customEndpointProvider'; + +const customResponsesModelId = 'custom-responses-model'; +const customResponsesMarker = 'resp_custom_previous'; class TestCustomEndpointBYOKModelProvider extends CustomEndpointBYOKModelProvider { public createEndpoint(model: OpenAICompatibleLanguageModelChatInformation): Promise { @@ -57,6 +61,40 @@ function createStorageService(): IBYOKStorageService { }; } +function createResponsesBody(endpoint: IChatEndpoint): IEndpointBody { + return endpoint.createRequestBody({ + debugName: 'test', + messages: [ + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'before marker' }] + }, + { + role: Raw.ChatRole.Assistant, + content: [{ + type: Raw.ChatCompletionContentPartKind.Opaque, + value: { + type: CustomDataPartMimeTypes.StatefulMarker, + value: { + modelId: customResponsesModelId, + marker: customResponsesMarker, + } + } + }] + }, + { + role: Raw.ChatRole.User, + content: [{ type: Raw.ChatCompletionContentPartKind.Text, text: 'after marker' }] + } + ], + requestId: 'test-custom-responses-store', + postOptions: {}, + ignoreStatefulMarker: false, + finishedCb: undefined, + location: ChatLocation.Other, + }); +} + describe('CustomEndpointBYOKModelProvider', () => { const disposables = new DisposableStore(); let accessor: ITestingServicesAccessor; @@ -136,6 +174,32 @@ describe('CustomEndpointBYOKModelProvider', () => { }); describe('CustomEndpointOAIEndpoint', () => { + async function createConfiguredResponsesEndpoint(zeroDataRetentionEnabled?: boolean): Promise { + const provider = instaService.createInstance(TestCustomEndpointBYOKModelProvider, createStorageService()); + const tokenSource = disposables.add(new vscode.CancellationTokenSource()); + const modelConfiguration: CustomEndpointModelConfig = { + id: customResponsesModelId, + name: 'Custom Responses Model', + url: 'https://api.example.com', + apiType: 'responses', + maxInputTokens: 128000, + maxOutputTokens: 16000, + toolCalling: true, + vision: false, + }; + if (zeroDataRetentionEnabled !== undefined) { + modelConfiguration.zeroDataRetentionEnabled = zeroDataRetentionEnabled; + } + const [model] = await provider.provideLanguageModelChatInformation({ + silent: true, + configuration: { + apiKey: 'test-api-key', + models: [modelConfiguration], + } + }, tokenSource.token); + return provider.createEndpoint(model); + } + function makeMetadata(supportedEndpoints: ModelSupportedEndpoint[] | undefined): IChatModelInformation { return { id: 'custom-model', @@ -167,6 +231,36 @@ describe('CustomEndpointBYOKModelProvider', () => { }; } + it('omits store after cloning a Custom Endpoint Responses endpoint when zeroDataRetentionEnabled is omitted', async () => { + const endpoint = (await createConfiguredResponsesEndpoint()).cloneWithTokenOverride(64000); + const body = createResponsesBody(endpoint); + + expect({ + storePresent: 'store' in body, + store: body.store, + previousResponseId: body.previous_response_id, + }).toEqual({ + storePresent: false, + store: undefined, + previousResponseId: customResponsesMarker, + }); + }); + + it('disables store and previous_response_id for Custom Endpoint ZDR Responses requests', async () => { + const endpoint = await createConfiguredResponsesEndpoint(true); + const body = createResponsesBody(endpoint); + + expect({ + storePresent: 'store' in body, + store: body.store, + previousResponseId: body.previous_response_id, + }).toEqual({ + storePresent: true, + store: false, + previousResponseId: undefined, + }); + }); + it('uses Messages API and sends x-api-key + anthropic-version when supported_endpoints includes Messages', () => { const endpoint = instaService.createInstance(CustomEndpointOAIEndpoint, makeMetadata([ModelSupportedEndpoint.Messages]), From dbcdbc9823900857b77ec90857683924e5ea8a5a Mon Sep 17 00:00:00 2001 From: Dmitriy Vasyura Date: Wed, 19 Aug 2026 11:24:45 -0700 Subject: [PATCH 11/28] Coalesce pane composite bar layout updates (#331670) workbench: coalesce composite bar layout updates Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../workbench/browser/parts/paneCompositePart.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/vs/workbench/browser/parts/paneCompositePart.ts b/src/vs/workbench/browser/parts/paneCompositePart.ts index d06a3f5406faf1..f870dd7e27aa91 100644 --- a/src/vs/workbench/browser/parts/paneCompositePart.ts +++ b/src/vs/workbench/browser/parts/paneCompositePart.ts @@ -10,12 +10,12 @@ import { IProgressIndicator } from '../../../platform/progress/common/progress.j import { PaneComposite, PaneCompositeDescriptor, PaneCompositeRegistry } from '../panecomposite.js'; import { IPaneComposite } from '../../common/panecomposite.js'; import { IViewDescriptorService, ViewContainerLocation } from '../../common/views.js'; -import { DisposableStore, MutableDisposable } from '../../../base/common/lifecycle.js'; +import { DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js'; import { IView } from '../../../base/browser/ui/grid/grid.js'; import { IWorkbenchLayoutService, Parts, SINGLE_WINDOW_PARTS, getFloatingOuterGutterEdges, getFloatingPaneCompositeHorizontalMargins, getFloatingPaneCompositeVerticalMargins } from '../../services/layout/browser/layoutService.js'; import { CompositePart, ICompositePartOptions, ICompositeTitleLabel } from './compositePart.js'; import { IPaneCompositeBarOptions, PaneCompositeBar } from './paneCompositeBar.js'; -import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow } from '../../../base/browser/dom.js'; +import { Dimension, EventHelper, trackFocus, $, addDisposableListener, EventType, prepend, getWindow, scheduleAtNextAnimationFrame } from '../../../base/browser/dom.js'; import { Registry } from '../../../platform/registry/common/platform.js'; import { INotificationService } from '../../../platform/notification/common/notification.js'; import { IStorageService } from '../../../platform/storage/common/storage.js'; @@ -124,6 +124,7 @@ export abstract class AbstractPaneCompositePart extends CompositePart()); + private readonly pendingCompositeBarLayout = this._register(new MutableDisposable()); private compositeBarPosition: CompositeBarPosition | undefined = undefined; private emptyPaneMessageElement: HTMLElement | undefined; @@ -492,8 +493,12 @@ export abstract class AbstractPaneCompositePart extends CompositePart { + this.pendingCompositeBarLayout.clear(); + this.layoutCompositeBar(); + }); + } } async openPaneComposite(id?: string, focus?: boolean): Promise { @@ -677,6 +682,8 @@ export abstract class AbstractPaneCompositePart extends CompositePart Date: Wed, 19 Aug 2026 20:29:54 +0200 Subject: [PATCH 12/28] agentHost: keep Agent Merge monitoring sessions without a client (#331669) Agent Merge only ran while a client had the session open and subscribed, so enabling it and then navigating away silently stopped the repair/merge work until the session was reopened. The Agent Host now keeps an enabled session resident on its own: - `AgentMergeController` owns a hold on the sessions it monitors, maintained eagerly at its own state transitions and exposed as `holdsSession()`. `AgentService` skips idle eviction and empty-session GC for a held session and re-arms the release when `onDidReleaseHold` reports the hold ended, so disable/archive/terminal states go back to normal eviction. - Enablement is mirrored into an index of session URIs in the orchestrator database, so startup finds the few monitored sessions with one query plus a single-row registry lookup each instead of opening every session database. Enable, disable, archive and delete all maintain it; delete clears it in the same transaction that removes the session, and stale entries self-heal. - Provider registration (and turning the feature on) restores the indexed sessions, so monitoring resumes after a host restart and for a session never opened in this client. The index is a derived pointer only: enablement and all host-owned target state are still read from session config, and `agentMerge.controller` remains blocked from client writes. Adds regression tests for staying resident after the last unsubscribe, resuming on a fresh host and being released once disabled, and index cleanup on archive and delete. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/node/agentHostDatabase.ts | 55 +++- .../agentHost/node/agentMergeController.ts | 66 +++++ .../platform/agentHost/node/agentService.ts | 156 ++++++++++- .../agentHost/node/agentSessionRegistry.ts | 11 + .../agentHost/test/node/agentService.test.ts | 264 +++++++++++++++++- .../test/node/agentSessionRegistry.test.ts | 15 + .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 4 + 7 files changed, 566 insertions(+), 5 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostDatabase.ts b/src/vs/platform/agentHost/node/agentHostDatabase.ts index acdb0efe78dde7..32ae0e953b150e 100644 --- a/src/vs/platform/agentHost/node/agentHostDatabase.ts +++ b/src/vs/platform/agentHost/node/agentHostDatabase.ts @@ -72,6 +72,14 @@ export interface IAgentHostDatabase extends IDisposable { markSessionTombstoned(session: string): Promise; /** Clears a session's deletion tombstone (used on explicit create/restore). */ clearSessionTombstone(session: string): Promise; + /** + * Records whether Agent Merge is enabled for `session`. This host-owned index + * lets startup find the few monitored sessions without opening every session + * database. + */ + setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise; + /** Session URIs currently marked Agent-Merge-enabled. */ + listAgentMergeEnabledSessions(): Promise; close(): Promise; } @@ -154,6 +162,13 @@ function tombstoneKey(session: string): string { return `sessionTombstone:${session}`; } +const agentMergeEnabledKeyPrefix = 'agentMergeEnabled:'; + +/** Metadata key marking a session as Agent-Merge-enabled. */ +function agentMergeEnabledKey(session: string): string { + return `${agentMergeEnabledKeyPrefix}${session}`; +} + function quoteSqlString(value: string): string { return `'${value.replaceAll('\'', '\'\'')}'`; } @@ -197,8 +212,24 @@ export class AgentHostDatabase implements IAgentHostDatabase { return changes > 0; } - unregisterSession(session: string): Promise { - return this._run('DELETE FROM sessions WHERE session_uri = ?', [session]); + async unregisterSession(session: string): Promise { + const database = await this._ensureDatabase(); + try { + await exec( + database, + `BEGIN IMMEDIATE; + DELETE FROM sessions WHERE session_uri = ${quoteSqlString(session)}; + DELETE FROM metadata WHERE key = ${quoteSqlString(agentMergeEnabledKey(session))}; + COMMIT;`, + ); + } catch (error) { + try { + await exec(database, 'ROLLBACK'); + } catch (rollbackError) { + throw new AggregateError([error, rollbackError], `Failed to unregister session ${session}`); + } + throw error; + } } async tombstoneAndUnregisterSession(session: string): Promise { @@ -211,6 +242,7 @@ export class AgentHostDatabase implements IAgentHostDatabase { `BEGIN IMMEDIATE; INSERT INTO metadata (key, value) VALUES (${tombstoneValue}, 'true') ON CONFLICT(key) DO UPDATE SET value = excluded.value; + DELETE FROM metadata WHERE key = ${quoteSqlString(agentMergeEnabledKey(session))}; DELETE FROM sessions WHERE session_uri = ${sessionValue}; COMMIT;`, ); @@ -321,6 +353,25 @@ export class AgentHostDatabase implements IAgentHostDatabase { return this._run('DELETE FROM metadata WHERE key = ?', [tombstoneKey(session)]); } + setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { + return enabled + ? this._run( + `INSERT INTO metadata (key, value) VALUES (?, 'true') + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + [agentMergeEnabledKey(session)], + ) + : this._run('DELETE FROM metadata WHERE key = ?', [agentMergeEnabledKey(session)]); + } + + async listAgentMergeEnabledSessions(): Promise { + const rows = await all( + await this._ensureDatabase(), + `SELECT key FROM metadata WHERE key LIKE ? || '%' AND value = 'true'`, + [agentMergeEnabledKeyPrefix], + ); + return rows.map(row => (row.key as string).slice(agentMergeEnabledKeyPrefix.length)); + } + private async _run(sql: string, parameters: readonly unknown[]): Promise { await run(await this._ensureDatabase(), sql, parameters); } diff --git a/src/vs/platform/agentHost/node/agentMergeController.ts b/src/vs/platform/agentHost/node/agentMergeController.ts index 711c04b4bbe8d8..7563e195929d97 100644 --- a/src/vs/platform/agentHost/node/agentMergeController.ts +++ b/src/vs/platform/agentHost/node/agentMergeController.ts @@ -6,6 +6,7 @@ import { RunOnceScheduler, SequencerByKey } from '../../../base/common/async.js'; import { CancellationTokenSource } from '../../../base/common/cancellation.js'; import { structuralEquals } from '../../../base/common/equals.js'; +import { Emitter, Event } from '../../../base/common/event.js'; import { Disposable, DisposableMap, DisposableStore, MutableDisposable, toDisposable } from '../../../base/common/lifecycle.js'; import { autorun } from '../../../base/common/observable.js'; import { URI } from '../../../base/common/uri.js'; @@ -65,6 +66,13 @@ export class AgentMergeController extends Disposable { private readonly _evaluations = new SequencerByKey(); private readonly _activeTurns = new Map(); + private readonly _onDidReleaseHold = this._register(new Emitter()); + /** Fires when Agent Merge stops holding a session, so the host can re-arm its idle release. */ + readonly onDidReleaseHold: Event = this._onDidReleaseHold.event; + + /** Sessions kept resident so their monitoring survives with no client subscriber. */ + private readonly _heldSessions = new Set(); + constructor( private readonly _options: IAgentMergeControllerOptions, @IAgentHostStateManager private readonly _stateManager: AgentHostStateManager, @@ -115,6 +123,15 @@ export class AgentMergeController extends Disposable { return this._isFeatureEnabled(); } + /** + * Whether Agent Merge is keeping `session` resident. The host consults this + * before releasing an idle session, and re-arms that release when + * {@link onDidReleaseHold} reports the hold has ended. + */ + holdsSession(session: string): boolean { + return this._heldSessions.has(session); + } + onSessionAvailable(session: string): void { this._logService.trace(`[AgentMergeController] Session available: session=${session}`); this._syncSession(session); @@ -129,7 +146,53 @@ export class AgentMergeController extends Disposable { return context; } + /** + * Whether monitoring needs `session` in memory. Persisted enablement counts + * even before a runtime starts, so a restore is not evicted out from under + * the runtime that is about to claim it. + */ + private _shouldHoldSession(session: string): boolean { + if (this._runtimes.has(session)) { + return true; + } + if (!this._isFeatureEnabled()) { + return false; + } + const state = this._stateManager.getSessionState(session); + if (!state || isSessionStatusArchived(state.status)) { + return false; + } + return readAgentMergeSessionState(state.config?.values)?.enabled === true; + } + + /** + * Recomputes the hold after a state transition. Tracking it here — rather + * than lazily when the host happens to ask — keeps the answer correct for a + * session the host has never had reason to evict. + */ + private _updateHold(session: string): void { + const shouldHold = this._shouldHoldSession(session); + if (shouldHold === this._heldSessions.has(session)) { + return; + } + if (shouldHold) { + this._heldSessions.add(session); + return; + } + this._heldSessions.delete(session); + this._logService.debug(`[AgentMergeController] Released session hold: session=${session}`); + this._onDidReleaseHold.fire(session); + } + private _syncSession(session: string): void { + try { + this._doSyncSession(session); + } finally { + this._updateHold(session); + } + } + + private _doSyncSession(session: string): void { const state = this._stateManager.getSessionState(session); const agentMerge = readAgentMergeSessionState(state?.config?.values); if (!state || !agentMerge?.enabled) { @@ -653,6 +716,9 @@ export class AgentMergeController extends Disposable { this._runtimes.deleteAndDispose(session); this._logService.debug(`[AgentMergeController] Disposed session runtime: session=${session}`); } + // Also reached directly when the session is removed from state, which + // does not go through `_syncSession`. + this._updateHold(session); } private _hasTargetBranch(state: ReturnType, branchName: string): boolean { diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index f613b5b7c44c58..eaed1de1221b25 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -89,7 +89,7 @@ import { AgentHostChangesetOperationService } from './agentHostChangesetOperatio import { AgentHostGitStateService } from './agentHostGitStateService.js'; import { AgentHostGitHubEndpointService, IAgentHostGitHubEndpointService } from './agentHostGitHubEndpointService.js'; import { AgentMergeController } from './agentMergeController.js'; -import { AgentMergeConfigKey, agentMergeRootConfigSchema } from '../common/agentMerge.js'; +import { AgentMergeConfigKey, agentMergeRootConfigSchema, readAgentMergeSessionState } from '../common/agentMerge.js'; import { AgentMergeTools } from './agentMergeTools.js'; import { ITelemetryService } from '../../telemetry/common/telemetry.js'; import { NullTelemetryService } from '../../telemetry/common/telemetryUtils.js'; @@ -588,6 +588,13 @@ export class AgentService extends Disposable implements IAgentService { this._register(this._stateManager.onDidEmitEnvelope(e => this._onDidAction.fire(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._trackPendingSubagentChatFromEnvelope(e))); this._register(this._stateManager.onDidEmitEnvelope(e => this._persistAnnotations(e))); + // Archiving is terminal for Agent Merge, so the index is cleared from the + // action rather than from the controller's own disable. + this._register(this._stateManager.onDidEmitEnvelope(e => { + if (e.action.type === ActionType.SessionIsArchivedChanged && e.action.isArchived && !isAhpChatChannel(e.channel)) { + this._clearAgentMergeIndex(URI.parse(e.channel)); + } + })); this._register(this._stateManager.onDidEmitNotification(e => this._onDidNotification.fire(e))); this._register(this._stateManager.onDidChangeSessionSummary(({ session, changes }) => { const meta = this._stateManager.getSessionSummary(session)?._meta; @@ -622,6 +629,13 @@ export class AgentService extends Disposable implements IAgentService { for (const session of this._stateManager.getSessionUris()) { this._serverToolHost.advertise(session); } + // Turning the feature on resumes monitoring for persisted + // enabled sessions that are not in memory. + if (nextAgentMergeEnabled) { + this._agentMergeRestore = this._agentMergeRestore + .then(() => this._restoreAgentMergeMonitoredSessions()) + .catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err)); + } } this._onMigrateLegacySettingChanged(); })); @@ -685,6 +699,14 @@ export class AgentService extends Disposable implements IAgentService { cancelTurn: (session, turnId) => this._cancelAgentMergePrompt(session, turnId), getAutonomousSessionConfig: (session, config) => this._findProviderForSession(session)?.getAutonomousSessionConfig?.(config), })); + this._register(this._stateManager.onDidChangeSessionConfig(({ session, previous, current }) => this._syncAgentMergeIndex(URI.parse(session), previous, current))); + // A held session skipped its idle release; re-arm it once the hold ends. + this._register(this._agentMergeController.onDidReleaseHold(session => { + const resource = URI.parse(session); + if (!this._hasSessionSubscribers(resource) && this._stateManager.getSessionState(session)) { + this._scheduleSessionRelease(resource); + } + })); this._checkpointService = this._register(instantiationService.createInstance(AgentHostCheckpointService)); services.set(IAgentHostCheckpointService, this._checkpointService); @@ -1020,6 +1042,11 @@ export class AgentService extends Disposable implements IAgentService { this._initialProviderMigrations.set(provider.id, initialMigration); void initialMigration.catch(err => this._logService.warn(`[AgentService] registry migration: failed for late-registered provider ${provider.id}`, err)); + // Persisted enablement must resume without a client opening the session. + this._agentMergeRestore = this._agentMergeRestore + .then(() => initialMigration) + .then(() => this._restoreAgentMergeMonitoredSessions()) + .catch(err => this._logService.warn('[AgentService] Failed to restore Agent-Merge-enabled sessions', err)); if (!this._defaultProvider) { this._defaultProvider = provider.id; } @@ -1318,6 +1345,114 @@ export class AgentService extends Disposable implements IAgentService { }; } + private _agentMergeRestore: Promise = Promise.resolve(); + private _agentMergeIndexWrites: Promise = Promise.resolve(); + + /** Test surface: settles once the startup Agent Merge restore pass and the index writes it enqueued have run. */ + async whenAgentMergeSessionsRestored(): Promise { + // The restore pass enqueues index writes of its own, so alternate until + // both chains are quiescent. + for (let i = 0; i < 3; i++) { + await this._agentMergeIndexWrites; + await this._agentMergeRestore; + } + } + + /** + * Materializes persisted Agent-Merge-enabled sessions so monitoring resumes + * without a client opening them. The index is authoritative, so this never + * opens a database for a session that is not monitored. + */ + private async _restoreAgentMergeMonitoredSessions(): Promise { + if (!this._isAgentMergeEnabled()) { + return; + } + // A pending toggle must land before the index is read as authoritative. + await this._agentMergeIndexWrites; + const enabled = await this._sessionRegistry.listAgentMergeEnabled(); + if (enabled.length === 0) { + return; + } + const limiter = new Limiter(4); + await Promise.all(enabled.map(session => limiter.queue(async () => { + const sessionStr = session.toString(); + if (this._stateManager.getSessionState(sessionStr)) { + return; + } + try { + // A single-row registry lookup, so the pass costs one query per + // indexed session rather than a full registry enumeration. + const registered = await this._sessionRegistry.get(session, entry => this._migrateRegisteredSession(entry)); + // Deleted or unregistered since it was indexed, or archived by a + // pass that could not clear the index (e.g. a crash). + if (!registered || await this._isPersistedSessionArchived(session)) { + await this._sessionRegistry.setAgentMergeEnabled(session, false); + return; + } + // A session of a provider that registers later is picked up by + // that provider's own pass. + if (!this._providers.has(registered.provider)) { + return; + } + this._logService.info(`[AgentService] Restoring Agent-Merge-enabled session for monitoring: ${sessionStr}`); + await this.restoreSession(session); + // `restoreSession` cancels any pending release and arms none, so + // a session that never takes the hold (e.g. a stale index entry + // whose config says disabled) would otherwise stay resident with + // nothing left to release it. While the hold does apply, this + // timer simply finds the session held and stands down. + if (!this._hasSessionSubscribers(session) && this._stateManager.getSessionState(sessionStr)) { + this._scheduleSessionRelease(session); + } + } catch (err) { + this._logService.warn(`[AgentService] Failed to restore Agent-Merge-enabled session ${sessionStr}`, err); + } + }))); + } + + /** Archive check for the few indexed sessions, so a terminal session is never re-held. */ + private async _isPersistedSessionArchived(session: URI): Promise { + const ref = await this._sessionDataService.tryOpenDatabase(session); + if (!ref) { + return false; + } + try { + const metadata = await ref.object.getMetadataObject({ + [AH_META_IS_ARCHIVED_DB_KEY]: true, + [AH_META_IS_DONE_DB_KEY]: true, + }); + return (metadata[AH_META_IS_ARCHIVED_DB_KEY] ?? metadata[AH_META_IS_DONE_DB_KEY]) === 'true'; + } finally { + ref.dispose(); + } + } + + /** Mirrors a session's Agent Merge enablement into the host-owned index. */ + private _syncAgentMergeIndex(session: URI, previous: SessionConfigState | undefined, current: SessionConfigState | undefined): void { + const wasEnabled = readAgentMergeSessionState(previous?.values)?.enabled === true; + const isEnabled = readAgentMergeSessionState(current?.values)?.enabled === true; + if (wasEnabled === isEnabled) { + return; + } + this._writeAgentMergeIndex(session, isEnabled); + } + + /** Drops a session from the index when it reaches a terminal state (archived or deleted). */ + private _clearAgentMergeIndex(session: URI): void { + this._writeAgentMergeIndex(session, false); + } + + private _writeAgentMergeIndex(session: URI, enabled: boolean): void { + // A dropped enable write would silently stop the session resuming after + // a restart, so this retries like the other registry mutations. + this._agentMergeIndexWrites = this._agentMergeIndexWrites + .then(() => this._retryRegistryMutation( + () => this._sessionRegistry.setAgentMergeEnabled(session, enabled), + `Agent Merge index write for ${session.toString()}`, + )) + .catch(err => this._logService.warn(`[AgentService] Failed to update the Agent Merge index for ${session.toString()}`, err)); + } + /** * Awaits legacy migration started at provider registration. Provider-owned * discovery is independent and surfaces unknown chats additively. @@ -2417,6 +2552,9 @@ export class AgentService extends Disposable implements IAgentService { if (folderPickerDecision) { this._stateManager.setSessionMeta(session.toString(), withSessionFolderPickerDecision(this._stateManager.getSessionState(session.toString())?._meta, folderPickerDecision)); } + // Seeded config bypasses `onDidChangeSessionConfig`, so index a session + // created with Agent Merge already enabled. + this._syncAgentMergeIndex(session, undefined, sessionConfig); this._serverToolHost.advertise(session.toString()); // Persist resolved config values for restore. Mid-session updates are // persisted by `AgentSideEffects` on `SessionConfigChanged`. @@ -3789,6 +3927,10 @@ export class AgentService extends Disposable implements IAgentService { this._logService.trace(`[AgentService] Skipping GC for session that is not an unused draft: ${key}`); return false; } + // Never tear down a session Agent Merge is holding. + if (this._agentMergeController.holdsSession(this._sessionReleaseKey(resource))) { + return false; + } this._pendingSessionGc.set(resource, disposableTimeout(() => { this._pendingSessionGc.deleteAndDispose(resource); this._runSessionGc(resource).catch(err => { @@ -3872,6 +4014,12 @@ export class AgentService extends Disposable implements IAgentService { this._scheduleSessionRelease(evictionTarget); return; } + // Agent Merge keeps monitoring with no client subscriber, so releasing + // would silently stop it until someone reopened the session. + if (this._agentMergeController.holdsSession(evictionTargetKey)) { + this._logService.trace(`[AgentService] Skipping idle eviction for a session held by Agent Merge: ${evictionTargetKey}`); + return; + } if (this._releaseSessionInFlight.has(evictionTargetKey)) { return; } @@ -3888,6 +4036,9 @@ export class AgentService extends Disposable implements IAgentService { this._scheduleSessionRelease(evictionTarget); return; } + if (this._agentMergeController.holdsSession(evictionTargetKey)) { + return; + } const provider = this._findProviderForSession(evictionTarget); if (!provider) { return; @@ -4844,6 +4995,9 @@ export class AgentService extends Disposable implements IAgentService { ]); if (restoredConfig) { this._stateManager.setSessionConfig(sessionStr, restoredConfig); + // Seeded config bypasses `onDidChangeSessionConfig`, so heal the + // index for a session enabled before it was introduced. + this._syncAgentMergeIndex(session, undefined, restoredConfig); } this._agentMergeController.onSessionAvailable(sessionStr); // Seed restored session customizations into state so the very first diff --git a/src/vs/platform/agentHost/node/agentSessionRegistry.ts b/src/vs/platform/agentHost/node/agentSessionRegistry.ts index 2092e0247982a1..15f3a9a19aadbc 100644 --- a/src/vs/platform/agentHost/node/agentSessionRegistry.ts +++ b/src/vs/platform/agentHost/node/agentSessionRegistry.ts @@ -193,4 +193,15 @@ export class AgentSessionRegistry extends Disposable { async clearTombstone(session: URI): Promise { await this._database.clearSessionTombstone(session.toString()); } + + /** Maintains the host-owned index of Agent-Merge-enabled sessions. */ + async setAgentMergeEnabled(session: URI, enabled: boolean): Promise { + await this._database.setSessionAgentMergeEnabled(session.toString(), enabled); + } + + /** Session URIs the index marks Agent-Merge-enabled, without opening any session database. */ + async listAgentMergeEnabled(): Promise { + const sessions = await this._database.listAgentMergeEnabledSessions(); + return sessions.map(session => URI.parse(session)); + } } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index c1a50d5c3bf7ad..96d22c03f0353a 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -37,13 +37,14 @@ import { CodexSessionConfigKey } from '../../common/codexSessionConfigKeys.js'; import { ISessionDatabase, ISessionDataService } from '../../common/sessionDataService.js'; import { META_GITHUB_STATE, META_SOURCE_CONTROL_STATE } from '../../common/agentHostGitStateService.js'; import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; +import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; -import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseSession } from '../../node/agentHostDatabase.js'; +import { AgentHostDatabase, IAgentHostDatabase, IAgentHostDatabaseRegisterOptions, IAgentHostDatabaseSession, IAgentHostDatabaseSessionOptions } from '../../node/agentHostDatabase.js'; import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; import { AgentHostManagementService } from '../../node/agentHostManagementService.js'; import { AGENT_HOST_TITLE_SOURCE_AUTO, SESSION_CUSTOM_TITLE_SOURCE_KEY } from '../../node/shared/persistSessionMetadata.js'; @@ -218,6 +219,7 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { private _backfilled = false; private readonly _providerBackfilled = new Set(); private readonly _tombstones = new Set(); + private readonly _agentMergeEnabled = new Set(); registryWriteAttempts = 0; private _remainingRegistryWriteFailures = 0; private readonly _sessionsWithoutExternal = new Set(); @@ -256,12 +258,14 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { async unregisterSession(session: string): Promise { this._beforeWrite(); this._sessions.delete(session); + this._agentMergeEnabled.delete(session); } async tombstoneAndUnregisterSession(session: string): Promise { this._beforeWrite(); this._tombstones.add(session); this._sessions.delete(session); + this._agentMergeEnabled.delete(session); } async updateSessionExternal(updates: readonly { readonly session: string; readonly external: boolean }[]): Promise { @@ -326,6 +330,18 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { this._tombstones.delete(session); } + async setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { + if (enabled) { + this._agentMergeEnabled.add(session); + } else { + this._agentMergeEnabled.delete(session); + } + } + + async listAgentMergeEnabledSessions(): Promise { + return [...this._agentMergeEnabled]; + } + async close(): Promise { } dispose(): void { } @@ -338,6 +354,96 @@ class TransientRegistryWriteDatabase implements IAgentHostDatabase { } } +/** In-memory orchestrator database that two {@link AgentService} instances can share to simulate a host restart. */ +class TestAgentHostOrchestratorDatabase implements IAgentHostDatabase { + private readonly _sessions = new Map(); + private readonly _providerBackfilled = new Set(); + private readonly _tombstones = new Set(); + private readonly _agentMergeEnabled = new Set(); + private _backfilled = false; + + async registerSession(session: string, sessionOptions: IAgentHostDatabaseSessionOptions, registerOptions: IAgentHostDatabaseRegisterOptions): Promise { + if (registerOptions.checkTombstone && this._tombstones.has(session)) { + return false; + } + const { provider, startTime, source } = sessionOptions; + const existing = this._sessions.get(session); + this._sessions.set(session, existing ?? { session, provider, startTime, external: source === 'discovery', source }); + if (!registerOptions.checkTombstone) { + this._tombstones.delete(session); + } + return true; + } + + async unregisterSession(session: string): Promise { + this._sessions.delete(session); + this._agentMergeEnabled.delete(session); + } + + async tombstoneAndUnregisterSession(session: string): Promise { + this._tombstones.add(session); + this._sessions.delete(session); + this._agentMergeEnabled.delete(session); + } + + async updateSessionExternal(): Promise { } + + async listSessions(): Promise { + return [...this._sessions.values()]; + } + + async getSession(session: string): Promise { + return this._sessions.get(session); + } + + async isSessionRegistryEmpty(): Promise { + return this._sessions.size === 0; + } + + async isSessionRegistryBackfilled(): Promise { + return this._backfilled; + } + + async markSessionRegistryBackfilled(): Promise { + this._backfilled = true; + } + + async isProviderBackfilled(provider: string): Promise { + return this._providerBackfilled.has(provider); + } + + async markProviderBackfilled(provider: string): Promise { + this._providerBackfilled.add(provider); + } + + async isSessionTombstoned(session: string): Promise { + return this._tombstones.has(session); + } + + async markSessionTombstoned(session: string): Promise { + this._tombstones.add(session); + } + + async clearSessionTombstone(session: string): Promise { + this._tombstones.delete(session); + } + + async setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { + if (enabled) { + this._agentMergeEnabled.add(session); + } else { + this._agentMergeEnabled.delete(session); + } + } + + async listAgentMergeEnabledSessions(): Promise { + return [...this._agentMergeEnabled]; + } + + async close(): Promise { } + dispose(): void { } +} + suite('AgentService (node dispatcher)', () => { const disposables = new DisposableStore(); @@ -12010,4 +12116,158 @@ suite('AgentService (node dispatcher)', () => { assertBackingChangesetsComputing(localService.stateManager, sessionStr); }); }); + + suite('Agent Merge durable session monitoring', () => { + + function createAgentMergeService(sessionDb: TestSessionDatabase, orchestratorDb: IAgentHostDatabase): AgentService { + const localService = disposables.add(new AgentService( + new NullLogService(), fileService, createSessionDataService(sessionDb), + { _serviceBrand: undefined } as IProductService, createNoopGitService(), + undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, orchestratorDb, + )); + localService.configurationService.updateRootConfig({ [AgentMergeConfigKey.Enabled]: true }); + return localService; + } + + async function createEnabledSession(sessionDb: TestSessionDatabase, orchestratorDb: IAgentHostDatabase): Promise<{ readonly localService: AgentService; readonly localAgent: MockAgent; readonly sessionResource: URI }> { + const localAgent = new MockAgent('copilot'); + disposables.add(toDisposable(() => localAgent.dispose())); + // Restore resolves a config only when the session has persisted + // values; without one a `SessionConfigChanged` would be a no-op. + await sessionDb.setMetadata('configValues', '{}'); + const localService = createAgentMergeService(sessionDb, orchestratorDb); + localService.registerProvider(localAgent); + const { session } = await createAgentSession(localAgent); + localAgent.sessionMessages = [ + { type: 'message', session, role: 'user', messageId: 'msg-1', content: 'Hello', toolRequests: [] }, + { type: 'message', session, role: 'assistant', messageId: 'msg-2', content: 'Hi', toolRequests: [] }, + ]; + const sessionResource = (await localAgent.listSessions())[0].session; + await localService.restoreSession(sessionResource); + localService.configurationService.updateSessionConfig(sessionResource.toString(), { [SessionConfigKey.AgentMerge]: { enabled: true } }); + await localService.whenAgentMergeSessionsRestored(); + return { localService, localAgent, sessionResource }; + } + + test('an Agent-Merge-enabled session stays resident after its last subscriber drops, and is released once disabled', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const { localService, sessionResource } = await createEnabledSession(new TestSessionDatabase(), orchestratorDb); + const sessionStr = sessionResource.toString(); + localService.addSubscriber(sessionResource, 'client-1'); + + localService.unsubscribe(sessionResource, 'client-1'); + await new Promise(resolve => setTimeout(resolve, 60_000)); + const residentWhileEnabled = localService.stateManager.getSessionState(sessionStr) !== undefined; + + localService.configurationService.updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + await new Promise(resolve => setTimeout(resolve, 60_000)); + + assert.deepStrictEqual({ + residentWhileEnabled, + residentAfterDisable: localService.stateManager.getSessionState(sessionStr) !== undefined, + indexedAfterDisable: await orchestratorDb.listAgentMergeEnabledSessions(), + }, { + residentWhileEnabled: true, + residentAfterDisable: false, + indexedAfterDisable: [], + }); + }); + }); + + test('enabling records the session in the host index rather than in each session database', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const { sessionResource } = await createEnabledSession(new TestSessionDatabase(), orchestratorDb); + + assert.deepStrictEqual(await orchestratorDb.listAgentMergeEnabledSessions(), [sessionResource.toString()]); + }); + + test('a persisted Agent-Merge-enabled session begins monitoring on a fresh host, and is released once disabled', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sessionDb = new TestSessionDatabase(); + const { localAgent, sessionResource } = await createEnabledSession(sessionDb, orchestratorDb); + const sessionStr = sessionResource.toString(); + + // A fresh host over the same durable state must resume monitoring + // from the index alone. + const restarted = createAgentMergeService(sessionDb, orchestratorDb); + restarted.registerProvider(localAgent); + await restarted.whenAgentMergeSessionsRestored(); + const resumed = { + materialized: restarted.stateManager.getSessionState(sessionStr) !== undefined, + // Distinguishes a genuine resume from a session that was + // materialized and immediately disabled. + enabled: readAgentMergeSessionState(restarted.stateManager.getSessionState(sessionStr)?.config?.values)?.enabled, + indexed: await orchestratorDb.listAgentMergeEnabledSessions(), + }; + + // Nothing ever subscribed, so only the monitoring pin is holding + // this session resident; disabling must let it go. + restarted.configurationService.updateSessionConfig(sessionStr, { [SessionConfigKey.AgentMerge]: { enabled: false } }); + await new Promise(resolve => setTimeout(resolve, 60_000)); + + assert.deepStrictEqual({ + resumed, + residentAfterDisable: restarted.stateManager.getSessionState(sessionStr) !== undefined, + }, { + resumed: { materialized: true, enabled: true, indexed: [sessionStr] }, + residentAfterDisable: false, + }); + }); + }); + + test('an archived session is dropped from the index instead of being restored', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const sessionDb = new TestSessionDatabase(); + const { localAgent, sessionResource } = await createEnabledSession(sessionDb, orchestratorDb); + await sessionDb.setMetadata(AH_META_IS_ARCHIVED_DB_KEY, 'true'); + + const restarted = createAgentMergeService(sessionDb, orchestratorDb); + restarted.registerProvider(localAgent); + await restarted.whenAgentMergeSessionsRestored(); + + assert.deepStrictEqual({ + materialized: restarted.stateManager.getSessionState(sessionResource.toString()) !== undefined, + indexed: await orchestratorDb.listAgentMergeEnabledSessions(), + }, { + materialized: false, + indexed: [], + }); + }); + + test('archiving a session drops it from the index', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const { localService, sessionResource } = await createEnabledSession(new TestSessionDatabase(), orchestratorDb); + + localService.stateManager.dispatchServerAction(sessionResource.toString(), { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + await localService.whenAgentMergeSessionsRestored(); + + assert.deepStrictEqual(await orchestratorDb.listAgentMergeEnabledSessions(), []); + }); + + test('deleting a session drops it from the index', async () => { + const orchestratorDb = new TestAgentHostOrchestratorDatabase(); + const { localService, sessionResource } = await createEnabledSession(new TestSessionDatabase(), orchestratorDb); + + await localService.disposeSession(sessionResource); + + assert.deepStrictEqual(await orchestratorDb.listAgentMergeEnabledSessions(), []); + }); + + test('an archived Agent-Merge session is released like any other idle session', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const { localService, sessionResource } = await createEnabledSession(new TestSessionDatabase(), new TestAgentHostOrchestratorDatabase()); + const sessionStr = sessionResource.toString(); + localService.addSubscriber(sessionResource, 'client-1'); + // Archiving is the terminal state that must not keep the session pinned. + localService.stateManager.dispatchServerAction(sessionStr, { type: ActionType.SessionIsArchivedChanged, isArchived: true }); + + localService.unsubscribe(sessionResource, 'client-1'); + await new Promise(resolve => setTimeout(resolve, 60_000)); + + assert.strictEqual(localService.stateManager.getSessionState(sessionStr), undefined, 'an archived session must not stay pinned'); + }); + }); + }); }); diff --git a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts index 29a156a1306853..f63f03303a26bb 100644 --- a/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts +++ b/src/vs/platform/agentHost/test/node/agentSessionRegistry.test.ts @@ -12,6 +12,7 @@ import { AgentSessionRegistry } from '../../node/agentSessionRegistry.js'; class TestAgentHostDatabase implements IAgentHostDatabase { readonly sessions = new Map(); + readonly agentMergeEnabled = new Set(); backfilled = false; private readonly _providerBackfilled = new Set(); private readonly _tombstones = new Set(); @@ -123,6 +124,20 @@ class TestAgentHostDatabase implements IAgentHostDatabase { this._tombstones.delete(session); } + async setSessionAgentMergeEnabled(session: string, enabled: boolean): Promise { + this._throwWriteFailure(); + if (enabled) { + this.agentMergeEnabled.add(session); + } else { + this.agentMergeEnabled.delete(session); + } + } + + async listAgentMergeEnabledSessions(): Promise { + this._throwReadFailure(); + return [...this.agentMergeEnabled]; + } + async close(): Promise { } dispose(): void { } diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 11b0acce48a947..4780d8f7fdb63b 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -79,6 +79,10 @@ The controller subscribes to the reusable platform GitHub service at background Repair turns receive only pull-request-bound tools for failed CI details, attributed review-thread replies and resolution, and failed-workflow reruns. Those tools are advertised only while Agent Merge is enabled, so a host with the feature off exposes no Agent Merge surface to any provider. Pull-request feedback and CI content remain untrusted. The agent is never authorized to merge. The controller claims a turn only while no chat in the session has an active turn, and cancels a repair turn it started when Agent Merge stops. Before merging it re-reads live enablement, configuration, and target, then directly merges or enqueues through the GitHub service. Merge preparation captures an authoritative snapshot of every fragment the gate reads and refreshes top-level comments last, so a merge cannot race newly posted maintainer feedback. +Monitoring is durable and independent of any client. Agent Merge is a host-owned background activity, not a view of an open session: an enabled session keeps monitoring and repairing after the user navigates away, closes the session, or disconnects entirely. Two host lifecycle rules make that true. A session the controller is monitoring is exempt from idle-session eviction and from empty-session garbage collection, so losing the last subscriber never stops the work; when monitoring stops — disabled, archived, branch or pull-request retargeted, merged or closed, or attempt budget exhausted — the controller reports that, and the host re-arms the normal idle release it had suppressed. Independently, host startup and provider registration resume monitoring for enabled sessions, so it survives a host restart and works for a session that was never opened in this client. Turning the global feature on runs the same pass. These rules are provider-neutral and apply equally to a local or remote Agent Host, since both run this controller inside their own host process. + +Enablement is discoverable without a scan. The orchestrator database holds an index of Agent-Merge-enabled session URIs alongside the session registry, so startup finds the few monitored sessions with one query plus a single-row registry lookup each, instead of opening every session database; restoring those few sessions then costs what any restore costs. The index is a derived pointer, never the authorization state: the enabled flag and all host-owned target state still live in session config, and the startup pass re-reads them after restoring. Enabling, disabling, archiving, and deleting all maintain the index, and deletion clears it in the same transaction that removes the session. A stale entry (left by a crash) is self-healing: an entry whose session is unregistered or archived is dropped on the next pass instead of being restored. A session restored purely for monitoring is armed with the normal idle release, which the monitoring pin then suppresses, so it becomes evictable again the moment monitoring ends rather than being pinned for the life of the host. + The Agents Window initially exposes command-palette actions only: enable, disable, and configure the active Agent Host session. Enable and disable are offered only when they apply to the active session. Configure uses an accessible multi-select Quick Pick whose title button resets the session to the global defaults, so resetting can never silently override a visible selection. Diagnostics use the `AgentMergeController`, `AgentMergeTools`, and `AgentMergeActions` log prefixes. Lifecycle and outcomes are logged at info/debug level, repeated evaluation details at trace level, and rejected or exhausted operations at warning/error level. Logs include session and turn identifiers plus counts and enum-like outcomes, but never pull-request comment bodies, CI log contents, prompt text, credentials, or local paths. From 0e18182463e0ec54aa1fb4b2213ff9cc7a84644a Mon Sep 17 00:00:00 2001 From: vritant24 Date: Wed, 19 Aug 2026 11:34:57 -0700 Subject: [PATCH 13/28] byok: test explicit custom endpoint retention Cover the explicit false state so Custom Endpoint Responses requests continue sending store true and reusing previous_response_id. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../test/customEndpointProvider.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts index 5641d746684e45..0602c8ce23bf60 100644 --- a/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts +++ b/extensions/copilot/src/extension/byok/vscode-node/test/customEndpointProvider.spec.ts @@ -246,6 +246,21 @@ describe('CustomEndpointBYOKModelProvider', () => { }); }); + it('enables store and previous_response_id for Custom Endpoint Responses requests when zeroDataRetentionEnabled is false', async () => { + const endpoint = await createConfiguredResponsesEndpoint(false); + const body = createResponsesBody(endpoint); + + expect({ + storePresent: 'store' in body, + store: body.store, + previousResponseId: body.previous_response_id, + }).toEqual({ + storePresent: true, + store: true, + previousResponseId: customResponsesMarker, + }); + }); + it('disables store and previous_response_id for Custom Endpoint ZDR Responses requests', async () => { const endpoint = await createConfiguredResponsesEndpoint(true); const body = createResponsesBody(endpoint); From 3c803dc97b9b0f4cd1b045b35741c3f0e23c563b Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 19 Aug 2026 20:47:21 +0200 Subject: [PATCH 14/28] agentHost: Harden session catalog migration (#331679) agentHost: harden session catalog migration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/vs/platform/agentHost/AGENTS.md | 6 +- src/vs/platform/agentHost/common/agent.ts | 7 +- .../platform/agentHost/node/agentService.ts | 36 ++++-- .../agentHost/test/node/agentService.test.ts | 113 +++++++++++++++++- .../agentHost/test/node/claudeAgent.test.ts | 7 +- .../test/node/codex/codexAgent.test.ts | 16 ++- .../agentHost/test/node/copilotAgent.test.ts | 59 ++++----- .../platform/agentHost/test/node/mockAgent.ts | 4 +- .../agentHost/agentHostSessionListStore.ts | 10 -- .../agentHostChatContribution.test.ts | 24 ++++ 10 files changed, 211 insertions(+), 71 deletions(-) diff --git a/src/vs/platform/agentHost/AGENTS.md b/src/vs/platform/agentHost/AGENTS.md index e74a0fc658f77f..bd75274eec2430 100644 --- a/src/vs/platform/agentHost/AGENTS.md +++ b/src/vs/platform/agentHost/AGENTS.md @@ -219,9 +219,11 @@ Claude and Codex each use one memoized initial path: resolve/download the SDK, e If a provider cannot enumerate yet, its initial discovery attempt emits nothing; once ready, it emits the resulting chats through `onDidDiscoverChats`. Registry provenance is projected into `IAgentSessionMetadata._meta` with `readSessionExternal` / `withSessionExternal`, and the normal AHP listSessions round trip carries it to the Sessions provider. There is no external-specific UI behavior. -`listSessions()` coalesces concurrent computations per external-sessions mode, so the burst of calls a multi-window restore produces shares one registry traversal instead of one per window. The shared entry records the registry epoch it started at and is invalidated by every registry mutation, so a caller arriving after a mutation starts a fresh pass; each caller receives its own array. +`listSessions()` coalesces concurrent computations per external-sessions mode, so the burst of calls a multi-window restore produces shares one registry traversal instead of one per window. The shared entry records the registry epoch it started at and is invalidated by every registry mutation. A computation whose epoch changes restarts against the new registry, so both existing and later callers receive a complete post-mutation snapshot; each caller receives its own array. -Legacy registry migration remains a separate `listChatsToMigrate()` contract. It returns only chats known from non-empty provider session metadata, without external provenance, and is gated by durable per-provider/global migration markers. Agent Host writes `agentHost.workspaceless` as either `true` or `false` into every session it creates. Agent Service classifies each migration candidate itself: marker presence means internal, while absence means a known external chat. +Legacy registry migration uses the `listChatsToMigrate()` contract. An array is authoritative even when empty, while `undefined` means the catalog is unavailable and must not advance migration markers. Agent Service retries an unavailable registration-time catalog once before listing; persistent unavailability rejects the aggregate `listSessions()` call with a typed provider-catalog error so clients preserve their last successful snapshots and retry with their existing backoff. Replacement retry ownership is compare-and-swap single-flight: overlapping list computations that observed the same failed attempt await the first caller's installed retry rather than queueing another provider enumeration. Successful providers retain their completed migration state when a sibling provider is unavailable. + +Session-list clients treat only a successful return as authoritative. `BaseAgentHostSessionsProvider` and `AgentHostSessionListStore` retain their last successful snapshots when `listSessions()` rejects; a successful empty array still clears the snapshot. This separation prevents transport, authentication, or catalog failures from becoming deletion deltas. Provider-private discovery helpers name their concrete source: Claude uses `_listClaudeCodeChats()` / `_emitClaudeCodeChats()`, Codex uses `_listCodexChats()` / `_emitCodexChats()`, and Copilot uses `_discoverCopilotChats()` / `_emitCopilotChats()`. Providers filter known session metadata before emitting; Agent Service still performs the authoritative additive registry write and atomic tombstone check. Copilot treats the existence of a per-session database (under `{userDataPath}/agentSessionData`, never the shared Copilot home) as "known", which also keeps peer-chat backings out of the payload; it additionally drops a chat whose SDK context carries no working directory, because `_doResumeSession` requires one and a discovered chat has no other source for it. diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index 84e23cf0488d65..e79a6ee528b2a6 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1148,12 +1148,7 @@ export interface IAgent { /** Optional recovery hook for providers with historical backings but no persisted provider data. */ recoverLegacyChat?(chat: URI, context: URI | IAgentChatContext): Promise; - /** - * Enumerate provider-native chats for one-time registry migration. - * - * Returns `undefined` when the provider cannot enumerate yet; `[]` is an - * authoritative result indicating there are no legacy chats to migrate. - */ + /** Enumerate provider-native chats for registry migration; `undefined` means the catalog is unavailable. */ listChatsToMigrate(): Promise; /** Optional migration codec for providers that persisted peer backings before the host catalog. */ diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index eaed1de1221b25..72760e71437720 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -289,6 +289,13 @@ interface IProviderDiscoveryState { forceQueued: boolean; } +class ProviderCatalogUnavailableError extends Error { + constructor(readonly provider: AgentProvider) { + super(`Provider ${provider} cannot enumerate its native session catalog yet`); + this.name = 'ProviderCatalogUnavailableError'; + } +} + /** * Reconcile a session's working-directory set from a create-result / * materialization receipt. The resolved receipt is authoritative for the roots @@ -1459,21 +1466,30 @@ export class AgentService extends Disposable implements IAgentService { */ private async _awaitInitialProviderMigration(): Promise { const providers = [...this._providers.values()]; - const results = await Promise.allSettled(providers.map(provider => this._initialProviderMigrations.get(provider.id) ?? Promise.resolve())); + const migrations = providers.map(provider => this._initialProviderMigrations.get(provider.id) ?? Promise.resolve()); + const results = await Promise.allSettled(migrations); const retries: Promise[] = []; for (let index = 0; index < results.length; index++) { const result = results[index]; if (result.status === 'rejected') { const provider = providers[index]; this._logService.warn(`[AgentService] initial provider catalog for ${provider.id} was unavailable; retrying before listing sessions`, result.reason); - const retry = this._ensureLegacyChatsMigrated(provider, true); - this._initialProviderMigrations.set(provider.id, retry); - retries.push(retry); + retries.push(this._replaceFailedInitialProviderMigration(provider, migrations[index])); } } await Promise.all(retries); } + private _replaceFailedInitialProviderMigration(provider: IAgent, failed: Promise): Promise { + const current = this._initialProviderMigrations.get(provider.id); + if (current !== failed) { + return current ?? Promise.resolve(); + } + const retry = this._ensureLegacyChatsMigrated(provider, true); + this._initialProviderMigrations.set(provider.id, retry); + return retry; + } + /** * Runs one provider discovery at most once concurrently, sharing the * in-flight attempt across callers and clearing it on settle so failures @@ -1634,7 +1650,7 @@ export class AgentService extends Disposable implements IAgentService { } const sessions = await this._enumerateLegacyProviderSessions(provider); if (sessions === undefined) { - throw new Error(`Provider ${provider.id} cannot enumerate its native session catalog yet`); + throw new ProviderCatalogUnavailableError(provider.id); } const existing = new Map((await this._listRegisteredSessions()).map(session => [session.session.toString(), session.external])); const migrationLimiter = new Limiter(4); @@ -1774,7 +1790,7 @@ export class AgentService extends Disposable implements IAgentService { // Callers own their array; the shared result must not be mutable by one of them. return [...await inFlight.promise]; } - const promise = this._computeSessions(mode); + const promise = this._computeSessions(mode, epoch); const entry = { epoch, promise }; this._inFlightListSessions.set(mode, entry); const clear = () => { @@ -1786,11 +1802,14 @@ export class AgentService extends Disposable implements IAgentService { return [...await promise]; } - private async _computeSessions(mode: AgentHostExternalSessionsMode): Promise { + private async _computeSessions(mode: AgentHostExternalSessionsMode, epoch = this._registryEpoch): Promise { this._logService.trace('[AgentService] listSessions computation started'); const startedAt = Date.now(); // The first list waits for registration-time legacy migration if it is still in flight. await this._awaitInitialProviderMigration(); + if (epoch !== this._registryEpoch) { + return this.listSessions(mode); + } // The registry is the source of truth for top-level sessions. Internal // chat backings and subagent sessions never enter it; ephemeral sessions // are tombstoned at creation. A transiently missing provider snapshot no @@ -2012,6 +2031,9 @@ export class AgentService extends Disposable implements IAgentService { } else { this._logService.trace(message); } + if (epoch !== this._registryEpoch) { + return this.listSessions(mode); + } return visible; } diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index 96d22c03f0353a..ff098e1f46d7e5 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -4084,16 +4084,22 @@ suite('AgentService (node dispatcher)', () => { assert.strictEqual(agent.listExternalChatsCalls, 1); }); - test('listSessions rejects an unavailable migration catalog and retries it on the next call', async () => { + test('listSessions rejects an unavailable catalog and retries it on the next call', async () => { class NotYetMigratableAgent extends MockAgent { + override readonly onDidDiscoverChats = Event.None; migrationCalls = 0; enumerable = false; } - const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const db = new TransientRegistryWriteDatabase(); + const existing = AgentSession.uri('copilot', 'existing-before-unavailable'); + await db.registerSession(existing.toString(), { provider: 'copilot', startTime: 1, source: 'explicit' }, { checkTombstone: false }); + const writesBeforeUnavailable = db.registryWriteAttempts; + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); const agent = disposables.add(new NotYetMigratableAgent('copilot')); const legacy = AgentSession.uri('copilot', 'legacy-migration-not-ready'); (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(legacy), legacy); + (agent as unknown as { _sessions: Map })._sessions.set(AgentSession.id(existing), existing); (agent as unknown as { listChatsToMigrate: () => Promise }).listChatsToMigrate = async () => { agent.migrationCalls++; return agent.enumerable @@ -4101,20 +4107,115 @@ suite('AgentService (node dispatcher)', () => { : undefined; }; svc.registerProvider(agent); - await assert.rejects(svc.listSessions(), /cannot enumerate its native session catalog yet/); + await assert.rejects(svc.listSessions(), error => { + assert.ok(error instanceof Error); + const provider = Object.entries(error).find(([key]) => key === 'provider')?.[1]; + assert.deepStrictEqual({ + name: error.name, + provider, + }, { + name: 'ProviderCatalogUnavailableError', + provider: 'copilot', + }); + return true; + }); const callsAfterFailure = agent.migrationCalls; + assert.deepStrictEqual({ + registryWrites: db.registryWriteAttempts, + registered: (await svc.getRegisteredSessions()).map(session => session.toString()), + }, { + registryWrites: writesBeforeUnavailable, + registered: [existing.toString()], + }); agent.enumerable = true; - await timeout(0); const listed = await svc.listSessions(); assert.deepStrictEqual({ retriedBeforeFailure: callsAfterFailure > 1, retriedAfterFailure: agent.migrationCalls > callsAfterFailure, - listed: listed.map(session => session.session.toString()), + listed: listed.map(session => session.session.toString()).sort(), }, { retriedBeforeFailure: true, retriedAfterFailure: true, - listed: [legacy.toString()], + listed: [existing.toString(), legacy.toString()].sort(), + }); + }); + + test('overlapping mode computations share ownership of a replacement migration retry', async () => { + const retryGate = new DeferredPromise(); + class SingleFlightRetryAgent extends MockAgent { + catalogCalls = 0; + override async listChatsToMigrate(): Promise { + this.catalogCalls++; + if (this.catalogCalls === 1) { + return undefined; + } + await retryGate.p; + return []; + } + } + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService())); + const agent = disposables.add(new SingleFlightRetryAgent('copilot')); + svc.registerProvider(agent); + for (let i = 0; i < 20 && agent.catalogCalls === 0; i++) { + await timeout(0); + } + + const all = svc.listSessions(AgentHostExternalSessionsMode.All); + const recent = svc.listSessions(AgentHostExternalSessionsMode.Recent); + for (let i = 0; i < 20 && agent.catalogCalls < 2; i++) { + await timeout(0); + } + assert.strictEqual(agent.catalogCalls, 2, 'overlapping computations must share the replacement retry'); + retryGate.complete(); + await Promise.all([all, recent]); + assert.strictEqual(agent.catalogCalls, 2, 'a losing caller must await the installed retry instead of queueing another'); + }); + + test('concurrent aggregate listings retry only the unavailable provider', async () => { + class CatalogAgent extends MockAgent { + catalogCalls = 0; + available = true; + override async listChatsToMigrate(): Promise { + this.catalogCalls++; + return this.available + ? this.listExternalChats() + : undefined; + } + } + const db = new TransientRegistryWriteDatabase(); + const svc = disposables.add(new AgentService(new NullLogService(), fileService, createSessionDataService(), { _serviceBrand: undefined } as IProductService, createNoopGitService(), undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, db)); + svc.configurationService.updateRootConfig({ [AgentHostShowExternalSessionsConfigKey]: AgentHostExternalSessionsMode.All }); + const copilot = disposables.add(new CatalogAgent('copilot')); + const claude = disposables.add(new CatalogAgent('claude')); + const copilotSession = AgentSession.uri('copilot', 'complete-provider'); + const claudeSession = AgentSession.uri('claude', 'unavailable-provider'); + (copilot as unknown as { _sessions: Map })._sessions.set(AgentSession.id(copilotSession), copilotSession); + (claude as unknown as { _sessions: Map })._sessions.set(AgentSession.id(claudeSession), claudeSession); + claude.available = false; + svc.registerProvider(copilot); + svc.registerProvider(claude); + + await assert.rejects(Promise.all([svc.listSessions(), svc.listSessions()]), /cannot enumerate its native session catalog yet/); + const callsAfterFailure = { copilot: copilot.catalogCalls, claude: claude.catalogCalls }; + claude.available = true; + const [first, second] = await Promise.all([svc.listSessions(), svc.listSessions()]); + + assert.deepStrictEqual({ + callsAfterFailure, + finalCalls: { copilot: copilot.catalogCalls, claude: claude.catalogCalls }, + backfilled: { + copilot: await db.isProviderBackfilled('copilot'), + claude: await db.isProviderBackfilled('claude'), + }, + first: first.map(session => session.session.toString()).sort(), + second: second.map(session => session.session.toString()).sort(), + }, { + callsAfterFailure: { copilot: 1, claude: 2 }, + finalCalls: { copilot: 1, claude: 3 }, + backfilled: { copilot: true, claude: true }, + first: [claudeSession.toString(), copilotSession.toString()].sort(), + second: [claudeSession.toString(), copilotSession.toString()].sort(), }); }); diff --git a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts index 13026fe576e599..298c1648327a4a 100644 --- a/src/vs/platform/agentHost/test/node/claudeAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/claudeAgent.test.ts @@ -4931,7 +4931,7 @@ suite('ClaudeAgent', () => { modifiedB: b?.modifiedTime, sdkCalls: sdk.listSessionsCallCount, availabilityRequests: sdk.ensureAvailableForDiscoveryCalls, - migrationChats: chatsToMigrate.map(r => sessionIdOfChat(r.chat)), + migrationChats: chatsToMigrate?.map(r => sessionIdOfChat(r.chat)), }, { count: 3, ids: ['a', 'b', 'c'], @@ -4943,6 +4943,11 @@ suite('ClaudeAgent', () => { availabilityRequests: 1, migrationChats: ['a'], }); + + sdk.sessionList = []; + assert.deepStrictEqual(await agent.listChatsToMigrate(), []); + sdk.listSessionsRejection = new Error('catalog unavailable'); + assert.strictEqual(await agent.listChatsToMigrate(), undefined); }); test('native discovery emits only unknown Claude Code chats as external', async () => { diff --git a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts index 9c06122a80968d..893542b48e4fcb 100644 --- a/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/codex/codexAgent.test.ts @@ -432,7 +432,8 @@ suite('CodexAgent', () => { _resolveSdkRoot(): Promise; _listCodexChats(): Promise; _isKnownCodexChat(chat: (typeof chats)[number]): Promise; - }): Promise; + _logService: NullLogService; + }): Promise; }).listChatsToMigrate; const result = await listChatsToMigrate.call({ @@ -442,9 +443,22 @@ suite('CodexAgent', () => { const id = AgentSession.id(URI.parse(parseRequiredSessionUriFromChatUri(chat.chat))); return id !== 'unknown-external'; }, + _logService: new NullLogService(), }); assert.deepStrictEqual(result, chats.slice(0, 2)); + assert.deepStrictEqual(await listChatsToMigrate.call({ + _resolveSdkRoot: async () => '/sdk-root', + _listCodexChats: async () => [], + _isKnownCodexChat: async () => false, + _logService: new NullLogService(), + }), []); + assert.deepStrictEqual(await listChatsToMigrate.call({ + _resolveSdkRoot: async () => { throw new Error('SDK unavailable'); }, + _listCodexChats: async () => [], + _isKnownCodexChat: async () => false, + _logService: new NullLogService(), + }), undefined); }); test('native discovery emits only unknown Codex chats as external', async () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index 6a86a310ca9807..d11689003a6a76 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1834,11 +1834,10 @@ suite('CopilotAgent', () => { const client = new TestCopilotClient([sdkSession('owned-before-auth')]); const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); + const catalog = await agent.listChatsToMigrate(); assert.deepStrictEqual({ models: agent.models.get(), - sessions: sessions.map(session => sessionIdOfChat(session.chat)), + sessions: catalog?.map(session => sessionIdOfChat(session.chat)), starts: client.startCallCount, listCalls: client.listSessionCallCount, }, { @@ -2432,11 +2431,11 @@ suite('CopilotAgent', () => { } }); - test('surfaces undefined (not a rejection) for a classified Copilot client startup failure', async () => { + test('surfaces unavailable (not a rejection) for a classified Copilot client startup failure', async () => { // A recognized startup error means the CLI client is transiently // unavailable, not that this provider authoritatively has no legacy - // chats: `listChatsToMigrate` must resolve to `undefined` (still reporting - // the failure via telemetry) rather than reject or return `[]`. + // chats: `listChatsToMigrate` must report unavailable (still reporting + // the failure via telemetry) rather than reject or return complete-empty. const client = new TestCopilotClient([]); client.startError = new Error('Failed to start CLI server: spawn failed'); const telemetryService = new RecordingTelemetryService(); @@ -2513,8 +2512,8 @@ suite('CopilotAgent', () => { for (const testCase of cases) { client.startError = new Error(testCase.message); // All of these are recognized startup failures: the client is - // transiently unavailable, so `listChatsToMigrate` resolves to - // `undefined` (still reporting telemetry below) rather than + // transiently unavailable, so `listChatsToMigrate` reports + // unavailable (still reporting telemetry below) rather than // rejecting. assert.strictEqual(await agent.listChatsToMigrate(), undefined); } @@ -2929,7 +2928,7 @@ suite('CopilotAgent', () => { // Shutting down mid-start is "client transiently unavailable", not // an authoritative "no chats to migrate" answer, so `listChatsToMigrate` - // resolves to `undefined` rather than rejecting with the + // reports unavailable rather than rejecting with the // `CancellationError` that `_ensureClient` itself throws. assert.strictEqual(await listPromise, undefined); await shutdownPromise; @@ -3049,9 +3048,8 @@ suite('CopilotAgent', () => { const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client }); try { await agent.authenticate(GITHUB_COPILOT_PROTECTED_RESOURCE.resource, 'token'); - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); - const listed = sessions.find(s => sessionIdOfChat(s.chat) === sessionId); + const catalog = await agent.listChatsToMigrate(); + const listed = catalog?.find(s => sessionIdOfChat(s.chat) === sessionId); const chat = defaultChatUri(session); const meta = await agent.getChatMetadata(chat, exactChatContext(session, chat, session)); return { @@ -4985,9 +4983,8 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); - assert.deepStrictEqual(sessions.map(s => sessionIdOfChat(s.chat)), ['owned']); + const catalog = await agent.listChatsToMigrate(); + assert.deepStrictEqual(catalog?.map(s => sessionIdOfChat(s.chat)), ['owned']); } finally { await disposeAgent(agent); } @@ -5004,9 +5001,8 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); - assert.deepStrictEqual(sessions.map(withoutUndefinedProperties), [{ + const catalog = await agent.listChatsToMigrate(); + assert.deepStrictEqual(catalog?.map(withoutUndefinedProperties), [{ chat: defaultChatUri(legacySession), startTime: 1000, modifiedTime: 2000, @@ -5034,9 +5030,8 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); - assert.deepStrictEqual(sessions.map(withoutUndefinedProperties), [{ + const catalog = await agent.listChatsToMigrate(); + assert.deepStrictEqual(catalog?.map(withoutUndefinedProperties), [{ chat: defaultChatUri(session), startTime: 1000, modifiedTime: 2000, @@ -5109,7 +5104,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); - assert.deepStrictEqual((await agent.listChatsToMigrate())?.map(s => sessionIdOfChat(s.chat)), []); + assert.deepStrictEqual(await agent.listChatsToMigrate(), []); assert.deepStrictEqual(sessionDataService.openedSessions, []); } finally { await disposeAgent(agent); @@ -5251,9 +5246,8 @@ suite('CopilotAgent', () => { await writeExtensionHostMarker(userHome, sessionId); configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); - assert.deepStrictEqual(sessions, []); + const catalog = await agent.listChatsToMigrate(); + assert.deepStrictEqual(catalog, []); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); await fs.rm(workingDirectory, { recursive: true, force: true }); @@ -5293,10 +5287,7 @@ suite('CopilotAgent', () => { try { await agent.authenticate('https://api.github.com', 'token'); await writeExtensionHostMarker(userHome, sessionId); - assert.deepStrictEqual( - (await agent.listChatsToMigrate())?.map(s => sessionIdOfChat(s.chat)), - [], - ); + assert.deepStrictEqual(await agent.listChatsToMigrate(), []); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); await fs.rm(workingDirectory, { recursive: true, force: true }); @@ -5315,10 +5306,7 @@ suite('CopilotAgent', () => { await agent.authenticate('https://api.github.com', 'token'); configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); // No marker written: standalone SDK chats are not legacy extension-host sessions. - assert.deepStrictEqual( - (await agent.listChatsToMigrate())?.map(s => sessionIdOfChat(s.chat)), - [], - ); + assert.deepStrictEqual(await agent.listChatsToMigrate(), []); } finally { await fs.rm(userHome.fsPath, { recursive: true, force: true }); await fs.rm(workingDirectory, { recursive: true, force: true }); @@ -5363,10 +5351,9 @@ suite('CopilotAgent', () => { await writeExtensionHostMarker(userHome, sessionId); // marker present but already adopted configurationService.updateRootConfig({ [AgentHostMigrateLegacyCopilotCliEnabledConfigKey]: true }); - const sessions = await agent.listChatsToMigrate(); - assert.ok(sessions); + const catalog = await agent.listChatsToMigrate(); assert.deepStrictEqual( - sessions.map(s => ({ id: sessionIdOfChat(s.chat), adoptable: readSessionEhcliAdoptable(s._meta) })), + catalog?.map(s => ({ id: sessionIdOfChat(s.chat), adoptable: readSessionEhcliAdoptable(s._meta) })), [{ id: sessionId, adoptable: false }], ); } finally { diff --git a/src/vs/platform/agentHost/test/node/mockAgent.ts b/src/vs/platform/agentHost/test/node/mockAgent.ts index e02b46afbb64dc..2a62fb60a135ee 100644 --- a/src/vs/platform/agentHost/test/node/mockAgent.ts +++ b/src/vs/platform/agentHost/test/node/mockAgent.ts @@ -154,7 +154,7 @@ export class MockAgent implements IAgent { this._discoveredChatsEmitter.fire(chats); } - async listChatsToMigrate(): Promise { + async listChatsToMigrate(): Promise { return []; } @@ -561,7 +561,7 @@ export class ScriptedMockAgent implements IAgent { this._discoveredChatsEmitter.fire(chats); } - async listChatsToMigrate(): Promise { + async listChatsToMigrate(): Promise { return []; } diff --git a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts index 311fbdfd35f2c7..709f4feabdde4c 100644 --- a/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts +++ b/src/vs/workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionListStore.ts @@ -222,16 +222,6 @@ export class AgentHostSessionListStore extends Disposable { try { sessions = await this._connection.listSessions(); } catch { - // If notifications mutated the list while we were fetching, the - // in-memory state is more up-to-date than our failed fetch. - if (startGeneration !== this._mutationGeneration) { - return; - } - if (this._entries.size === 0) { - return; - } - this._entries.clear(); - this._onDidChangeSessions.fire({ removed: previousEntries.map(entry => this._toRemoval(entry)) }); return; } 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 87b7dd8ad4c463..1248514978aff3 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 @@ -2427,6 +2427,30 @@ suite('AgentHostChatContribution', () => { assert.strictEqual(listController.items.length, 0); }); + test('refresh preserves the last successful snapshot on failure and accepts complete empty', async () => { + const { instantiationService, agentHostService } = createTestServices(disposables); + const store = createSessionListStore(disposables, instantiationService, agentHostService); + const controller = disposables.add(instantiationService.createInstance(AgentHostSessionListController, 'agent-host-copilot', 'copilot', store, undefined, 'local')); + agentHostService.addSession({ session: AgentSession.uri('copilot', 'preserved'), startTime: 1000, modifiedTime: 2000, summary: 'Preserved session' }); + await controller.refresh(CancellationToken.None); + + agentHostService.listSessions = async () => { throw new Error('catalog unavailable'); }; + store.resetCache(); + await controller.refresh(CancellationToken.None); + const afterFailure = controller.items.map(item => item.label); + + agentHostService.listSessions = async () => []; + store.resetCache(); + await controller.refresh(CancellationToken.None); + assert.deepStrictEqual({ + afterFailure, + afterCompleteEmpty: controller.items.map(item => item.label), + }, { + afterFailure: ['Preserved session'], + afterCompleteEmpty: [], + }); + }); + test('refresh marks archived sessions as archived items', async () => { const { listController, agentHostService } = createContribution(disposables); From 928efb2996255d09faa9f306d3060e86ad1eea8a Mon Sep 17 00:00:00 2001 From: Sandeep Somavarapu Date: Wed, 19 Aug 2026 20:48:51 +0200 Subject: [PATCH 15/28] sessions: enable single-pane layout by default (#331681) * sessions: enable single-pane layout by default Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: document phone layout exception 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 | 13 +++++++------ src/vs/sessions/SINGLE_PANE_SCENARIOS.md | 8 +++++--- .../layout/browser/sessions.layout.contribution.ts | 2 +- src/vs/workbench/browser/workbench.contribution.ts | 1 - 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/vs/sessions/LAYOUT.md b/src/vs/sessions/LAYOUT.md index 09dcaf90ccb321..a7d71b90df2a0d 100644 --- a/src/vs/sessions/LAYOUT.md +++ b/src/vs/sessions/LAYOUT.md @@ -28,7 +28,7 @@ The Agents Window workbench (`Workbench` in `sessions/browser/workbench.ts`) pro The **Sessions Part** is the primary content surface. It hosts an internal grid of one or more **Session Views** (left-to-right) — see [§4 Sessions Part](#4-sessions-part) for the visibility model. -Editors open as modal overlays via `ModalEditorPart`. The main editor part exists in the workbench grid but is hidden by default. +The Agents window defaults `workbench.editor.useModal` to `some`: editors that require a modal open via `ModalEditorPart`, while ordinary editors open in the main editor part. The main editor part exists in the workbench grid but is hidden until needed. ### 2.1 Parts @@ -275,13 +275,14 @@ On phone-class viewports the Sessions Part is replaced by `MobileSessionsPart` ( --- -## 5. Editor Modal +## 5. Editor Presentation -Editors open as modal overlays rather than occupying grid space. The configuration `workbench.editor.useModal: 'all'` redirects all editor opens (without an explicit preferred group) to `ModalEditorPart`. +The Agents window defaults `workbench.editor.useModal` to `some`. Editors that require a modal, such as Settings and Keyboard Shortcuts, open in `ModalEditorPart`; ordinary editors open in the main editor part. | Trigger | Behavior | |---------|----------| -| Editor opens (no explicit group) | Opens in modal overlay | +| Ordinary editor opens (no explicit group) | Opens in the main editor part | +| Editor requiring a modal opens | Opens in modal overlay | | All editors closed / Escape / backdrop click | Modal closes and is disposed | When the editor part is shown in the grid (not as a modal), its title toolbar (`MenuId.EditorTitleLayout`, right of the tabs) hosts layout actions registered in `contrib/editor/browser/editor.contribution.ts`, ordered left-to-right as: open in modal editor, **maximize / restore editor area**, a single **Toggle Details** action for the auxiliary bar (labelled "Toggle Secondary Side Bar" in the non-single-pane layout), and **close editor area**. The auxiliary-bar toggle sits to the right of maximize/restore because it changes the right-hand side of the layout. It reuses the core `workbench.action.toggleAuxiliaryBar` command (already registered in the agents window by the workbench auxiliary bar part, and available in the Command Palette under **View**) surfaced through two `when`-gated menu items in `browser/layoutActions.ts` so the icon flips without rendering a checked/highlighted state: the `right-panel-show` codicon shows when the auxiliary bar is hidden (`AuxiliaryBarVisibleContext` negated, click to show) and the `right-panel-hide` codicon shows when it is visible (click to hide). In the Agents-window tab strip, the editor-actions side first shrinks down to 50px before the tab scroller starts shrinking. When tab actions are placed on the left, tabs retain trailing spacing consistent with the modern editor tab style. @@ -299,11 +300,11 @@ The Toggle Details action (Toggle Secondary Side Bar in the non-single-pane layo The main editor part can be explicitly revealed for workflows that target it directly. -### Single-pane redesign (experimental — `sessions.layout.singlePaneDetailPanel`, default OFF) +### Single-pane redesign (experimental — `sessions.layout.singlePaneDetailPanel`, default ON) > See [SINGLE_PANE_SCENARIOS.md](SINGLE_PANE_SCENARIOS.md) for the full scenario/state/transition catalog and the manual validation checklist. -The entire third-pane redesign is gated behind the experimental setting `sessions.layout.singlePaneDetailPanel`, read **once at startup** (a window reload applies a change). When the setting is **off** (default) the Agents window renders exactly as documented above (auxiliary bar as its own grid column with its composite tab strip + title, the standard multi-diff Changes editor). When **on**, the third pane becomes a **single pane with one full-width editor title region**. It supports `workbench.editor.showTabs` values `multiple` and `single`; while the unsupported `none` value is configured, the Agents editor part conditionally enforces `single`. When only the docked Auxiliary Bar is visible and the editor area is hidden, it enforces `multiple` so every managed detail tab remains directly available. +The entire third-pane redesign is gated behind the experimental setting `sessions.layout.singlePaneDetailPanel`, read **once at startup** (a window reload applies a change). When the setting is **on** (default), a non-phone Agents window uses a **single pane with one full-width editor title region**. Phone-class viewports always use the classic layout, regardless of the setting. When the setting is **off**, every Agents window also renders the classic layout documented above (auxiliary bar as its own grid column with its composite tab strip + title, the standard multi-diff Changes editor). The single-pane layout supports `workbench.editor.showTabs` values `multiple` and `single`; while the unsupported `none` value is configured, the Agents editor part conditionally enforces `single`. When only the docked Auxiliary Bar is visible and the editor area is hidden, it enforces `multiple` so every managed detail tab remains directly available. - The auxiliary bar is removed from the workbench grid and **docked inside the editor part** (absolutely positioned on the right, below the editor tab strip); the grid's top-right row becomes `Sessions | Editor`, and the editor part spans the editor + detail-panel width. - The editor group's **title region and header-hosted breadcrumbs span the full width**, while the editor content is inset on the right by the detail-panel width via the concrete `EditorPart.setContentRightInset(px)` method (`EditorPart`/`EditorGroupView`; not on the `IEditorPart` interface; `0` = no-op for all other layouts). The detail panel is always docked on the right, so no left margin is needed. diff --git a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md index 64d6d87e3e2268..ab6c4fb5607b00 100644 --- a/src/vs/sessions/SINGLE_PANE_SCENARIOS.md +++ b/src/vs/sessions/SINGLE_PANE_SCENARIOS.md @@ -11,9 +11,11 @@ bar spanning the editor content and a docked detail panel). imperative code) and the `SinglePaneLayoutEnabledContext` context key (read only by declarative `when` clauses). Features must gate on those — never read the setting or the context key directly in imperative code. -- When the setting is **OFF** (default), the Agents window renders exactly as before (auxiliary bar as - its own grid column with its composite tab strip; the standard multi-diff Changes editor). Nothing in - this document applies. +- When the setting is **ON** (default), a non-phone Agents window uses the single-pane layout described + here. Phone-class viewports always use the classic layout, regardless of the setting. When the setting + is **OFF**, every Agents window also renders the classic layout (auxiliary bar as its own grid column + with its composite tab strip; the standard multi-diff Changes editor). Nothing else in this document + applies to that classic layout. - Companion specs: [LAYOUT.md](LAYOUT.md) §5, [LAYOUT_CONTROLLER.md](LAYOUT_CONTROLLER.md), and [contrib/layout/browser/desktopSessionLayoutController.md](contrib/layout/browser/desktopSessionLayoutController.md). diff --git a/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts b/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts index 822136a7160dc2..9e64057d316e77 100644 --- a/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts +++ b/src/vs/sessions/contrib/layout/browser/sessions.layout.contribution.ts @@ -56,7 +56,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis [DOCK_DETAIL_PANEL_SETTING]: { type: 'boolean', markdownDescription: localize('sessions.layout.singlePaneDetailPanel', "Controls whether the Agents window docks the detail panel inside the editor so a single editor tab bar spans across the editor and the detail panel. Requires a window reload to take effect."), - default: false, + default: true, tags: ['experimental'], experiment: { mode: 'startup' } }, diff --git a/src/vs/workbench/browser/workbench.contribution.ts b/src/vs/workbench/browser/workbench.contribution.ts index 832e4f23f9a63e..9953cb0c91081a 100644 --- a/src/vs/workbench/browser/workbench.contribution.ts +++ b/src/vs/workbench/browser/workbench.contribution.ts @@ -371,7 +371,6 @@ const registry = Registry.as(ConfigurationExtensions.Con ], 'description': localize('useModal', "Controls whether editors open in a modal overlay."), 'default': 'some', - agentsWindow: { default: 'all' }, experiment: { mode: 'startup' } From 09272902b91b31285e819be24f09c91ce7d89a53 Mon Sep 17 00:00:00 2001 From: Ladislau Szomoru <3372902+lszomoru@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:50:13 +0200 Subject: [PATCH 16/28] Agents - create session from pull request should not leverage the `IGitService` (#331644) * Agents - create session from pull request should not leverage the `IGitService` * Pull request feedback --- .../browser/sessionsChatAccessibilityHelp.ts | 2 +- .../createSessionFromPullRequestAction.ts | 37 ++++--------------- .../github/browser/pullRequestPicker.ts | 23 +----------- .../test/browser/pullRequestPicker.test.ts | 37 ++++--------------- 4 files changed, 17 insertions(+), 82 deletions(-) diff --git a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts index 17b8bfa94906fe..010395b285fc4a 100644 --- a/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts +++ b/src/vs/sessions/contrib/chat/browser/sessionsChatAccessibilityHelp.ts @@ -40,7 +40,7 @@ export class SessionsChatAccessibilityHelp implements IAccessibleViewImplementat content.push(localize('sessionsChat.feedbackAttachment', "When a feedback comments attachment appears above the input, focus it and press Enter or Space. A single comment opens directly. Multiple comments open a tree grouped by file; use the arrow keys to navigate, Enter to reveal a comment, and Escape to close the tree.")); content.push(localize('sessionsChat.inputBackground', "Press Alt+Enter to start the session in the background without navigating into it. The started session appears in the Chat Sessions view.")); content.push(localize('sessionsChat.workspace', "Shift+Tab to navigate to the workspace picker and choose a workspace for your session.")); - content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate Create Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); + content.push(localize('sessionsChat.pullRequestSession', "In a repository section where New Session is a split button, focus New Session and press Right Arrow to reach its dropdown, then activate New Session from Pull Request to open a searchable pull request picker. Pull requests are grouped by review and assignment status. Use the arrow keys to navigate, Enter to create the session, and Escape to close the picker.")); content.push(localize('sessionsChat.githubReferences', "Pull request and issue pills in the session header open their GitHub item in the GitHub Pull Requests extension when it is available. Pills that represent several items open a keyboard-accessible picker.")); content.push(localize('sessionsChat.failingChecksPullRequest', "When the active session has failing checks, use Reveal in the banner above the input to open its pull request, or use Fix Checks to ask the agent to address the failures.")); content.push(localize('sessionsChat.pickFolderQuickPick', "To choose a folder from a searchable list instead, use the New Session in Folder command{0}.", '')); diff --git a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts index 15556d940b7c85..c12765fbedb935 100644 --- a/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts +++ b/src/vs/sessions/contrib/github/browser/createSessionFromPullRequestAction.ts @@ -19,7 +19,6 @@ import { IQuickInputService } from '../../../../platform/quickinput/common/quick import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { toErrorMessage } from '../../../../base/common/errorMessage.js'; import { ChatContextKeys } from '../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { IGitService } from '../../../../workbench/contrib/git/common/gitService.js'; import { ISessionsManagementService } from '../../../services/sessions/common/sessionsManagement.js'; import { ISessionsPartService } from '../../../services/sessions/browser/sessionsPartService.js'; import { ISessionsService } from '../../../services/sessions/browser/sessionsService.js'; @@ -28,16 +27,16 @@ import { Menus } from '../../../browser/menus.js'; import { ISessionSection, SessionSectionHasGitHubRepositoryContext, SessionSectionHasNonCloudRepositoryContext, SessionSectionTypeContext } from '../../sessions/browser/views/sessionsList.js'; import { IGitHubService } from './githubService.js'; import { IGitHubPullRequestSummary } from '../common/types.js'; -import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getGitHubRepositoryFromRemotes, hasExistingPullRequest, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js'; +import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, hasExistingPullRequest, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from './pullRequestPicker.js'; import { createAndOpenPullRequestSession } from './pullRequestSessionCreation.js'; -export const CREATE_SESSION_FROM_PULL_REQUEST_COMMAND_ID = 'workbench.agentSessions.createSessionFromPullRequest'; +export const NEW_SESSION_FROM_PULL_REQUEST_COMMAND_ID = 'workbench.agentSessions.newSessionFromPullRequest'; -registerAction2(class CreateSessionFromPullRequestAction extends Action2 { +registerAction2(class NewSessionFromPullRequestAction extends Action2 { constructor() { super({ - id: CREATE_SESSION_FROM_PULL_REQUEST_COMMAND_ID, - title: localize2('createSessionFromPullRequest', "Create Session from Pull Request"), + id: NEW_SESSION_FROM_PULL_REQUEST_COMMAND_ID, + title: localize2('newSessionFromPullRequest', "New Session from Pull Request"), icon: Codicon.gitPullRequestCreate, precondition: ChatContextKeys.enabled, menu: { @@ -61,7 +60,6 @@ registerAction2(class CreateSessionFromPullRequestAction extends Action2 { const sessionsManagementService = accessor.get(ISessionsManagementService); const notificationService = accessor.get(INotificationService); - const gitService = accessor.get(IGitService); const gitHubService = accessor.get(IGitHubService); const sessionsService = accessor.get(ISessionsService); const sessionsPartService = accessor.get(ISessionsPartService); @@ -71,8 +69,8 @@ registerAction2(class CreateSessionFromPullRequestAction extends Action2 { const store = new DisposableStore(); const pickerCts = store.add(new CancellationTokenSource()); let sessionCreated = false; - picker.title = localize('createSessionFromPullRequest.title', "Create Session from Pull Request"); - picker.placeholder = localize('createSessionFromPullRequest.resolvingRepository', "Resolving GitHub repository..."); + picker.title = localize('newSessionFromPullRequest.title', "New Session from Pull Request"); + picker.placeholder = localize('newSessionFromPullRequest.resolvingRepository', "Resolving GitHub repository..."); picker.matchOnDescription = true; picker.matchOnDetail = true; picker.sortByLabel = false; @@ -89,26 +87,7 @@ registerAction2(class CreateSessionFromPullRequestAction extends Action2 { let repository; try { - repository = await resolvePullRequestSessionRepository( - context.sessions, - async folderUri => { - const gitRepository = await gitService.openRepository(folderUri); - if (!gitRepository) { - return undefined; - } - const current = getGitHubRepositoryFromRemotes(gitRepository.state.get().remotes); - if (current) { - return current; - } - const state = await waitForState( - gitRepository.state, - state => state.remotes.length > 0, - undefined, - pickerCts.token, - ); - return getGitHubRepositoryFromRemotes(state.remotes); - }, - ); + repository = await resolvePullRequestSessionRepository(context.sessions); } catch (error) { picker.hide(); if (!isCancellationError(error)) { diff --git a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts index d38d35963fe8ef..816309c36a09d6 100644 --- a/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts +++ b/src/vs/sessions/contrib/github/browser/pullRequestPicker.ts @@ -30,14 +30,8 @@ export interface IPullRequestSessionRepository { readonly repo: string; } -export interface IRepositoryRemote { - readonly name: string; - readonly fetchUrl?: string; -} - export async function resolvePullRequestSessionRepository( sectionSessions: readonly ISession[], - resolveGitHubRepository: (folderUri: URI) => Promise<{ readonly owner: string; readonly repo: string } | undefined>, ): Promise { let folderUri: URI | undefined; for (const session of sectionSessions) { @@ -55,25 +49,10 @@ export async function resolvePullRequestSessionRepository( if (!folderUri) { return undefined; } - const identity = getFirstGitHubRepository(sectionSessions) ?? await resolveGitHubRepository(folderUri); + const identity = getFirstGitHubRepository(sectionSessions); return identity ? { folderUri, owner: identity.owner, repo: identity.repo } : undefined; } -export function getGitHubRepositoryFromRemotes(remotes: readonly IRepositoryRemote[]): { readonly owner: string; readonly repo: string } | undefined { - const orderedRemotes = [...remotes].sort((a, b) => Number(b.name === 'origin') - Number(a.name === 'origin')); - for (const remote of orderedRemotes) { - const fetchUrl = remote.fetchUrl?.trim().replace(/\/$/, '').replace(/\.git$/, ''); - if (!fetchUrl) { - continue; - } - const match = /^(?:(?:https?|ssh):\/\/(?:git@)?github\.com\/|git@github\.com:)(?[^/\s]+)\/(?[^/\s]+)$/i.exec(fetchUrl); - if (match?.groups) { - return { owner: match.groups.owner, repo: match.groups.repo }; - } - } - return undefined; -} - export function getExistingPullRequests(sessions: readonly ISession[], owner: string, repo: string, repositorySessions: readonly ISession[] = []): IExistingPullRequests { const numbers = new Set(); const headRefs = new Set(); diff --git a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts index c95ea94c226173..eaef8075e93f67 100644 --- a/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts +++ b/src/vs/sessions/contrib/github/test/browser/pullRequestPicker.test.ts @@ -13,7 +13,7 @@ import { mock } from '../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { readSessionGitHubState } from '../../../../../platform/agentHost/common/state/sessionState.js'; import { ISession, ISessionWorkspace } from '../../../../services/sessions/common/session.js'; -import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getGitHubRepositoryFromRemotes, getPullRequestNumberFromCheckoutRef, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from '../../browser/pullRequestPicker.js'; +import { createPullRequestBootstrapPrompt, createPullRequestContextAttachment, createPullRequestQuickPickItems, createPullRequestSessionMetadata, getExistingPullRequests, getPullRequestNumberFromCheckoutRef, IPullRequestQuickPickItem, mergePullRequestSummaries, pullRequestMatchesQuery, resolvePullRequestSessionRepository } from '../../browser/pullRequestPicker.js'; import { IGitHubPullRequestSummary } from '../../common/types.js'; import { createAndOpenPullRequestSession } from '../../browser/pullRequestSessionCreation.js'; @@ -259,7 +259,7 @@ suite('Create Session from Pull Request', () => { }); }); - test('resolves non-cloud repositories from session metadata or Git remotes', async () => { + test('resolves non-cloud repositories from session metadata', async () => { const cloudRoot = URI.parse('github-remote-file://github/alexr00/playground/copilot%252Finspect-pull-request-748'); const localRoot = URI.file('/repos/alexr00/playground'); const remoteRoot = URI.parse('vscode-remote://ssh-remote+host/repos/alexr00/playground'); @@ -268,17 +268,13 @@ suite('Create Session from Pull Request', () => { const remoteSession = sessionWithRepository(remoteRoot, 'alexr00', 'playground'); assert.deepStrictEqual({ - cloud: await resolvePullRequestSessionRepository([cloudSession], async () => undefined), - local: await resolvePullRequestSessionRepository([localSession], async () => ({ owner: 'alexr00', repo: 'playground' })), - mixed: await resolvePullRequestSessionRepository([cloudSession, localSession], async () => undefined), - remote: await resolvePullRequestSessionRepository([remoteSession], async () => undefined), + cloud: await resolvePullRequestSessionRepository([cloudSession]), + local: await resolvePullRequestSessionRepository([localSession]), + mixed: await resolvePullRequestSessionRepository([cloudSession, localSession]), + remote: await resolvePullRequestSessionRepository([remoteSession]), }, { cloud: undefined, - local: { - folderUri: localRoot, - owner: 'alexr00', - repo: 'playground', - }, + local: undefined, mixed: { folderUri: localRoot, owner: 'alexr00', @@ -291,25 +287,6 @@ suite('Create Session from Pull Request', () => { }, }); }); - - test('parses GitHub repository identity from origin before other remotes', () => { - assert.deepStrictEqual({ - https: getGitHubRepositoryFromRemotes([ - { name: 'upstream', fetchUrl: 'git@github.com:microsoft/vscode.git' }, - { name: 'origin', fetchUrl: 'https://github.com/alexr00/vscode.git' }, - ]), - ssh: getGitHubRepositoryFromRemotes([ - { name: 'origin', fetchUrl: 'ssh://git@github.com/alexr00/playground' }, - ]), - nonGitHub: getGitHubRepositoryFromRemotes([ - { name: 'origin', fetchUrl: 'https://example.com/alexr00/playground.git' }, - ]), - }, { - https: { owner: 'alexr00', repo: 'vscode' }, - ssh: { owner: 'alexr00', repo: 'playground' }, - nonGitHub: undefined, - }); - }); }); function pullRequest(number: number, overrides: Partial = {}): IGitHubPullRequestSummary { From e7a7392c478f123c0b1c5ecba5d669fcb51b9f72 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 19 Aug 2026 11:54:22 -0700 Subject: [PATCH 17/28] Remove Agents window startup A/A experiment trigger (#331559) The A/A assignment read is no longer needed now that the experiment has been completed. Drop the BlockStartup contribution, unit test, and treatment constant. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../electron-browser/chat.contribution.ts | 2 -- .../sessionsWindowStartupExperiment.ts | 20 ------------ .../sessionsWindowStartupExperiment.test.ts | 31 ------------------- 3 files changed, 53 deletions(-) delete mode 100644 src/vs/sessions/contrib/sessions/browser/sessionsWindowStartupExperiment.ts delete mode 100644 src/vs/sessions/contrib/sessions/test/browser/sessionsWindowStartupExperiment.test.ts diff --git a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts index 2fdecdc86d9da9..533bbeee63bfa0 100644 --- a/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts +++ b/src/vs/sessions/contrib/chat/electron-browser/chat.contribution.ts @@ -26,7 +26,6 @@ import { IStorageService, StorageScope } from '../../../../platform/storage/comm import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { TOTAL_SESSIONS_KEY } from '../../sessions/browser/sessionsLifecycleTracker.js'; import { ISessionsWindowOpenViewState, SessionsWindowOpenTelemetry, SessionsWindowSessionStartTelemetry } from '../../sessions/browser/sessionsWindowOpenTelemetry.js'; -import { SessionsWindowStartupExperiment } from '../../sessions/browser/sessionsWindowStartupExperiment.js'; import { INewSessionComposerService, NewSessionWorkspacePreselectionSource } from '../browser/newSessionComposerService.js'; class SelectAgentsFolderContribution extends Disposable implements IWorkbenchContribution { @@ -226,7 +225,6 @@ class SelectAgentsFolderContribution extends Disposable implements IWorkbenchCon } registerWorkbenchContribution2(SelectAgentsFolderContribution.ID, SelectAgentsFolderContribution, WorkbenchPhase.BlockStartup); -registerWorkbenchContribution2(SessionsWindowStartupExperiment.ID, SessionsWindowStartupExperiment, WorkbenchPhase.BlockStartup); registerWorkbenchContribution2(SessionsCopilotConfigSlashSubmitHandlerContribution.ID, SessionsCopilotConfigSlashSubmitHandlerContribution, WorkbenchPhase.AfterRestored); // Renderer-side BYOK language-model handler that backs the node agent host's diff --git a/src/vs/sessions/contrib/sessions/browser/sessionsWindowStartupExperiment.ts b/src/vs/sessions/contrib/sessions/browser/sessionsWindowStartupExperiment.ts deleted file mode 100644 index 997233d681ae20..00000000000000 --- a/src/vs/sessions/contrib/sessions/browser/sessionsWindowStartupExperiment.ts +++ /dev/null @@ -1,20 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { IWorkbenchContribution } from '../../../../workbench/common/contributions.js'; -import { IWorkbenchAssignmentService } from '../../../../workbench/services/assignment/common/assignmentService.js'; - -export const AGENTS_WINDOW_STARTUP_AA_EXPERIMENT = 'agentsWindowStartupAA'; - -export class SessionsWindowStartupExperiment implements IWorkbenchContribution { - - static readonly ID = 'sessions.windowStartupExperiment'; - - constructor( - @IWorkbenchAssignmentService assignmentService: IWorkbenchAssignmentService, - ) { - void assignmentService.getTreatment(AGENTS_WINDOW_STARTUP_AA_EXPERIMENT); - } -} diff --git a/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowStartupExperiment.test.ts b/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowStartupExperiment.test.ts deleted file mode 100644 index f952bff36ca1b9..00000000000000 --- a/src/vs/sessions/contrib/sessions/test/browser/sessionsWindowStartupExperiment.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { NullWorkbenchAssignmentService } from '../../../../../workbench/services/assignment/test/common/nullAssignmentService.js'; -import { AGENTS_WINDOW_STARTUP_AA_EXPERIMENT, SessionsWindowStartupExperiment } from '../../browser/sessionsWindowStartupExperiment.js'; - -class TestAssignmentService extends NullWorkbenchAssignmentService { - readonly treatments: string[] = []; - - override async getTreatment(name: string): Promise { - this.treatments.push(name); - return true as T; - } -} - -suite('SessionsWindowStartupExperiment', () => { - - ensureNoDisposablesAreLeakedInTestSuite(); - - test('reads the A/A treatment when the contribution starts', () => { - const assignmentService = new TestAssignmentService(); - - new SessionsWindowStartupExperiment(assignmentService); - - assert.deepStrictEqual(assignmentService.treatments, [AGENTS_WINDOW_STARTUP_AA_EXPERIMENT]); - }); -}); From 16cd612e0a01c69f0eb4030ef2eccde0e85e5ca6 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Wed, 19 Aug 2026 11:55:11 -0700 Subject: [PATCH 18/28] cli: recover stale tunnel port protocols (#331351) * cli: recover stale tunnel port protocols Handles stale tunnel ports whose immutable protocol does not match the requested forwarding mode. - Matches the structured HTTP response instead of the formatted error message. - Removes the stale port and retries registration for the legacy Node tunnel path. - Adds the linkPresentationProviders extension point. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * cli: fix tunnel protocol conflict detection Uses the formatted TunnelError detail because the pinned dev-tunnels revision does not expose the inner HTTP error as a source. - Matches the production error shape used by relay port registration. - Removes the synthetic source wrapper from the regression test. - Documents the SDK limitation and legacy Node tunnel scope. (Commit message generated by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/src/tunnels/dev_tunnels.rs | 89 +++++++++++++++++++++++++--------- 1 file changed, 67 insertions(+), 22 deletions(-) diff --git a/cli/src/tunnels/dev_tunnels.rs b/cli/src/tunnels/dev_tunnels.rs index ed8a9b08e0588a..6c039124266fbc 100644 --- a/cli/src/tunnels/dev_tunnels.rs +++ b/cli/src/tunnels/dev_tunnels.rs @@ -35,6 +35,7 @@ use tunnels::management::{ }; static TUNNEL_COUNT_LIMIT_NAME: &str = "TunnelsPerUserPerLocation"; +static TUNNEL_PORT_PROTOCOL_CONFLICT_DETAIL: &str = "The tunnel port protocol cannot be changed."; #[allow(dead_code)] mod tunnel_flags { @@ -1026,17 +1027,29 @@ impl ActiveTunnelManager { privacy: PortPrivacy, protocol: PortProtocol, ) -> Result<(), WrappedError> { - self.relay - .lock() - .await - .add_port(&TunnelPort { - port_number, - protocol: Some(protocol.to_contract_str().to_string()), - access_control: Some(privacy_to_tunnel_acl(privacy)), - ..Default::default() - }) - .await - .map_err(|e| wrap(e, "error adding port to relay"))?; + let relay = self.relay.lock().await; + let port = TunnelPort { + port_number, + protocol: Some(protocol.to_contract_str().to_string()), + access_control: Some(privacy_to_tunnel_acl(privacy)), + ..Default::default() + }; + + match relay.add_port(&port).await { + Ok(()) => {} + Err(error) if is_tunnel_port_protocol_conflict(&error) => { + relay + .remove_port(port_number) + .await + .map_err(|e| wrap(e, "error replacing port protocol in relay"))?; + relay + .add_port(&port) + .await + .map_err(|e| wrap(e, "error adding port to relay"))?; + } + Err(error) => return Err(wrap(error, "error adding port to relay")), + } + Ok(()) } @@ -1045,17 +1058,28 @@ impl ActiveTunnelManager { &self, port_number: u16, ) -> Result, WrappedError> { - self.relay - .lock() - .await - .add_port_raw(&TunnelPort { - port_number, - protocol: Some(TUNNEL_PROTOCOL_AUTO.to_owned()), - access_control: Some(privacy_to_tunnel_acl(PortPrivacy::Private)), - ..Default::default() - }) - .await - .map_err(|e| wrap(e, "error adding port to relay")) + let relay = self.relay.lock().await; + let port = TunnelPort { + port_number, + protocol: Some(TUNNEL_PROTOCOL_AUTO.to_owned()), + access_control: Some(privacy_to_tunnel_acl(PortPrivacy::Private)), + ..Default::default() + }; + + match relay.add_port_raw(&port).await { + Ok(receiver) => Ok(receiver), + Err(error) if is_tunnel_port_protocol_conflict(&error) => { + relay + .remove_port(port_number) + .await + .map_err(|e| wrap(e, "error replacing port protocol in relay"))?; + relay + .add_port_raw(&port) + .await + .map_err(|e| wrap(e, "error adding port to relay")) + } + Err(error) => Err(wrap(error, "error adding port to relay")), + } } /// Removes a port from TCP/IP forwarding. @@ -1251,6 +1275,16 @@ fn vec_eq_as_set(a: &[String], b: &[String]) -> bool { true } +// This is only relevant for Node-owned tunnels created before +// https://github.com/microsoft/vscode/pull/329066 was merged. The pinned dev-tunnels +// revision does not expose the inner HttpError as Error::source(), so match its detail +// in the formatted error until the SDK provides a structured error code. +fn is_tunnel_port_protocol_conflict(error: &impl std::fmt::Display) -> bool { + error + .to_string() + .contains(TUNNEL_PORT_PROTOCOL_CONFLICT_DETAIL) +} + fn privacy_to_tunnel_acl(privacy: PortPrivacy) -> TunnelAccessControl { TunnelAccessControl { entries: vec![match privacy { @@ -1310,4 +1344,15 @@ mod test { ); assert_eq!(clean_hostname_for_tunnel("z"), "remote-machine".to_string()); } + + #[test] + fn test_is_tunnel_port_protocol_conflict() { + assert!(is_tunnel_port_protocol_conflict(&format!( + "failed to add port to tunnel: response error: HTTP status 400: \ + {{\"detail\":\"{TUNNEL_PORT_PROTOCOL_CONFLICT_DETAIL}\"}}" + ))); + assert!(!is_tunnel_port_protocol_conflict( + &"response error: Another validation error." + )); + } } From 17c1bfa855a17a9610d4a404aba1dc784e195e81 Mon Sep 17 00:00:00 2001 From: Drew Skwiers-Koballa Date: Wed, 19 Aug 2026 12:08:42 -0700 Subject: [PATCH 19/28] add mcp server key as description in tool picker (#325003) * add mcp server key as description * avoiding duplicate text Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Drew Skwiers-Koballa Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Dmitriy Vasyura --- src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts index 3cc9b13301404a..d5ba68c929d7e6 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatToolPicker.ts @@ -371,6 +371,7 @@ export async function showToolsPicker( ordinal: BucketOrdinal.Mcp, id: key, label: source.serverLabel || source.label, + description: source.serverLabel && source.serverLabel !== source.label ? source.label : undefined, checked: undefined, collapsed, children, From 8041c5c6ceccd4079ea04babeac8fa3d4fae0862 Mon Sep 17 00:00:00 2001 From: Benjamin Steenhoek Date: Wed, 19 Aug 2026 14:25:22 -0500 Subject: [PATCH 20/28] nes: feat: add eagerness option for diffpatch prompt (#327544) * Add patchBased02AggressionHighLow * Make eagerness part of prompt strategy for diffpatch prompt * Explicitly gate isAggressionPromptingStrategy behind prompt strategy and eagerness option * Remove accidental deletion * Properly rename function * Refactor patch-based postscript scoping Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Fix PatchBased02 eagerness prompt Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ulugbek Abdullaev --- .../extension/xtab/common/promptCrafting.ts | 13 +++++---- .../src/extension/xtab/node/xtabProvider.ts | 6 ++-- .../xtab/test/common/promptCrafting.spec.ts | 28 +++++++++++++++++++ .../common/dataTypes/xtabPromptOptions.ts | 27 ++++++++++++++---- .../test/common/xtabPromptOptions.spec.ts | 15 +++++++++- 5 files changed, 75 insertions(+), 14 deletions(-) diff --git a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts index 55f6c53a25c86b..9af66e630ff703 100644 --- a/extensions/copilot/src/extension/xtab/common/promptCrafting.ts +++ b/extensions/copilot/src/extension/xtab/common/promptCrafting.ts @@ -367,9 +367,10 @@ function appendWithNewLineIfNeeded(base: string, toAppend: string, minNewLines: return (base + '\n'.repeat(newLinesToAdd) + toAppend).trim(); } -function getPostScript(opts: PromptOptions, currentFilePath: string, aggressivenessLevel: AggressivenessLevel) { - const { promptingStrategy } = opts; +function getPostScript(options: PromptOptions, currentFilePath: string, aggressivenessLevel: AggressivenessLevel) { + const { promptingStrategy, eagernessPrompt } = options; const xtab275BasePostScript = `The developer was working on a section of code within the tags \`code_to_edit\` in the file located at \`${currentFilePath}\`. Using the given \`recently_viewed_code_snippets\`, \`current_file_content\`, \`edit_diff_history\`, \`area_around_code_to_edit\`, and the cursor position marked as \`${PromptTags.CURSOR}\`, please continue the developer's work. Update the \`code_to_edit\` section by predicting and completing the changes they would have made next. Provide the revised code that was between the \`${PromptTags.EDIT_WINDOW.start}\` and \`${PromptTags.EDIT_WINDOW.end}\` tags, but do not include the tags themselves. Avoid undoing or reverting the developer's last change unless there are obvious typos or errors. Don't include the line numbers or the form #| in your response. Do not skip any lines. Do not be lazy.`; + const patchBased02PostScript = `The developer was working on a section of code within the \`current_file_content\` - carefully note their \`cursor_location\` marked with \`<|cursor|>\`. Using the given \`recently_viewed_code_snippets\`, \`current_file_content\`, \`edit_diff_history\`, and \`cursor_location\`, please continue the developer's work. Output a modified diff format with a sequence of intuitive next changes, where each patch must start with \`:\`. Order changes by priority and flow; for instance, edits adjacent to the user's cursor should always be prioritized, followed by lines near the cursor, followed by lines farther away. If there are no good edit candidates, output the empty string "". Avoid undoing or reverting the developer's last change unless there are obvious typos or errors. Adhere meticulously to the diff format.`; let postScript: string | undefined; switch (promptingStrategy) { @@ -377,11 +378,13 @@ function getPostScript(opts: PromptOptions, currentFilePath: string, aggressiven case PromptingStrategy.Codexv21NesUnified: break; case PromptingStrategy.PatchBased02: - postScript = `The developer was working on a section of code within the \`current_file_content\` - carefully note their \`cursor_location\` marked with \`<|cursor|>\`. Using the given \`recently_viewed_code_snippets\`, \`current_file_content\`, \`edit_diff_history\`, and \`cursor_location\`, please continue the developer's work. Output a modified diff format with a sequence of intuitive next changes, where each patch must start with \`:\`. Order changes by priority and flow; for instance, edits adjacent to the user's cursor should always be prioritized, followed by lines near the cursor, followed by lines farther away. If there are no good edit candidates, output the empty string "". Avoid undoing or reverting the developer's last change unless there are obvious typos or errors. Adhere meticulously to the diff format.`; - break; case PromptingStrategy.PatchBased02WithRecentLineNumbers: case PromptingStrategy.PatchBased02WithoutRecentLineNumbers: - postScript = `The developer was working on a section of code within the \`current_file_content\` - carefully note their \`cursor_location\` marked with \`<|cursor|>\`. Using the given \`recently_viewed_code_snippets\`, \`current_file_content\`, \`edit_diff_history\`, and \`cursor_location\`, please continue the developer's work. Output a modified diff format with a sequence of intuitive next changes, where each patch must start with \`:\`. Order changes by priority and flow; for instance, edits adjacent to the user's cursor should always be prioritized, followed by lines near the cursor, followed by lines farther away. If there are no good edit candidates, output the empty string "". Avoid undoing or reverting the developer's last change unless there are obvious typos or errors. Adhere meticulously to the diff format.`; + postScript = eagernessPrompt === 'aggressionHighLow' + ? aggressivenessLevel === AggressivenessLevel.Medium + ? patchBased02PostScript + : `<|aggression|>${aggressivenessLevel}<|/aggression|>\n\n${patchBased02PostScript}` + : patchBased02PostScript; break; case PromptingStrategy.UnifiedModel: postScript = `The developer was working on a section of code within the tags \`code_to_edit\` in the file located at \`${currentFilePath}\`. Using the given \`recently_viewed_code_snippets\`, \`current_file_content\`, \`edit_diff_history\`, \`area_around_code_to_edit\`, and the cursor position marked as \`${PromptTags.CURSOR}\`, please continue the developer's work. Update the \`code_to_edit\` section by predicting and completing the changes they would have made next. Start your response with , , or . If you are making an edit, start with and then provide the rewritten code window followed by . If you are inserting new code, start with and then provide only the new code that will be inserted at the cursor position followed by . If no changes are necessary, reply only with . Avoid undoing or reverting the developer's last change unless there are obvious typos or errors.`; diff --git a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts index daf5b42d5605ee..5a7e8095f2eadf 100644 --- a/extensions/copilot/src/extension/xtab/node/xtabProvider.ts +++ b/extensions/copilot/src/extension/xtab/node/xtabProvider.ts @@ -19,7 +19,7 @@ import { LanguageContextEntry, LanguageContextResponse } from '../../../platform import { LanguageId } from '../../../platform/inlineEdits/common/dataTypes/languageId'; import { NextCursorLinePrediction } from '../../../platform/inlineEdits/common/dataTypes/nextCursorLinePrediction'; import * as xtabPromptOptions from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; -import { AggressivenessSetting, EarlyDivergenceCancellationMode, isAggressivenessStrategy, LanguageContextLanguages, LanguageContextOptions } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; +import { AggressivenessSetting, EarlyDivergenceCancellationMode, isEagernessPrompt, LanguageContextLanguages, LanguageContextOptions } from '../../../platform/inlineEdits/common/dataTypes/xtabPromptOptions'; import { InlineEditRequestLogContext } from '../../../platform/inlineEdits/common/inlineEditLogContext'; import { IInlineEditsModelService } from '../../../platform/inlineEdits/common/inlineEditsModelService'; import { ResponseProcessor } from '../../../platform/inlineEdits/common/responseProcessor'; @@ -617,7 +617,7 @@ export class XtabProvider implements IStatelessNextEditProvider { } // Adjust debounce based on user aggressiveness setting for non-aggressiveness models - if (!isAggressivenessStrategy(promptOptions.promptingStrategy)) { + if (!isEagernessPrompt(promptOptions)) { this._applyAggressivenessSettings(delaySession, tracer); } } @@ -1574,6 +1574,7 @@ export class XtabProvider implements IStatelessNextEditProvider { }, memory: undefined, lintOptions: undefined, + eagernessPrompt: undefined, includePostScript: true, globalBudget: this.getGlobalBudget(), }; @@ -1782,6 +1783,7 @@ export function overrideModelConfig(modelConfig: ModelConfig, overridingConfig: ...modelConfig, modelName: overridingConfig.modelName, promptingStrategy: overridingConfig.promptingStrategy, + eagernessPrompt: overridingConfig.eagernessPrompt ?? modelConfig.eagernessPrompt, includePostScript: overridingConfig.includePostScript ?? modelConfig.includePostScript, currentFile: { ...modelConfig.currentFile, diff --git a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts index ef4646c7a99a9f..560041892d3e11 100644 --- a/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts +++ b/extensions/copilot/src/extension/xtab/test/common/promptCrafting.spec.ts @@ -614,6 +614,7 @@ describe('getUserPrompt', () => { strategy: PromptingStrategy | undefined; includeLineNumbers?: IncludeLineNumbersOption; includePostScript?: boolean; + eagernessPrompt?: 'aggressionHighLow'; aggressivenessLevel?: AggressivenessLevel; rejectedEditsMemory?: RejectedEditsMemoryMode; }): PromptPieces { @@ -635,6 +636,7 @@ describe('getUserPrompt', () => { const promptOptions: PromptOptions = { ...DEFAULT_OPTIONS, promptingStrategy: opts.strategy, + eagernessPrompt: opts.eagernessPrompt, ...(opts.includePostScript !== undefined ? { includePostScript: opts.includePostScript } : {}), ...(opts.rejectedEditsMemory !== undefined ? { memory: { rejectedEdits: opts.rejectedEditsMemory } } : {}), currentFile: { @@ -816,6 +818,32 @@ describe('getUserPrompt', () => { expect(prompt).toContain(PromptTags.CURSOR_LOCATION.start + '\n' + ' const ' + PromptTags.CURSOR + 'x = 1;' + '\n' + PromptTags.CURSOR_LOCATION.end); }); + test.each([ + [AggressivenessLevel.Medium, ''], + [AggressivenessLevel.High, '<|aggression|>high<|/aggression|>'], + [AggressivenessLevel.Low, '<|aggression|>low<|/aggression|>'], + ])('PatchBased02 aggression prompt places the %s tag before the postscript', (aggressivenessLevel, aggressionTag) => { + const pieces = createTestPromptPieces({ + cursorLine: 2, + cursorColumn: 9, + strategy: PromptingStrategy.PatchBased02, + eagernessPrompt: 'aggressionHighLow', + aggressivenessLevel, + }); + const { prompt } = getUserPrompt(pieces); + + const cursorLocation = `${PromptTags.CURSOR_LOCATION.start}\n const ${PromptTags.CURSOR}x = 1;\n${PromptTags.CURSOR_LOCATION.end}`; + const postScript = 'The developer was working on a section of code within the `current_file_content`'; + expect(prompt).toContain(cursorLocation); + expect(prompt.indexOf(cursorLocation)).toBeLessThan(prompt.indexOf(postScript)); + if (aggressivenessLevel === AggressivenessLevel.Medium) { + expect(prompt).not.toContain('<|aggression|>'); + expect(prompt).toContain(`${PromptTags.CURSOR_LOCATION.end}\n\n${postScript}`); + } else { + expect(prompt).toContain(`${PromptTags.CURSOR_LOCATION.end}\n\n${aggressionTag}\n\n${postScript}`); + } + }); + describe('Xtab275AggressivenessHighLow', () => { test('medium level does not include aggressive tag', () => { const pieces = createTestPromptPieces({ diff --git a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts index eb48769041f7f1..ba571126085620 100644 --- a/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts +++ b/extensions/copilot/src/platform/inlineEdits/common/dataTypes/xtabPromptOptions.ts @@ -492,6 +492,8 @@ export namespace EditIntent { } } +export type EagernessPrompt = 'aggressionHighLow'; + export type PromptOptions = { readonly promptingStrategy: PromptingStrategy | undefined /* default */; readonly currentFile: CurrentFileOptions; @@ -503,6 +505,7 @@ export type PromptOptions = { readonly memory: PromptMemoryOptions | undefined; readonly includePostScript: boolean; readonly lintOptions: LintOptions | undefined; + readonly eagernessPrompt: EagernessPrompt | undefined; /** * When set, parts share a single pool of `totalTokens` and unused budget from * earlier parts in `order` cascades to later parts. When `undefined`, each @@ -559,12 +562,21 @@ export function isPromptingStrategy(value: string): value is PromptingStrategy { return (Object.values(PromptingStrategy) as string[]).includes(value); } -export function isAggressivenessStrategy(strategy: PromptingStrategy | undefined): boolean { - return strategy === PromptingStrategy.XtabAggressiveness - || strategy === PromptingStrategy.Xtab275Aggressiveness - || strategy === PromptingStrategy.Xtab275AggressivenessHighLow - || strategy === PromptingStrategy.Xtab275EditIntent - || strategy === PromptingStrategy.Xtab275EditIntentShort; +export function isEagernessPrompt(options: PromptOptions): boolean { + if (options.promptingStrategy === undefined) { + return false; + } + return (options.eagernessPrompt !== undefined && [ + PromptingStrategy.PatchBased02, + PromptingStrategy.PatchBased02WithRecentLineNumbers, + PromptingStrategy.PatchBased02WithoutRecentLineNumbers, + ].includes(options.promptingStrategy)) // eagerness prompt option is only supported for patch-based strategies + || [PromptingStrategy.XtabAggressiveness, + PromptingStrategy.Xtab275Aggressiveness, + PromptingStrategy.Xtab275AggressivenessHighLow, + PromptingStrategy.Xtab275EditIntent, + PromptingStrategy.Xtab275EditIntentShort, + ].includes(options.promptingStrategy); // first-class aggressiveness strategies } export function isRejectedEditMemoryEnabled(options: { readonly memory?: PromptMemoryOptions }): boolean { @@ -614,6 +626,7 @@ export namespace ResponseFormat { export const DEFAULT_OPTIONS: PromptOptions = { promptingStrategy: undefined, + eagernessPrompt: undefined, currentFile: { maxTokens: 1500, includeTags: true, @@ -673,6 +686,7 @@ export const LANGUAGE_CONTEXT_ENABLED_LANGUAGES: LanguageContextLanguages = { export interface ModelConfiguration { modelName: string; promptingStrategy: PromptingStrategy | undefined /* default */; + eagernessPrompt?: EagernessPrompt; includeTagsInCurrentFile: boolean; includePostScript?: boolean; currentFile?: Partial; @@ -742,6 +756,7 @@ export const LINT_OPTIONS_VALIDATOR: IValidator> = vObj({ export const MODEL_CONFIGURATION_VALIDATOR: IValidator = vObj({ 'modelName': vRequired(vString()), 'promptingStrategy': vUnion(vEnum(...Object.values(PromptingStrategy)), vUndefined()), + 'eagernessPrompt': vUnion(vEnum('aggressionHighLow'), vUndefined()), 'includeTagsInCurrentFile': vRequired(vBoolean()), 'includePostScript': vUnion(vBoolean(), vUndefined()), 'currentFile': vUnion(CurrentFileOptions.VALIDATOR, vUndefined()), diff --git a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts index 76cf202b1cb08c..d6a6d7f41a3751 100644 --- a/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts +++ b/extensions/copilot/src/platform/inlineEdits/test/common/xtabPromptOptions.spec.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; import { ImportChanges } from '../../common/dataTypes/importFilteringOptions'; -import { applyStrategyConfig, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy, RejectedEditsMemoryMode } from '../../common/dataTypes/xtabPromptOptions'; +import { applyStrategyConfig, DEFAULT_OPTIONS, GlobalBudgetOptions, IncludeLineNumbersOption, isEagernessPrompt, MODEL_CONFIGURATION_VALIDATOR, ModelConfiguration, PromptingStrategy, RejectedEditsMemoryMode } from '../../common/dataTypes/xtabPromptOptions'; function baseConfig(overrides: Partial = {}): ModelConfiguration { return { @@ -85,6 +85,12 @@ describe('applyStrategyConfig', () => { describe('MODEL_CONFIGURATION_VALIDATOR', () => { + it('accepts a config with eagernessPrompt', () => { + const result = MODEL_CONFIGURATION_VALIDATOR.validate(baseConfig({ eagernessPrompt: 'aggressionHighLow' })); + expect(result.error).toBeUndefined(); + expect(result.content?.eagernessPrompt).toBe('aggressionHighLow'); + }); + it('keeps rejected-edit memory off by default', () => { expect(DEFAULT_OPTIONS.memory).toBeUndefined(); expect(MODEL_CONFIGURATION_VALIDATOR.validate(baseConfig()).content?.memory).toBeUndefined(); @@ -114,6 +120,13 @@ describe('MODEL_CONFIGURATION_VALIDATOR', () => { }); }); +describe('isEagernessPrompt', () => { + it('recognizes the PatchBased02 aggression prompt option', () => { + expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.PatchBased02, eagernessPrompt: 'aggressionHighLow' })).toBe(true); + expect(isEagernessPrompt({ ...DEFAULT_OPTIONS, promptingStrategy: PromptingStrategy.PatchBased02 })).toBe(false); + }); +}); + describe('GlobalBudgetOptions', () => { function gb(overrides: Partial = {}): GlobalBudgetOptions { From 8729180f16cde365377bbedf497c0d96c89f5f31 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 12:43:37 -0700 Subject: [PATCH 21/28] Apply multi-diff options before attaching models (#331692) * Apply multi-diff options before attaching models ## Summary - apply per-document editor options before attaching a multi-diff model - prevent initial model construction from falling back to stale environment accessibility detection - add regression coverage for option ordering and effective accessibility support ## Testing - npm run eslint -- src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts - ./scripts/test.sh --run src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts --run src/vs/editor/test/browser/widget/diffEditorWidget.test.ts --run src/vs/workbench/contrib/multiDiffEditor/test/browser/multiDiffEditorInput.test.ts (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Make multi-diff test storage-independent Register an in-memory storage service so editor contributions do not depend on test execution order. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../multiDiffEditor/diffEditorItemTemplate.ts | 2 +- .../widget/multiDiffEditorWidget.test.ts | 104 ++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts diff --git a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts index 654b49c44a08bb..0d3d4996f86f73 100644 --- a/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts +++ b/src/vs/editor/browser/widget/multiDiffEditor/diffEditorItemTemplate.ts @@ -319,8 +319,8 @@ export class DiffEditorItemTemplate extends Disposable implements IPooledObject< this._dataStore.clear(); this._viewModel.set(data.viewModel, tx); - this.editor.setDiffModel(data.viewModel.diffEditorViewModelRef, tx); this.editor.updateOptions(updateOptions(value.options ?? {})); + this.editor.setDiffModel(data.viewModel.diffEditorViewModelRef, tx); }); if (value.onOptionsDidChange) { this._dataStore.add(value.onOptionsDidChange(() => { diff --git a/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts new file mode 100644 index 00000000000000..f6859bb4eabddf --- /dev/null +++ b/src/vs/editor/test/browser/widget/multiDiffEditorWidget.test.ts @@ -0,0 +1,104 @@ +/*--------------------------------------------------------------------------------------------- + * 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 sinon from 'sinon'; +import { Dimension } from '../../../../base/browser/dom.js'; +import { Event, ValueWithChangeEvent } from '../../../../base/common/event.js'; +import { waitForState } from '../../../../base/common/observable.js'; +import { URI } from '../../../../base/common/uri.js'; +import { mock } from '../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js'; +import { AccessibilitySupport } from '../../../../platform/accessibility/common/accessibility.js'; +import { IAccessibilitySignalService } from '../../../../platform/accessibilitySignal/browser/accessibilitySignalService.js'; +import { IActionViewItemService, NullActionViewItemService } from '../../../../platform/actions/browser/actionViewItemService.js'; +import { IMenu, IMenuService } from '../../../../platform/actions/common/actions.js'; +import { ServiceCollection } from '../../../../platform/instantiation/common/serviceCollection.js'; +import { IEditorProgressService } from '../../../../platform/progress/common/progress.js'; +import { InMemoryStorageService, IStorageService } from '../../../../platform/storage/common/storage.js'; +import { IDiffProviderFactoryService } from '../../../browser/widget/diffEditor/diffProviderFactoryService.js'; +import { DiffEditorWidget } from '../../../browser/widget/diffEditor/diffEditorWidget.js'; +import { RefCounted } from '../../../browser/widget/diffEditor/utils.js'; +import { IDocumentDiffItem, IMultiDiffEditorModel } from '../../../browser/widget/multiDiffEditor/model.js'; +import { MultiDiffEditorWidget } from '../../../browser/widget/multiDiffEditor/multiDiffEditorWidget.js'; +import { IWorkbenchUIElementFactory } from '../../../browser/widget/multiDiffEditor/workbenchUIElementFactory.js'; +import { EditorOption } from '../../../common/config/editorOptions.js'; +import { instantiateTextModel } from '../../common/testTextModel.js'; +import { TestDiffProviderFactoryService } from '../diff/testDiffProviderFactoryService.js'; +import { createCodeEditorServices } from '../testCodeEditor.js'; + +suite('MultiDiffEditorWidget', () => { + + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + teardown(() => { + sinon.restore(); + }); + + test('applies document options before attaching the diff model', async () => { + const services = new ServiceCollection(); + services.set(IAccessibilitySignalService, new class extends mock() { }()); + services.set(IActionViewItemService, new NullActionViewItemService()); + services.set(IEditorProgressService, new class extends mock() { }()); + services.set(IDiffProviderFactoryService, new TestDiffProviderFactoryService()); + services.set(IStorageService, disposables.add(new InMemoryStorageService())); + services.set(IMenuService, new class extends mock() { + override createMenu(): IMenu { + return new class extends mock() { + override readonly onDidChange = Event.None; + override getActions() { return []; } + override dispose(): void { } + }(); + } + }()); + const instantiationService = createCodeEditorServices(disposables, services); + + const originalUri = URI.parse('inmemory://original/test.js'); + const modifiedUri = URI.parse('inmemory://modified/test.js'); + const original = disposables.add(instantiateTextModel(instantiationService, 'const value = 1;', undefined, undefined, originalUri)); + const modified = disposables.add(instantiateTextModel(instantiationService, 'const value = 2;', undefined, undefined, modifiedUri)); + const documentItem = RefCounted.createOfNonDisposable({ + original, + modified, + options: { accessibilitySupport: 'off' }, + }, { dispose() { } }); + const model: IMultiDiffEditorModel = { + documents: ValueWithChangeEvent.const([documentItem]), + }; + + const updateOptionsSpy = sinon.spy(DiffEditorWidget.prototype, 'updateOptions'); + const setDiffModelSpy = sinon.spy(DiffEditorWidget.prototype, 'setDiffModel'); + + const container = document.createElement('div'); + const widget = instantiationService.createInstance( + MultiDiffEditorWidget, + container, + {} satisfies IWorkbenchUIElementFactory, + undefined, + ); + widget.layout(new Dimension(800, 600)); + const viewModel = widget.createViewModel(model); + await waitForState(viewModel.items, items => items.length === 1); + widget.setViewModel(viewModel); + widget.reveal({ original: originalUri, modified: modifiedUri }, { highlight: false }); + + try { + assert.deepStrictEqual({ + configuredAccessibilitySupport: updateOptionsSpy.firstCall.args[0].accessibilitySupport, + optionsAppliedBeforeModel: updateOptionsSpy.calledBefore(setDiffModelSpy), + effectiveAccessibilitySupport: widget.getActiveControl()?.getModifiedEditor().getOption(EditorOption.accessibilitySupport), + }, { + configuredAccessibilitySupport: 'off', + optionsAppliedBeforeModel: true, + effectiveAccessibilitySupport: AccessibilitySupport.Disabled, + }); + } finally { + widget.setViewModel(undefined); + viewModel.dispose(); + widget.dispose(); + documentItem.dispose(); + } + }); +}); From a5ff91e557fe062532f6b673302158f528cacb6c Mon Sep 17 00:00:00 2001 From: Justin Chen <54879025+justschen@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:06:00 -0700 Subject: [PATCH 22/28] agents window: fix sessions list progress (#331394) * agents window: fix sessions list progress * expose summary returned from listSessions * agentHost: handle repeated session listings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * address comments --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: dmitrivMS <9581278+dmitrivMS@users.noreply.github.com> --- .../agentHost/node/agentHostStateManager.ts | 126 ++++++++---- .../platform/agentHost/node/agentService.ts | 28 +-- .../agentHost/node/protocolServerHandler.ts | 2 +- .../test/node/agentHostStateManager.test.ts | 38 ++++ .../agentHost/test/node/agentService.test.ts | 78 ++++++- .../test/node/protocolServerHandler.test.ts | 194 +++++++++++++++++- .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 2 +- 7 files changed, 410 insertions(+), 58 deletions(-) diff --git a/src/vs/platform/agentHost/node/agentHostStateManager.ts b/src/vs/platform/agentHost/node/agentHostStateManager.ts index 4105cd38e58ef4..82303835f28c68 100644 --- a/src/vs/platform/agentHost/node/agentHostStateManager.ts +++ b/src/vs/platform/agentHost/node/agentHostStateManager.ts @@ -13,7 +13,7 @@ import { TelemetryLevel } from '../../telemetry/common/telemetry.js'; import { ActionType, ActionEnvelope, ActionOrigin, INotification, IRootConfigChangedAction, SessionAction, ChatAction, RootAction, StateAction, TerminalAction, ChangesetAction, ClientChangesetAction, AnnotationsAction, ClientAnnotationsAction, isRootAction, isSessionAction, isChatAction, isChangesetAction, isAnnotationsAction, type AuthRequiredParams, type ProgressParams, type SessionSummaryChangedParams } from '../common/state/sessionActions.js'; import type { IStateSnapshot } from '../common/state/sessionProtocol.js'; import { rootReducer, sessionReducer, chatReducer, changesetReducer, annotationsReducer } from '../common/state/sessionReducers.js'; -import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; +import { createRootState, createSessionState, createChatState, createDefaultChatSummary, chatSummaryFromState, buildDefaultChatUri, parseDefaultChatUri, parseRequiredSessionUriFromChatUri, parseSubagentSessionUri, isAhpChatChannel, isDefaultChatUri, mergeSessionWithDefaultChat, isAhpRootChannel, readSessionExternal, SessionLifecycle, withHostBuildInfo, type Changeset, type ChangesetState, type AnnotationsState, type ChatState, type ChatSummary, type Customization, type ISessionWithDefaultChat, type Message, type RootState, type SessionConfigState, type SessionMeta, type SessionState, type SessionSummary, type Turn, type URI, ROOT_STATE_URI, ChangesetStatus, IHostBuildInfo, SessionStatus } from '../common/state/sessionState.js'; import { AgentHostTelemetryLevelConfigKey, IPermissionsValue, platformRootSchema, telemetryLevelToAgentHostConfigValue } from '../common/agentHostSchema.js'; import { SessionConfigKey } from '../common/sessionConfigKeys.js'; import { parseChangesetUri } from '../common/changesetUri.js'; @@ -261,8 +261,10 @@ export class AgentHostStateManager extends Disposable { * since it closes over {@link _toSummary} and {@link _onDidEmitNotification}. */ private readonly _summaryNotifier: SessionSummaryNotifier; - /** Session summaries that clients have actually received through `root/sessionAdded`. */ + /** Session summaries exposed to at least one client through `root/listSessions` or `root/sessionAdded`. */ private readonly _publishedSessionSummaries = new Set(); + /** Session summaries globally published through `root/sessionAdded`. */ + private readonly _addedSessionSummaries = new Set(); private readonly _onDidEmitEnvelope = this._register(new Emitter()); readonly onDidEmitEnvelope: Event = this._onDidEmitEnvelope.event; @@ -327,6 +329,18 @@ export class AgentHostStateManager extends Disposable { }, )); } + + private _emitSessionAdded(summary: SessionSummary): void { + this._summaryNotifier.announce(summary.resource, summary); + this._publishedSessionSummaries.add(summary.resource); + this._addedSessionSummaries.add(summary.resource); + this._onDidEmitNotification.fire({ + type: 'root/sessionAdded', + channel: ROOT_STATE_URI, + summary, + }); + } + private readonly _log = (msg: string) => this._logService.warn(`[AgentHostStateManager] ${msg}`); get hasActiveSessions(): boolean { @@ -734,13 +748,7 @@ export class AgentHostStateManager extends Disposable { // its later flush emit incremental updates and what makes // `markSessionPersisted` a no-op. Provisional sessions // intentionally skip both until they are persisted. - this._summaryNotifier.announce(key, summary); - this._publishedSessionSummaries.add(key); - this._onDidEmitNotification.fire({ - type: 'root/sessionAdded', - channel: ROOT_STATE_URI, - summary, - }); + this._emitSessionAdded(summary); } return state; @@ -769,7 +777,7 @@ export class AgentHostStateManager extends Disposable { this._logService.warn(`[AgentHostStateManager] markSessionPersisted: unknown session ${key}`); return; } - if (!force && this._publishedSessionSummaries.has(key)) { + if (!force && this._addedSessionSummaries.has(key)) { return; } // Propagate the materialization-resolved fields so subscribers calling @@ -781,13 +789,7 @@ export class AgentHostStateManager extends Disposable { entry.modifiedAt = summary.modifiedAt; entry.changes = summary.changes; const full = this._toSummary(key, entry); - this._summaryNotifier.announce(key, full); - this._publishedSessionSummaries.add(key); - this._onDidEmitNotification.fire({ - type: 'root/sessionAdded', - channel: ROOT_STATE_URI, - summary: full, - }); + this._emitSessionAdded(full); } /** @@ -803,17 +805,11 @@ export class AgentHostStateManager extends Disposable { this._logService.trace(`[AgentHostStateManager] announceSurfacedSession: already in state ${key}`); return; } - if (this._publishedSessionSummaries.has(key)) { - this._logService.trace(`[AgentHostStateManager] announceSurfacedSession: already published ${key}`); + if (this._addedSessionSummaries.has(key)) { + this._logService.trace(`[AgentHostStateManager] announceSurfacedSession: already added ${key}`); return; } - this._summaryNotifier.announce(key, summary); - this._publishedSessionSummaries.add(key); - this._onDidEmitNotification.fire({ - type: 'root/sessionAdded', - channel: ROOT_STATE_URI, - summary, - }); + this._emitSessionAdded(summary); } /** Removes a surfaced session without affecting a live session. */ @@ -821,7 +817,9 @@ export class AgentHostStateManager extends Disposable { if (this._sessionStates.has(session)) { return; } - if (!this._publishedSessionSummaries.delete(session)) { + const wasPublished = this._publishedSessionSummaries.delete(session); + const wasAdded = this._addedSessionSummaries.delete(session); + if (!wasPublished && !wasAdded) { return; } this._summaryNotifier.remove(session); @@ -834,25 +832,22 @@ export class AgentHostStateManager extends Disposable { /** Publishes or unpublishes a live session summary without changing its session state. */ setSessionSummaryPublished(session: string, published: boolean): void { - if (published === this._publishedSessionSummaries.has(session)) { - return; - } - if (published) { + if (this._addedSessionSummaries.has(session)) { + return; + } const entry = this._sessionStates.get(session); if (!entry) { return; } const summary = this._toSummary(session, entry); - this._summaryNotifier.announce(session, summary); - this._publishedSessionSummaries.add(session); - this._onDidEmitNotification.fire({ - type: 'root/sessionAdded', - channel: ROOT_STATE_URI, - summary, - }); + this._emitSessionAdded(summary); } else { - this._publishedSessionSummaries.delete(session); + const wasPublished = this._publishedSessionSummaries.delete(session); + const wasAdded = this._addedSessionSummaries.delete(session); + if (!wasPublished && !wasAdded) { + return; + } this._summaryNotifier.remove(session); this._onDidEmitNotification.fire({ type: 'root/sessionRemoved', @@ -862,6 +857,58 @@ export class AgentHostStateManager extends Disposable { } } + /** Records `root/listSessions` baselines and returns a current snapshot for the response. */ + prepareSessionSummariesForListing(summaries: readonly SessionSummary[]): SessionSummary[] { + const result: SessionSummary[] = []; + for (const summary of summaries) { + const wasPublished = this._publishedSessionSummaries.has(summary.resource); + if (wasPublished) { + if (this._summaryNotifier.isDirty(summary.resource)) { + this._summaryNotifier.flush(summary.resource); + } + } + + const entry = this._sessionStates.get(summary.resource); + const current = entry ? this._toSummary(summary.resource, entry) : summary; + if (!wasPublished) { + this._summaryNotifier.announce(summary.resource, current); + this._publishedSessionSummaries.add(summary.resource); + } + result.push(entry ? this._mergeLiveSummaryForListing(summary, current) : summary); + } + return result; + } + + private _mergeLiveSummaryForListing(listed: SessionSummary, current: SessionSummary): SessionSummary { + const meta = listed._meta !== undefined || current._meta !== undefined + ? { ...listed._meta, ...current._meta } + : undefined; + return { + ...listed, + title: current.title || listed.title, + status: current.status, + activity: current.activity, + modifiedAt: current.modifiedAt, + project: current.project ?? listed.project, + workingDirectories: current.workingDirectories ?? listed.workingDirectories, + changes: current.changes ?? listed.changes, + ...(meta !== undefined ? { _meta: meta } : {}), + }; + } + + /** Returns external sessions exposed through either listing or global add notification. */ + getExposedExternalSessionKeys(): string[] { + const result: string[] = []; + for (const session of this._publishedSessionSummaries) { + const entry = this._sessionStates.get(session); + const summary = entry ? this._toSummary(session, entry) : this._summaryNotifier.getAnnounced(session); + if (readSessionExternal(summary?._meta)) { + result.push(session); + } + } + return result; + } + /** * Restores a session from a previous server lifetime into the state manager * with pre-populated turns. The session is created in `ready` lifecycle @@ -1184,6 +1231,7 @@ export class AgentHostStateManager extends Disposable { this.removeSession(session); if (wasPublished) { this._publishedSessionSummaries.delete(session.toString()); + this._addedSessionSummaries.delete(session.toString()); this._onDidEmitNotification.fire({ type: 'root/sessionRemoved', channel: ROOT_STATE_URI, diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index 72760e71437720..fd2f86f37725fe 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -2178,8 +2178,12 @@ export class AgentService extends Disposable implements IAgentService { private async _reconcileExternalSessions(previousMode?: AgentHostExternalSessionsMode): Promise { const startedAt = Date.now(); const previouslyBroadcast = new Set(this._broadcastExternalSessions); + const previouslyExposed = new Set(previouslyBroadcast); + for (const session of this._stateManager.getExposedExternalSessionKeys()) { + previouslyExposed.add(session); + } const listed = previousMode !== undefined - ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.All), previousMode, previouslyBroadcast) + ? this._resolveModeChangeVisibility(await this.listSessions(AgentHostExternalSessionsMode.All), previousMode, previouslyExposed) : await this.listSessions(); const visible = new Set(); let published = 0; @@ -2191,18 +2195,18 @@ export class AgentService extends Disposable implements IAgentService { visible.add(key); if (!previouslyBroadcast.has(key)) { published++; - if (this._stateManager.getSessionState(key)) { - this._stateManager.setSessionSummaryPublished(key, true); - } else { - const provider = AgentSession.provider(metadata.session); - if (provider) { - await this._announceSurfacedSession(metadata, provider); - } + } + if (this._stateManager.getSessionState(key)) { + this._stateManager.setSessionSummaryPublished(key, true); + } else { + const provider = AgentSession.provider(metadata.session); + if (provider) { + await this._announceSurfacedSession(metadata, provider); } } } let retracted = 0; - for (const key of previouslyBroadcast) { + for (const key of previouslyExposed) { if (!visible.has(key)) { retracted++; if (this._stateManager.getSessionState(key)) { @@ -2231,12 +2235,12 @@ export class AgentService extends Disposable implements IAgentService { * Derives both the previous and current mode's visible sets from one catalog * pass, since {@link AgentHostExternalSessionsMode.All} is a superset of every * mode and the mode is just a parameter to {@link _shouldIncludeSession}. - * Adds what `previousMode` had published into `previouslyBroadcast`. + * Adds what `previousMode` had exposed into `previouslyExposed`. */ private _resolveModeChangeVisibility( superset: readonly IAgentSessionMetadata[], previousMode: AgentHostExternalSessionsMode, - previouslyBroadcast: Set, + previouslyExposed: Set, ): IAgentSessionMetadata[] { const now = this._now(); const recentKeysFor = (mode: AgentHostExternalSessionsMode) => mode === AgentHostExternalSessionsMode.Recent @@ -2246,7 +2250,7 @@ export class AgentService extends Disposable implements IAgentService { const previousRecentKeys = recentKeysFor(previousMode); for (const session of superset) { if (readSessionExternal(session._meta) && this._shouldIncludeSession(session, previousMode, now, previousRecentKeys)) { - previouslyBroadcast.add(session.session.toString()); + previouslyExposed.add(session.session.toString()); } } diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index f2c85187b1498b..32790479dc690b 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -1464,7 +1464,7 @@ export class ProtocolServerHandler extends Disposable { ...(s._meta !== undefined ? { _meta: s._meta } : {}), } satisfies ListSessionsResult['items'][number]; }); - return { items }; + return { items: this._stateManager.prepareSessionSummariesForListing(items) }; }, resolveSessionConfig: async (_client, params) => { return this._agentService.resolveSessionConfig({ diff --git a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts index 90da4f75c61aa6..5cf4090d768f90 100644 --- a/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts +++ b/src/vs/platform/agentHost/test/node/agentHostStateManager.test.ts @@ -322,6 +322,44 @@ suite('AgentHostStateManager', () => { }); }); + test('listed provisional session still applies the materialization upsert', () => { + const provisional = { ...makeSessionSummary(), workingDirectories: ['file:///provisional'] }; + manager.createSession(provisional, { emitNotification: false }); + manager.dispatchServerAction(sessionChatUri, { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: '2025-01-01T00:00:00.000Z', + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + manager.prepareSessionSummariesForListing([manager.getSessionSummary(sessionUri)!]); + const notifications: INotification[] = []; + disposables.add(manager.onDidEmitNotification(notification => notifications.push(notification))); + + const persisted = { + ...makeSessionSummary(), + project: { uri: 'file:///resolved-worktree', displayName: 'Resolved Worktree' }, + workingDirectories: ['file:///resolved-worktree'], + }; + manager.markSessionPersisted(sessionUri, persisted); + + const added = notifications.find(notification => notification.type === NotificationType.SessionAdded); + assert.deepStrictEqual({ + status: manager.getSessionState(sessionUri)?.status, + project: manager.getSessionState(sessionUri)?.project, + workingDirectories: manager.getSessionState(sessionUri)?.workingDirectories, + addedStatus: added?.type === NotificationType.SessionAdded ? added.summary.status : undefined, + addedProject: added?.type === NotificationType.SessionAdded ? added.summary.project : undefined, + addedWorkingDirectories: added?.type === NotificationType.SessionAdded ? added.summary.workingDirectories : undefined, + }, { + status: SessionStatus.InProgress, + project: persisted.project, + workingDirectories: persisted.workingDirectories, + addedStatus: SessionStatus.InProgress, + addedProject: persisted.project, + addedWorkingDirectories: persisted.workingDirectories, + }); + }); + test('getActiveTurnId returns active turn id after turnStarted', () => { manager.createSession(makeSessionSummary()); manager.dispatchServerAction(sessionUri, { type: ActionType.SessionReady, }); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index ff098e1f46d7e5..b499b1dacb68e1 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -40,7 +40,7 @@ import { SessionConfigKey } from '../../common/sessionConfigKeys.js'; import { AgentMergeConfigKey, readAgentMergeSessionState } from '../../common/agentMerge.js'; import { SessionDatabase } from '../../node/sessionDatabase.js'; import { ActionType, ActionEnvelope, NotificationType } from '../../common/state/sessionActions.js'; -import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; +import { AH_META_IS_READ_DB_KEY, AH_META_IS_ARCHIVED_DB_KEY, AH_META_ORCHESTRATION_DB_KEY, AH_META_WORKSPACELESS_DB_KEY, ChangesetStatus, CustomizationType, MessageAttachmentKind, MessageKind, SessionActiveClient, ResponsePartKind, ROOT_STATE_URI, SESSION_META_FOLDER_PICKER_KEY, SESSION_META_MULTI_ROOT_KEY, SessionLifecycle, SessionSourceControlOutcome, SessionStatus, ToolCallCancellationReason, ToolCallConfirmationReason, ToolCallStatus, ToolResultContentType, TurnState, buildChatUri, buildDefaultChatUri, buildSubagentChatUri, buildSubagentSessionUri, customizationId, isDefaultChatUri, isSubagentSession, parseChatUri, parseSubagentSessionUri, readSessionEhcliAdoptable, readSessionExternal, readSessionGitHubState, readSessionMultiRootMetadata, readSessionFolderPickerDecision, readSessionOrchestration, readSessionSourceControlState, withSessionEhcliAdoptable, withSessionMultiRootMetadata, ChatOriginKind, type ChangesetState, type ISessionFolderPickerDecision, type ISessionOrchestration, type ISessionWithDefaultChat, type MarkdownResponsePart, type SessionState, type SessionSummary, type ToolCallCompletedState, type ToolCallResponsePart, type Turn } from '../../common/state/sessionState.js'; import { ChatInteractivity, type MessageAttachment } from '../../common/state/protocol/state.js'; import { IProductService } from '../../../product/common/productService.js'; import { AgentService } from '../../node/agentService.js'; @@ -2943,7 +2943,7 @@ suite('AgentService (node dispatcher)', () => { } } - function createExternalSessionService(now: () => number, sessionDataService = createSessionDataService()): AgentService { + function createExternalSessionService(now: () => number, sessionDataService = createSessionDataService(), orchestratorDatabase?: IAgentHostDatabase): AgentService { return disposables.add(new AgentService( new NullLogService(), fileService, @@ -2958,7 +2958,7 @@ suite('AgentService (node dispatcher)', () => { [], undefined, undefined, - undefined, + orchestratorDatabase, now, )); } @@ -2974,6 +2974,29 @@ suite('AgentService (node dispatcher)', () => { await (service as unknown as { _sessionListReconciliation: Promise })._sessionListReconciliation; } + function exposeListedSessions(service: AgentService, sessions: readonly IAgentSessionMetadata[]): void { + const summaries = sessions.map((session): SessionSummary => { + const provider = AgentSession.provider(session.session); + if (!provider) { + throw new Error(`Session has no provider: ${session.session.toString()}`); + } + return { + resource: session.session.toString(), + provider, + title: session.summary ?? 'Session', + status: session.status ?? SessionStatus.Idle, + activity: session.activity, + createdAt: new Date(session.startTime).toISOString(), + modifiedAt: new Date(session.modifiedTime).toISOString(), + ...(session.project ? { project: { uri: session.project.uri.toString(), displayName: session.project.displayName } } : {}), + workingDirectories: session.workingDirectories?.map(directory => directory.toString()), + changes: session.changes, + ...(session._meta !== undefined ? { _meta: session._meta } : {}), + }; + }); + service.stateManager.prepareSessionSummariesForListing(summaries); + } + test('listSessions aggregates sessions from all providers', async () => { service.registerProvider(copilotAgent); @@ -3142,6 +3165,55 @@ suite('AgentService (node dispatcher)', () => { }); }); + test('recent re-adds a registry-known external session after restart list visibility rotates', async () => { + const now = Date.now(); + const database = new TransientRegistryWriteDatabase(); + const first = AgentSession.uri('copilot', 'first'); + const second = AgentSession.uri('copilot', 'second'); + const third = AgentSession.uri('copilot', 'third'); + for (const [session, startTime] of [[first, now - 1], [second, now - 2], [third, now - 3]] as const) { + await database.registerSession(session.toString(), { provider: 'copilot', startTime, source: 'discovery' }, { checkTombstone: true }); + } + await database.markProviderBackfilled('copilot'); + + const svc = createExternalSessionService(() => now, createSessionDataService(), database); + setExternalSessionsMode(svc, AgentHostExternalSessionsMode.Recent, 1); + await waitForSessionListReconciliation(svc); + const agent = disposables.add(new TimedExternalAgent('copilot')); + agent.addSession('first', now - 1); + agent.addSession('second', now - 2); + agent.addSession('third', now - 3); + svc.registerProvider(agent); + + const initiallyListed = await svc.listSessions(); + exposeListedSessions(svc, initiallyListed); + const notifications: string[] = []; + disposables.add(svc.onDidNotification(notification => { + if (notification.type === NotificationType.SessionAdded) { + notifications.push(`add:${AgentSession.id(URI.parse(notification.summary.resource))}`); + } else if (notification.type === NotificationType.SessionRemoved) { + notifications.push(`remove:${AgentSession.id(URI.parse(notification.session))}`); + } + })); + + agent.addSession('third', now); + (svc as unknown as { _queueSessionListReconciliation(): void })._queueSessionListReconciliation(); + await waitForSessionListReconciliation(svc); + agent.addSession('second', now + 1); + (svc as unknown as { _queueSessionListReconciliation(): void })._queueSessionListReconciliation(); + await waitForSessionListReconciliation(svc); + + assert.deepStrictEqual({ + initiallyListed: initiallyListed.map(session => AgentSession.id(session.session)), + visible: (await svc.listSessions()).map(session => AgentSession.id(session.session)), + notifications, + }, { + initiallyListed: ['first', 'second'], + visible: ['second', 'third'], + notifications: ['add:first', 'add:third', 'remove:second', 'add:second', 'remove:first'], + }); + }); + test('external discovery reconciles against a mode change that completes while registration is in flight', async () => { const now = Date.now(); const svc = createExternalSessionService(() => now); diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index 86a916836be07e..d696ddc2c54e15 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -23,7 +23,7 @@ import { ActionType, type ActionEnvelope, type IRootConfigChangedAction, type Se import { PROTOCOL_VERSION } from '../../common/state/protocol/version/registry.js'; import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, JSON_RPC_INTERNAL_ERROR, JsonRpcErrorCodes, ProtocolError, AhpErrorCodes, AHP_UNSUPPORTED_PROTOCOL_VERSION, AHP_SESSION_NOT_FOUND, type AhpNotification, type InitializeResult, type ProtocolMessage, type ReconnectResult, type ResourceListResult, type ResourceWriteParams, type ResourceWriteResult, type IStateSnapshot } from '../../common/state/sessionProtocol.js'; import { MessageKind, ResponsePartKind, SessionStatus, ChangesetStatus, ToolCallConfirmationReason, ToolCallContributorKind, ToolCallStatus, ToolResultContentType, buildChatUri, buildDefaultChatUri, readSessionExternal, readSessionWorkspaceless, withSessionExternal, withSessionWorkspaceless, type SessionSummary } from '../../common/state/sessionState.js'; -import type { SessionAddedParams } from '../../common/state/protocol/notifications.js'; +import type { SessionAddedParams, SessionSummaryChangedParams } from '../../common/state/protocol/notifications.js'; import type { IProtocolServer, IProtocolTransport } from '../../common/state/sessionTransport.js'; import { ProtocolServerHandler } from '../../node/protocolServerHandler.js'; import { CompositeProtocolServer } from '../../node/compositeProtocolServer.js'; @@ -149,6 +149,7 @@ class MockAgentService implements IAgentService { shutdownCalls = 0; createSessionBarrier: DeferredPromise | undefined; subscribeBarrier: DeferredPromise | undefined; + afterListSessionsSnapshot: (() => void) | undefined; private readonly _onDidAction = new Emitter(); readonly onDidAction = this._onDidAction.event; @@ -203,7 +204,11 @@ class MockAgentService implements IAgentService { this.disposedChats.push({ session: session.toString(), chat: chat.toString() }); this._stateManager.removeChat(session.toString(), chat.toString()); } - async listSessions(): Promise { return this.listedSessions; } + async listSessions(): Promise { + const result = [...this.listedSessions]; + this.afterListSessionsSnapshot?.(); + return result; + } async subscribe(resource: URI, _clientId: string): Promise { await this.subscribeBarrier?.p; const snapshot = this._stateManager.getSnapshot(resource.toString()); @@ -986,6 +991,191 @@ suite('ProtocolServerHandler', () => { assert.deepStrictEqual(result.items.map(item => item.project), [{ uri: URI.file('/workspace/project').toString(), displayName: 'Project' }]); }); + test('listSessions exposure does not suppress a global sessionAdded notification', async () => { + const summary = makeSessionSummary(); + const transportA = connectClient('client-list-exposed'); + const transportB = connectClient('client-list-missing'); + transportA.sent.length = 0; + transportB.sent.length = 0; + + let responsePromise = waitForResponse(transportB, 2); + transportB.simulateMessage(request(2, 'listSessions')); + await responsePromise; + + agentService.listedSessions.push({ + session: URI.parse(summary.resource), + startTime: Date.parse(summary.createdAt), + modifiedTime: Date.parse(summary.modifiedAt), + summary: summary.title, + status: summary.status, + }); + responsePromise = waitForResponse(transportA, 2); + transportA.simulateMessage(request(2, 'listSessions')); + await responsePromise; + transportA.sent.length = 0; + transportB.sent.length = 0; + + stateManager.announceSurfacedSession(summary); + + assert.deepStrictEqual({ + exposedClient: findNotifications(transportA.sent, 'root/sessionAdded').length, + missingClient: findNotifications(transportB.sent, 'root/sessionAdded').length, + }, { + exposedClient: 1, + missingClient: 1, + }); + }); + + test('listSessions publishes only changed canonical fields for restored sessions', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const project = { uri: 'file:///test-project', displayName: 'Test Project' }; + const summary = { + ...makeSessionSummary(), + project, + workingDirectories: ['file:///test-project'], + changes: { additions: 1, deletions: 2, files: 3 }, + _meta: { live: 'current' }, + }; + const startedAt = new Date(Date.parse(summary.modifiedAt) + 1000).toISOString(); + stateManager.restoreSession(summary, []); + agentService.listedSessions.push({ + session: URI.parse(summary.resource), + startTime: Date.parse(summary.createdAt), + modifiedTime: Date.parse(summary.modifiedAt), + summary: summary.title, + status: summary.status, + project: { uri: URI.parse(project.uri), displayName: project.displayName }, + workingDirectories: summary.workingDirectories.map(directory => URI.parse(directory)), + changes: { ...summary.changes }, + _meta: { providerOnly: true, live: 'stale' }, + }); + + const transport = connectClient('client-list-status'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'listSessions')); + const response = await responsePromise; + const listedMeta = (response as unknown as { result: ListSessionsResult }).result.items[0]._meta; + transport.sent.length = 0; + + stateManager.dispatchServerAction(buildDefaultChatUri(sessionUri), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt, + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + await new Promise(resolve => setTimeout(resolve, 150)); + + const summaryChanges = transport.sent + .filter(isJsonRpcNotification) + .filter(message => message.method === 'root/sessionSummaryChanged') + .map(message => (message.params as SessionSummaryChangedParams).changes); + assert.deepStrictEqual({ listedMeta, summaryChanges }, { + listedMeta: { providerOnly: true, live: 'current' }, + summaryChanges: [{ status: SessionStatus.InProgress }], + }); + }); + }); + + test('repeated listSessions flushes a pending status before returning the new baseline', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const summary = makeSessionSummary(); + stateManager.restoreSession(summary, []); + const listedSession: IAgentSessionMetadata = { + session: URI.parse(summary.resource), + startTime: Date.parse(summary.createdAt), + modifiedTime: Date.parse(summary.modifiedAt), + summary: summary.title, + status: summary.status, + }; + agentService.listedSessions.push(listedSession); + + const transport = connectClient('client-repeat-list-status'); + transport.sent.length = 0; + let responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'listSessions')); + await responsePromise; + transport.sent.length = 0; + + stateManager.dispatchServerAction(buildDefaultChatUri(sessionUri), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt: new Date().toISOString(), + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + agentService.listedSessions[0] = { ...listedSession, status: SessionStatus.InProgress }; + responsePromise = waitForResponse(transport, 3); + transport.simulateMessage(request(3, 'listSessions')); + const response = await responsePromise; + const listedStatus = (response as unknown as { result: ListSessionsResult }).result.items[0].status; + + stateManager.dispatchServerAction(buildDefaultChatUri(sessionUri), { + type: ActionType.ChatTurnComplete, + turnId: 'turn-1', + duration: 1, + }); + await new Promise(resolve => setTimeout(resolve, 150)); + + const statusChanges = transport.sent + .filter(isJsonRpcNotification) + .filter(message => message.method === 'root/sessionSummaryChanged') + .map(message => (message.params as SessionSummaryChangedParams).changes.status); + assert.deepStrictEqual({ listedStatus, statusChanges }, { + listedStatus: SessionStatus.InProgress, + statusChanges: [SessionStatus.InProgress, SessionStatus.Idle], + }); + }); + }); + + test('repeated listSessions refreshes a stale snapshot before its response', () => { + return runWithFakedTimers({ useFakeTimers: true }, async () => { + const summary = makeSessionSummary(); + const startedAt = new Date(Date.parse(summary.modifiedAt) + 1000).toISOString(); + stateManager.restoreSession(summary, []); + agentService.listedSessions.push({ + session: URI.parse(summary.resource), + startTime: Date.parse(summary.createdAt), + modifiedTime: Date.parse(summary.modifiedAt), + summary: summary.title, + status: summary.status, + }); + + const transport = connectClient('client-stale-list-status'); + transport.sent.length = 0; + let responsePromise = waitForResponse(transport, 2); + transport.simulateMessage(request(2, 'listSessions')); + await responsePromise; + transport.sent.length = 0; + + agentService.afterListSessionsSnapshot = () => { + agentService.afterListSessionsSnapshot = undefined; + stateManager.dispatchServerAction(buildDefaultChatUri(sessionUri), { + type: ActionType.ChatTurnStarted, + turnId: 'turn-1', + startedAt, + message: { text: 'hello', origin: { kind: MessageKind.User } }, + }); + }; + responsePromise = waitForResponse(transport, 3); + transport.simulateMessage(request(3, 'listSessions')); + await responsePromise; + + const observed = transport.sent.flatMap(message => { + if (isJsonRpcNotification(message) && message.method === 'root/sessionSummaryChanged') { + return [{ kind: 'notification', status: (message.params as SessionSummaryChangedParams).changes.status }]; + } + if (isJsonRpcResponse(message) && message.id === 3 && hasKey(message, { result: true })) { + return [{ kind: 'response', status: (message.result as ListSessionsResult).items[0].status }]; + } + return []; + }); + assert.deepStrictEqual(observed, [ + { kind: 'notification', status: SessionStatus.InProgress }, + { kind: 'response', status: SessionStatus.InProgress }, + ]); + }); + }); + test('listSessions omits project metadata when absent', async () => { agentService.listedSessions.push({ session: URI.parse(sessionUri), diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 4780d8f7fdb63b..09b142fc6ba4e8 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -139,7 +139,7 @@ The **only** per-provider difference is the storage key: local uses the fixed `l ### External session visibility -Provider-native sessions discovered outside the Agent Host carry `_meta.external`. `chat.agentSessions.showExternal` controls whether the catalog publishes none, all, the last 24 hours, or the last 7 days (the default). Configuration changes publish or unpublish matching summaries immediately, including restored sessions, while retaining live Agent Host state so a later settings change can surface them again. The state manager distinguishes a summary retained as the diff baseline from one actually published through `root/sessionAdded`; restoring a filtered session records the former without implying the latter, and hidden summary changes advance that baseline without emitting root notifications. +Provider-native sessions discovered outside the Agent Host carry `_meta.external`. `chat.agentSessions.showExternal` controls whether the catalog publishes none, all, the last 24 hours, or the last 7 days (the default). Configuration changes publish or unpublish matching summaries immediately, including restored sessions, while retaining live Agent Host state so a later settings change can surface them again. The state manager separately tracks the canonical diff baseline, exposure through a client-scoped `root/listSessions` response, and process-wide delivery through `root/sessionAdded`. A list response therefore enables later summary deltas without suppressing a global add needed by other clients, while restoring a filtered session records only the baseline and hidden summary changes advance it without emitting root notifications. Copilot discovery includes external SDK sessions only when their persisted `clientName` is exactly `github/cli` or `github/autopilot`, their persisted context includes non-empty repository metadata, and they were modified within the last seven days. Unknown and missing client names, repository-less sessions, and older sessions are excluded. `clientName` identifies the runtime client that created or last resumed the session, not immutable creator provenance. From 8cbcdace12f3426168e4b6b5f32ce5487ae9f2ce Mon Sep 17 00:00:00 2001 From: Don Jayamanne Date: Thu, 20 Aug 2026 06:10:43 +1000 Subject: [PATCH 23/28] agentHost: log shadowed Claude customizations in multi-root workspaces (#331696) In a multi-root workspace, when two folders each contribute a standalone skill or agent with the same name, first-name-wins discovery keeps the primary folder's copy and drops the other. The Claude SDK/CLI exposes standalone entries by name only, so the shadowed copy is unreachable by name and was previously dropped with no diagnostic. Add an optional `onShadowed` callback to `selectFirstClaudeCustomizationByKey` and use it in the multi-root discovery path to log a warning naming the dropped customization, its source URI, and the winning copy. Discovery output is unchanged; same-named user-scope copies remain ordinary precedence and are not logged. Refs #331508 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../claudeCustomizationPolicy.ts | 12 +++++++++- .../claudeMultiRootCustomizationDiscovery.ts | 21 ++++++++++------ ...udeMultiRootCustomizationDiscovery.test.ts | 24 +++++++++++++++++++ src/vs/sessions/AI_CUSTOMIZATIONS.md | 2 +- 4 files changed, 50 insertions(+), 9 deletions(-) diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts index 2f4e3b1bf96b9a..dcea84e2865944 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeCustomizationPolicy.ts @@ -6,13 +6,23 @@ import { isEqualOrParent } from '../../../../../base/common/resources.js'; import { URI } from '../../../../../base/common/uri.js'; -export function selectFirstClaudeCustomizationByKey(groups: readonly (readonly T[])[], keyOf: (item: T) => string): readonly T[] { +/** + * First-seen-per-key wins across `groups` (ordered highest precedence first). + * `onShadowed` is invoked for each later item dropped because its key was + * already claimed, receiving the dropped item and the winning item. + */ +export function selectFirstClaudeCustomizationByKey(groups: readonly (readonly T[])[], keyOf: (item: T) => string, onShadowed?: (shadowed: T, winner: T) => void): readonly T[] { const selected = new Map(); for (const group of groups) { for (const item of group) { const key = keyOf(item); if (!selected.has(key)) { selected.set(key, item); + continue; + } + const winner = selected.get(key); + if (winner !== undefined) { + onShadowed?.(item, winner); } } } diff --git a/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts b/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts index 8210cd1124ae6b..24e05cb385e612 100644 --- a/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts +++ b/src/vs/platform/agentHost/node/claude/customizations/claudeMultiRootCustomizationDiscovery.ts @@ -53,16 +53,23 @@ export async function discoverClaudeMultiRootCustomizations( ]); return { workingDirectories: roots, discovered, nativePlugins }; } - const [scopes, nativePlugins] = await Promise.all([ - Promise.all([ - ...roots.map((root, index) => scanClaudeCustomizationScope(root, fileService, index === 0)), - scanClaudeCustomizationScope(userHome, fileService), - ]), + const [rootScopes, userScope, nativePlugins] = await Promise.all([ + Promise.all(roots.map((root, index) => scanClaudeCustomizationScope(root, fileService, index === 0))), + scanClaudeCustomizationScope(userHome, fileService), scanClaudeNativePluginsForRoots(roots, userHome, fileService, logService), ]); + const scopes = [...rootScopes, userScope]; + // User-scope overrides are expected precedence; only warn on cross-workspace-folder collisions. + const userScopeItems = new Set(userScope); + const logShadowedAcrossRoots = (kind: string) => (shadowed: IParsedAgent | IParsedSkill, winner: IParsedAgent | IParsedSkill): void => { + if (userScopeItems.has(shadowed)) { + return; + } + logService.warn(`[claudeMultiRootCustomizationDiscovery] ${kind} '${shadowed.name}' at '${shadowed.uri.toString()}' is shadowed by '${winner.uri.toString()}' from another workspace folder and is unreachable by name`); + }; const discovered = [ - ...selectFirstClaudeCustomizationByKey(scopes.map(items => items.filter(isParsedAgent)), item => item.name), - ...selectFirstClaudeCustomizationByKey(scopes.map(items => items.filter(isParsedSkill)), item => item.name), + ...selectFirstClaudeCustomizationByKey(scopes.map(items => items.filter(isParsedAgent)), item => item.name, logShadowedAcrossRoots('agent')), + ...selectFirstClaudeCustomizationByKey(scopes.map(items => items.filter(isParsedSkill)), item => item.name, logShadowedAcrossRoots('skill')), ]; return { workingDirectories: roots, diff --git a/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts b/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts index 965a0c09aeb5d3..df0acde0816c0b 100644 --- a/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts +++ b/src/vs/platform/agentHost/test/node/customizations/claudeMultiRootCustomizationDiscovery.test.ts @@ -15,6 +15,13 @@ import { scanClaudeDiskCustomizations } from '../../../node/claude/customization import { scanClaudeNativePlugins } from '../../../node/claude/customizations/scan/claudeNativePluginScan.js'; import { createInMemoryFileService, seedFile } from './claudeCustomizationTestUtils.js'; +class CapturingLogService extends NullLogService { + readonly warns: string[] = []; + override warn(message: string, ...args: unknown[]): void { + this.warns.push([message, ...args.map(a => String(a))].join(' ')); + } +} + suite('claudeMultiRootCustomizationDiscovery', () => { const disposables = new DisposableStore(); const rootA = URI.from({ scheme: Schemas.inMemory, path: '/a' }); @@ -84,6 +91,23 @@ suite('claudeMultiRootCustomizationDiscovery', () => { }); }); + test('warns when a workspace folder skill or agent is shadowed by another folder', async () => { + const agentA = await seed('/a/.claude/agents/reviewer.md', '---\nname: reviewer\ndescription: from a\n---'); + const agentB = await seed('/b/.claude/agents/reviewer.md', '---\nname: reviewer\ndescription: from b\n---'); + const skillA = await seed('/a/.claude/skills/deploy/SKILL.md', '---\nname: deploy\ndescription: from a\n---'); + const skillB = await seed('/b/.claude/skills/deploy/SKILL.md', '---\nname: deploy\ndescription: from b\n---'); + // A same-named user-scope skill is an expected override, not a cross-folder collision. + await seed('/home/.claude/skills/deploy/SKILL.md', '---\nname: deploy\ndescription: from user\n---'); + const logService = new CapturingLogService(); + + await discoverClaudeMultiRootCustomizations([rootA, rootB], userHome, fileService, logService); + + assert.deepStrictEqual(logService.warns.sort(), [ + `[claudeMultiRootCustomizationDiscovery] agent 'reviewer' at '${agentB.toString()}' is shadowed by '${agentA.toString()}' from another workspace folder and is unreachable by name`, + `[claudeMultiRootCustomizationDiscovery] skill 'deploy' at '${skillB.toString()}' is shadowed by '${skillA.toString()}' from another workspace folder and is unreachable by name`, + ].sort()); + }); + test('deduplicates equivalent roots without changing precedence', async () => { await seed('/a/.claude/agents/a.md', '---\nname: a\ndescription: A\n---'); diff --git a/src/vs/sessions/AI_CUSTOMIZATIONS.md b/src/vs/sessions/AI_CUSTOMIZATIONS.md index cee3b067fc4b1d..f027fec91d7d51 100644 --- a/src/vs/sessions/AI_CUSTOMIZATIONS.md +++ b/src/vs/sessions/AI_CUSTOMIZATIONS.md @@ -153,7 +153,7 @@ The shared plugin discovery pipeline selects format-specific component paths whi Runtime projection is provider-specific. Copilot receives strict skills and MCP explicitly rather than through legacy SDK plugin-directory discovery. Codex receives strict skill roots plus MCP, with remote transport selected by its existing auto-detection. Claude excludes strict packages from legacy plugin discovery and can project remote MCP through its existing auto-detection, but its current SDK cannot register external skill directories or provide the per-server working directory required by strict stdio MCP, so those components are reported and skipped. -Claude Agent Host multi-root customization discovery is gated by the hidden, default-off `chat.agentHost.claudeAgent.multiRootEnabled` setting. When enabled, the primary working directory and each SDK `additionalDirectories` root contribute standalone `.claude/agents`, `.claude/skills`, and native plugin enablement to the Customizations editor. Roots are processed in session order, followed by user scope; same-named standalone agents or skills use the first visible definition as the display source. This display policy is centralized because the SDK reports standalone entries by name rather than source URI. Native plugin loaded state remains authoritative from the SDK snapshot. Rules, hooks, MCP configuration, commands, and CLAUDE.md remain primary-root/user scoped because Claude additional directories do not load those configuration types. Each contributing root has its own writable directory container, and secondary-root watchers observe only agents, skills, and plugin settings. +Claude Agent Host multi-root customization discovery is gated by the hidden, default-off `chat.agentHost.claudeAgent.multiRootEnabled` setting. When enabled, the primary working directory and each SDK `additionalDirectories` root contribute standalone `.claude/agents`, `.claude/skills`, and native plugin enablement to the Customizations editor. Roots are processed in session order, followed by user scope; same-named standalone agents or skills use the first visible definition as the display source. This display policy is centralized because the SDK reports standalone entries by name rather than source URI. When a standalone agent or skill in one workspace folder is shadowed by a same-named copy in an earlier folder, the dropped copy is logged as a warning because it is unreachable by name (matching the Claude CLI); same-named user-scope copies are ordinary precedence and are not logged. Native plugin loaded state remains authoritative from the SDK snapshot. Rules, hooks, MCP configuration, commands, and CLAUDE.md remain primary-root/user scoped because Claude additional directories do not load those configuration types. Each contributing root has its own writable directory container, and secondary-root watchers observe only agents, skills, and plugin settings. ### IHarnessDescriptor From bcb1c853a25b9aca473d1960da1798f471863b86 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 13:21:14 -0700 Subject: [PATCH 24/28] chat: Prevent duplicate remote Agent Host queue sends (#331698) chat: prevent duplicate remote Agent Host queue sends Classify server-managed queues using the registered Agent Host provider metadata so remote sessions are not dequeued by both the client and host. Add regression coverage for remote queues.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/chatService/chatServiceImpl.ts | 2 +- .../common/chatService/chatService.test.ts | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts index c19a6ccc2b4e22..ec9b12a291f5fb 100644 --- a/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/chatService/chatServiceImpl.ts @@ -1858,7 +1858,7 @@ export class ChatService extends Disposable implements IChatService { * controls queued-message dequeuing on the server side. */ private _isServerManagedQueue(sessionResource: URI): boolean { - return getChatSessionType(sessionResource).startsWith('agent-host-'); + return this.chatSessionService.getChatSessionContribution(getChatSessionType(sessionResource))?.agentHostProviderId !== undefined; } /** diff --git a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts index de80441ae886be..4c25631e0dac8e 100644 --- a/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/chatService/chatService.test.ts @@ -1374,6 +1374,62 @@ suite('ChatService', () => { assert.strictEqual(model.getPendingRequests().length, 0); }); + test('does not locally dequeue pending requests for remote agent host sessions', async () => { + const sessionType = 'remote-neat-cat-copilotcli'; + const sessionResource = URI.from({ scheme: sessionType, path: '/session-server-managed-queue' }); + + const mockSessionsService = new MockChatSessionsService(); + mockSessionsService.setContributions([{ + type: sessionType, + name: 'Remote Agent Host', + displayName: 'Remote Agent Host', + description: 'Remote Agent Host', + agentHostProviderId: 'copilotcli', + }]); + testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { + provideChatSessionContent: resource => Promise.resolve({ + sessionResource: resource, + history: [], + onWillDispose: Event.None, + dispose: () => { }, + }), + })); + instantiationService.stub(IChatSessionsService, mockSessionsService); + + const invokedMessages: string[] = []; + testDisposables.add(chatAgentService.registerAgent(sessionType, { ...getAgentData(sessionType), isDefault: true })); + testDisposables.add(chatAgentService.registerAgentImplementation(sessionType, { + async invoke(request) { + invokedMessages.push(request.message); + return {}; + }, + })); + + const testService = createChatService(); + const ref = await testService.acquireOrLoadSession(sessionResource, ChatAgentLocation.Chat, CancellationToken.None); + assert.ok(ref); + testDisposables.add(ref); + + const result = await testService.sendRequest(sessionResource, 'queued message', { agentIdSilent: sessionType, queue: ChatRequestQueueKind.Queued }); + assert.ok(ChatSendResult.isQueued(result)); + await timeout(0); + + const model = testService.getSession(sessionResource) as ChatModel; + const pendingRequests = model.getPendingRequests(); + const actual = { + invokedMessages, + pendingMessages: pendingRequests.map(request => request.request.message.text), + }; + for (const pendingRequest of pendingRequests) { + testService.removePendingRequest(sessionResource, pendingRequest.request.id); + } + + assert.deepStrictEqual(actual, { + invokedMessages: [], + pendingMessages: ['queued message'], + }); + }); + test('sendPendingRequestImmediately re-sends a steering message as a turn on agent host sessions', async () => { const sessionType = 'agent-host-copilot'; const sessionResource = URI.from({ scheme: sessionType, path: '/session-send-immediately' }); @@ -1384,6 +1440,7 @@ suite('ChatService', () => { name: 'Agent Host', displayName: 'Agent Host', description: 'Agent Host', + agentHostProviderId: 'copilot', }]); testDisposables.add(mockSessionsService.registerChatSessionContentProvider(sessionType, { provideChatSessionContent: resource => Promise.resolve({ From 7e0ab9d1672db1f56f2a67913f4df63a609a2119 Mon Sep 17 00:00:00 2001 From: Ralph Feltis Date: Wed, 19 Aug 2026 14:19:44 -0700 Subject: [PATCH 25/28] Revert chat quota trajectory nudge (#331401) * Revert chat quota trajectory nudge Remove the Chat Quota Trajectory Nudge after shipping control by stopping the Public and Insiders ExP stages. This drops the feature from #320683 and the billing-period fix from #325895. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove unused ChatInputNotificationSeverity import Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../chat/browser/chatQuotaNotification.ts | 184 --------- .../browser/chatQuotaNotification.test.ts | 370 +----------------- 2 files changed, 3 insertions(+), 551 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts index c38676c949a2d8..7982a04bced819 100644 --- a/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts +++ b/src/vs/workbench/contrib/chat/browser/chatQuotaNotification.ts @@ -4,17 +4,11 @@ *--------------------------------------------------------------------------------------------*/ import { safeIntl } from '../../../../base/common/date.js'; -import { createMarkdownCommandLink, MarkdownString } from '../../../../base/common/htmlContent.js'; import { Disposable, DisposableStore } from '../../../../base/common/lifecycle.js'; -import { URI } from '../../../../base/common/uri.js'; import { localize } from '../../../../nls.js'; -import { CommandsRegistry } from '../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../platform/log/common/log.js'; -import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { IWorkbenchContribution } from '../../../common/contributions.js'; import { IWorkbenchAssignmentService } from '../../../services/assignment/common/assignmentService.js'; import { ChatEntitlement, IChatEntitlementService, IQuotaSnapshot, IRateLimitSnapshot } from '../../../services/chat/common/chatEntitlementService.js'; @@ -25,37 +19,6 @@ import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatIn const QUOTA_NOTIFICATION_ID = 'copilot.quotaStatus'; const THRESHOLDS = [50, 75, 90, 95]; const SWITCH_TO_AUTO_TREATMENT_NAME = 'config.chatQuotaWarningSwitchToAuto'; -const TRAJECTORY_NUDGE_SPEC = { - treatmentName: 'config.chatQuotaTrajectoryNudge', - shownStorageKey: 'chat.quotaTrajectory.shownPeriod', - averageDailyUsageThreshold: 4.5, - minimumPercentUsed: 10, - maximumPercentUsed: 35, - msPerDay: 24 * 60 * 60 * 1000, - learnMoreUrl: 'https://aka.ms/token-usage-tips', - learnMoreCommandId: 'workbench.action.chat.learnMoreAboutCreditUsage', -} as const; - -type ChatQuotaTrajectoryNudgeLinkClickedClassification = { - owner: 'rfeltis'; - comment: 'Tracks when users click the chat quota trajectory nudge learn more link.'; -}; - -type ChatQuotaTrajectoryNudgeEnrollmentEvent = { - treatment: boolean; - entitlement: string; - averageDailyUsage: number; - percentUsed: number; -}; - -type ChatQuotaTrajectoryNudgeEnrollmentClassification = { - owner: 'rfeltis'; - comment: 'Tracks when a user is assigned to a flight for the chat quota trajectory nudge experiment, to measure experiment exposure.'; - treatment: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The treatment value assigned by the experiment service (true for the treatment arm, false for control).' }; - entitlement: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'The user entitlement when the user was assigned to the experiment flight.' }; - averageDailyUsage: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The average daily monthly quota usage percentage when the user was assigned to the experiment flight.' }; - percentUsed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'The monthly quota percentage used when the user was assigned to the experiment flight.' }; -}; /** * Persisted flag remembering that the user dismissed the quota-exceeded @@ -96,8 +59,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo private _switchToAutoTreatment: boolean | undefined; private _switchToAutoAssignmentRequested = false; private _activeQuotaWarning: { percentUsed: number; threshold: number } | undefined; - private _trajectoryTreatment: boolean | undefined; - private _trajectoryAssignmentRequested = false; constructor( @IChatEntitlementService private readonly _chatEntitlementService: IChatEntitlementService, @@ -106,7 +67,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo @ILanguageModelsService private readonly _languageModelsService: ILanguageModelsService, @IStorageService private readonly _storageService: IStorageService, @IWorkbenchAssignmentService private readonly _assignmentService: IWorkbenchAssignmentService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, @ILogService private readonly _logService: ILogService, ) { super(); @@ -115,7 +75,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo this._register(this._chatEntitlementService.onDidChangeQuotaExceeded(() => this._update())); this._register(this._chatEntitlementService.onDidChangeEntitlement(() => this._update())); this._register(this._languageModelsService.onDidChangeLanguageModels(() => this._refreshActiveQuotaApproachingWarning())); - this._register(CommandsRegistry.registerCommand(TRAJECTORY_NUDGE_SPEC.learnMoreCommandId, (accessor: ServicesAccessor) => this._handleCreditEfficiencyLearnMoreCommand(accessor))); // Re-evaluate when the selected model changes (e.g. switching between Copilot and BYOK). // The chatModelId context key is widget-scoped and may not bubble to the global @@ -159,39 +118,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo } } - /** - * Reads the already-evaluated trajectory experiment cohort. The assignment - * service resolves the cohort asynchronously, so this is requested only once - * the user has met every non-experiment condition required for the nudge. - * - * Stores the raw treatment value. `undefined` means the user is not - * assigned to the flight (or assignments are not available); only a `true` - * treatment renders the nudge. We deliberately do not coerce a missing - * assignment into a synthetic "control" value, since that would assume an - * enrollment that may not exist. Enrollment telemetry is emitted only when - * the user is actually assigned to a flight. - */ - private async _resolveTrajectoryTreatment(warning: { averageDailyUsage: number; percentUsed: number }): Promise { - const treatment = await this._assignmentService.getTreatment(TRAJECTORY_NUDGE_SPEC.treatmentName); - this._trajectoryTreatment = treatment; - if (treatment !== undefined) { - this._logQuotaTrajectoryNudgeEnrolled(treatment, warning); - } - if (treatment === true) { - this._update(); - } - } - - private _requestTrajectoryTreatment(warning: { averageDailyUsage: number; percentUsed: number }): void { - if (!this._trajectoryAssignmentRequested) { - this._trajectoryAssignmentRequested = true; - void this._resolveTrajectoryTreatment(warning).catch(error => { - this._logService.error(`Failed to resolve ${TRAJECTORY_NUDGE_SPEC.treatmentName}`, error); - this._trajectoryAssignmentRequested = false; - }); - } - } - private _getRelevantSnapshot(): IQuotaSnapshot | undefined { const quotas = this._chatEntitlementService.quotas; const entitlement = this._chatEntitlementService.entitlement; @@ -281,12 +207,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo // Priority 2: Quota approaching threshold if (isQuotaNotificationEligible) { - const trajectoryWarning = this._computeQuotaTrajectoryWarning(); - if (trajectoryWarning) { - this._showQuotaTrajectoryWarning(trajectoryWarning); - return; - } - const quotaWarning = this._computeQuotaWarning(); if (quotaWarning) { this._showQuotaApproachingWarning(quotaWarning); @@ -325,86 +245,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo return undefined; } - private _computeQuotaTrajectoryWarning(): { averageDailyUsage: number; percentUsed: number } | undefined { - if (this._isTrajectoryShownInCurrentPeriod()) { - return undefined; - } - - const snapshot = this._getRelevantSnapshot(); - if (!snapshot || snapshot.unlimited || snapshot.percentRemaining <= 0) { - return undefined; - } - - const resetDate = this._chatEntitlementService.quotas.resetDate; - if (!resetDate) { - return undefined; - } - - const reset = new Date(resetDate); - const resetTime = reset.getTime(); - if (!Number.isFinite(resetTime)) { - return undefined; - } - - const periodStart = new Date(resetTime); - periodStart.setUTCMonth(periodStart.getUTCMonth() - 1); - const periodStartTime = periodStart.getTime(); - const elapsedDays = (Date.now() - periodStartTime) / TRAJECTORY_NUDGE_SPEC.msPerDay; - if (elapsedDays < 0) { - return undefined; - } - - const percentUsed = 100 - snapshot.percentRemaining; - if (percentUsed < TRAJECTORY_NUDGE_SPEC.minimumPercentUsed || percentUsed > TRAJECTORY_NUDGE_SPEC.maximumPercentUsed) { - return undefined; - } - - const averageDailyUsage = percentUsed / Math.max(1, elapsedDays); - if (averageDailyUsage < TRAJECTORY_NUDGE_SPEC.averageDailyUsageThreshold) { - return undefined; - } - - this._requestTrajectoryTreatment({ averageDailyUsage, percentUsed }); - return this._trajectoryTreatment === true ? { averageDailyUsage, percentUsed } : undefined; - } - - private _showQuotaTrajectoryWarning(warning: { averageDailyUsage: number; percentUsed: number }): void { - this._showingExhausted = false; - this._storeTrajectoryShown(); - const learnMoreLink = createMarkdownCommandLink({ - text: localize('quota.trajectory.learnMoreStandalone', "Learn about optimizing usage"), - id: TRAJECTORY_NUDGE_SPEC.learnMoreCommandId, - tooltip: localize('quota.trajectory.learnMoreTooltip', "Learn about optimizing usage"), - }); - const message = localize({ key: 'quota.trajectory.message', comment: ['{Locked="["}', '{Locked="]({0})"}'] }, "You're likely to exhaust your AI credits before your billing period. {0}.", learnMoreLink); - - this._setNotification({ - id: QUOTA_NOTIFICATION_ID, - telemetryId: 'quotaTrajectoryNudge', - severity: ChatInputNotificationSeverity.Info, - message: new MarkdownString(message, { isTrusted: { enabledCommands: [TRAJECTORY_NUDGE_SPEC.learnMoreCommandId] } }), - description: undefined, - actions: [], - dismissible: true, - autoDismissOnMessage: false, - }); - } - - private async _handleCreditEfficiencyLearnMoreCommand(accessor: ServicesAccessor): Promise { - this._telemetryService.publicLog2<{}, ChatQuotaTrajectoryNudgeLinkClickedClassification>('chatQuotaTrajectoryNudgeLinkClicked'); - queueMicrotask(() => this._hideNotification()); - await accessor.get(IOpenerService).open(URI.parse(TRAJECTORY_NUDGE_SPEC.learnMoreUrl)); - } - - private _logQuotaTrajectoryNudgeEnrolled(treatment: boolean, warning: { averageDailyUsage: number; percentUsed: number }): void { - this._telemetryService.publicLog2('chatQuotaTrajectoryNudgeEnrolled', { - treatment, - entitlement: ChatEntitlement[this._chatEntitlementService.entitlement], - averageDailyUsage: Math.round(warning.averageDailyUsage * 100) / 100, - percentUsed: Math.round(warning.percentUsed * 100) / 100, - }); - } - /** * Returns the highest threshold that was newly crossed, or `undefined`. */ @@ -663,30 +503,6 @@ export class ChatQuotaNotificationContribution extends Disposable implements IWo ).value.format(resetDate); } - private _getTrajectoryPeriodKey(): string | undefined { - const resetDate = this._chatEntitlementService.quotas.resetDate; - if (!resetDate) { - return undefined; - } - const date = new Date(resetDate); - if (!Number.isFinite(date.getTime())) { - return undefined; - } - return `${date.getUTCFullYear()}-${date.getUTCMonth() + 1}`; - } - - private _isTrajectoryShownInCurrentPeriod(): boolean { - const periodKey = this._getTrajectoryPeriodKey(); - return !!periodKey && this._storageService.get(TRAJECTORY_NUDGE_SPEC.shownStorageKey, StorageScope.APPLICATION) === periodKey; - } - - private _storeTrajectoryShown(): void { - const periodKey = this._getTrajectoryPeriodKey(); - if (periodKey) { - this._storageService.store(TRAJECTORY_NUDGE_SPEC.shownStorageKey, periodKey, StorageScope.APPLICATION, StorageTarget.USER); - } - } - private _setNotification(notification: IChatInputNotification): void { this._chatInputNotificationService.setNotification(notification); } diff --git a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts index 75a4204617a254..4f50e7875935e3 100644 --- a/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts +++ b/src/vs/workbench/contrib/chat/test/browser/chatQuotaNotification.test.ts @@ -4,33 +4,25 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; -import * as sinon from 'sinon'; import { Emitter, Event } from '../../../../../base/common/event.js'; -import { createMarkdownCommandLink } from '../../../../../base/common/htmlContent.js'; import { IObservable, observableValue } from '../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; -import { CommandsRegistry } from '../../../../../platform/commands/common/commands.js'; import { IContextKeyService } from '../../../../../platform/contextkey/common/contextkey.js'; import { MockContextKeyService } from '../../../../../platform/keybinding/test/common/mockKeybindingService.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; import { InMemoryStorageService, StorageScope, StorageTarget } from '../../../../../platform/storage/common/storage.js'; -import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; -import { NullTelemetryServiceShape } from '../../../../../platform/telemetry/common/telemetryUtils.js'; import { IAssignmentFilter, IWorkbenchAssignmentService } from '../../../../services/assignment/common/assignmentService.js'; import { ChatEntitlement, IChatEntitlementService, IChatSentiment, IQuotaSnapshot, IRateLimitSnapshot } from '../../../../services/chat/common/chatEntitlementService.js'; import { ChatQuotaNotificationContribution } from '../../browser/chatQuotaNotification.js'; import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; import { ILanguageModelChatMetadata, ILanguageModelsService } from '../../common/languageModels.js'; -import { ChatInputNotificationActionKind, ChatInputNotificationSeverity, IChatInputNotification, IChatInputNotificationCommandAction, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; +import { ChatInputNotificationActionKind, IChatInputNotification, IChatInputNotificationCommandAction, IChatInputNotificationService } from '../../browser/widget/input/chatInputNotificationService.js'; -const CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID = 'workbench.action.chat.learnMoreAboutCreditUsage'; const SWITCH_TO_AUTO_TREATMENT_NAME = 'config.chatQuotaWarningSwitchToAuto'; -const TRAJECTORY_NUDGE_TREATMENT_NAME = 'config.chatQuotaTrajectoryNudge'; // --- Mock IChatEntitlementService ------------------------------------------- interface IMockQuotas { - resetDate?: string; usageBasedBilling?: boolean; chat?: IQuotaSnapshot; completions?: IQuotaSnapshot; @@ -58,7 +50,6 @@ function createMockEntitlementService(opts?: { onDidChangeQuotaRemaining: onDidChangeQuotaRemaining.event, onDidChangeUsageBasedBilling: Event.None, quotas: { - resetDate: opts?.quotas?.resetDate, usageBasedBilling: opts?.quotas?.usageBasedBilling ?? true, chat: opts?.quotas?.chat, completions: opts?.quotas?.completions, @@ -162,7 +153,6 @@ function getCommandAction(notification: IChatInputNotification): IChatInputNotif } function createMockAssignmentService( - trajectoryTreatment?: boolean | Promise, switchToAutoTreatment?: boolean | Promise, ) { const getTreatmentCalls: string[] = []; @@ -176,9 +166,6 @@ function createMockAssignmentService( if (name === SWITCH_TO_AUTO_TREATMENT_NAME) { return Promise.resolve(switchToAutoTreatment as T | undefined); } - if (name === TRAJECTORY_NUDGE_TREATMENT_NAME) { - return Promise.resolve(trajectoryTreatment as T | undefined); - } return Promise.resolve(undefined); }, }; @@ -186,16 +173,6 @@ function createMockAssignmentService( return { service, getTreatmentCalls }; } -class TestTelemetryService extends NullTelemetryServiceShape { - readonly events: { name: string; data: unknown }[] = []; - - override publicLog2(eventName?: string, data?: unknown): void { - if (eventName) { - this.events.push({ name: eventName, data }); - } - } -} - // --- Helpers --------------------------------------------------------------- function makeQuotaSnapshot(percentRemaining: number, opts?: Partial): IQuotaSnapshot { @@ -219,29 +196,20 @@ function makeRateLimitSnapshot(percentRemaining: number, opts?: Partial { const store = ensureNoDisposablesAreLeakedInTestSuite(); - teardown(() => { - sinon.restore(); - }); - function createContribution( entitlementOpts?: Parameters[0], - modelOpts?: { contextModelId?: string; vendor?: string; selectedModelId?: string; switchToAutoTreatment?: boolean | Promise; trajectoryTreatment?: boolean | Promise; telemetryService?: ITelemetryService }, + modelOpts?: { contextModelId?: string; vendor?: string; selectedModelId?: string; switchToAutoTreatment?: boolean | Promise }, sharedStorageService?: InMemoryStorageService, ) { const entitlementMock = createMockEntitlementService(entitlementOpts); const notificationMock = createMockNotificationService(); - const assignmentMock = createMockAssignmentService(modelOpts?.trajectoryTreatment, modelOpts?.switchToAutoTreatment); + const assignmentMock = createMockAssignmentService(modelOpts?.switchToAutoTreatment); const contextKeyService = store.add(new MockContextKeyService()); if (modelOpts?.contextModelId) { contextKeyService.createKey(ChatContextKeys.chatModelId.key, undefined).set(modelOpts.contextModelId); @@ -286,7 +254,6 @@ suite('ChatQuotaNotificationContribution', () => { languageModelsService, storageService, assignmentMock.service, - modelOpts?.telemetryService ?? new NullTelemetryServiceShape(), new NullLogService(), )); @@ -734,336 +701,6 @@ suite('ChatQuotaNotificationContribution', () => { }); }); - // --- Quota trajectory warning -------------------------------------------- - - suite('quota trajectory warning', () => { - let clock: sinon.SinonFakeTimers; - - setup(() => { - clock = sinon.useFakeTimers({ - now: new Date('2026-06-25T00:00:00Z'), - toFake: ['Date'], - }); - }); - - test('does not show when experiment treatment is disabled', async () => { - const { notificationMock } = createContribution({ - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }); - - await flushPromises(); - - assert.strictEqual(notificationMock.getNotification(), undefined); - }); - - test('does not show when user is eligible but not assigned to the experiment', async () => { - // No treatment configured: getTreatment resolves to undefined, i.e. - // the user is not in the flight. This must not be treated as control - // enrollment, but it should still attempt exposure since the user met - // every render condition. - const { assignmentMock, notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }); - - await flushPromises(); - - assert.deepStrictEqual({ - treatments: assignmentMock.getTreatmentCalls, - notification: notificationMock.getNotification(), - }, { - treatments: [TRAJECTORY_NUDGE_TREATMENT_NAME], - notification: undefined, - }); - }); - - test('does not show outside monthly usage window', async () => { - const results = []; - for (const percentRemaining of [91, 64]) { - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(percentRemaining), - }, - }, { trajectoryTreatment: true }); - - await flushPromises(); - - results.push(notificationMock.getNotification()?.message); - } - - assert.deepStrictEqual(results, [undefined, undefined]); - }); - - test('shows info notification when projected daily usage is above threshold', async () => { - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true }); - - await flushPromises(); - - const notification = notificationMock.getNotification(); - assert.ok(notification); - const message = notification.message; - const learnMoreLink = createMarkdownCommandLink({ - text: 'Learn about optimizing usage', - id: CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID, - tooltip: 'Learn about optimizing usage', - }); - assert.deepStrictEqual({ - message: typeof message === 'string' ? message : message.value, - severity: notification.severity, - actions: notification.actions.length, - autoDismissOnMessage: notification.autoDismissOnMessage, - }, { - message: `You're likely to exhaust your AI credits before your billing period. ${learnMoreLink}.`, - severity: ChatInputNotificationSeverity.Info, - actions: 0, - autoDismissOnMessage: false, - }); - }); - - test('does not show when projected daily usage is below threshold', async () => { - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(78), - }, - }, { trajectoryTreatment: true }); - - await flushPromises(); - - assert.strictEqual(notificationMock.getNotification(), undefined); - }); - - test('does not show when reset date implies no elapsed billing days', async () => { - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(31), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true }); - - await flushPromises(); - - assert.strictEqual(notificationMock.getNotification(), undefined); - }); - - test('counts the first billing day for 31-day and 28-day cycles', async () => { - const results = []; - for (const [now, resetDate] of [ - ['2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z'], - ['2026-02-01T00:00:00Z', '2026-03-01T00:00:00Z'], - ]) { - clock.setSystemTime(new Date(now)); - const telemetryService = new TestTelemetryService(); - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate, - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(88), - }, - }, { trajectoryTreatment: true, telemetryService }); - - await flushPromises(); - - results.push({ - events: telemetryService.events, - notificationShown: notificationMock.getNotification() !== undefined, - }); - } - - assert.deepStrictEqual(results, [ - { - events: [{ - name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { treatment: true, entitlement: 'Pro', averageDailyUsage: 12, percentUsed: 12 }, - }], - notificationShown: true, - }, - { - events: [{ - name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { treatment: true, entitlement: 'Pro', averageDailyUsage: 12, percentUsed: 12 }, - }], - notificationShown: true, - }, - ]); - }); - - test('shows trajectory nudge only after treatment resolves', async () => { - let resolveTreatment: ((value: boolean | undefined) => void) | undefined; - const trajectoryTreatment = new Promise(resolve => { - resolveTreatment = resolve; - }); - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment }); - - await flushPromises(); - assert.strictEqual(notificationMock.getNotification(), undefined); - - assert.ok(resolveTreatment); - resolveTreatment(true); - await flushPromises(); - - const notification = notificationMock.getNotification(); - assert.ok(notification); - const message = notification.message; - assert.ok(typeof message !== 'string' && message.value.includes('exhaust your AI credits')); - }); - - test('learn more command logs link-clicked telemetry', async () => { - const telemetryService = new TestTelemetryService(); - createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true, telemetryService }); - - await flushPromises(); - const command = CommandsRegistry.getCommand(CREDIT_EFFICIENCY_LEARN_MORE_COMMAND_ID); - assert.ok(command); - command.handler({ get: () => ({ open: async () => true }) } as never); - await flushPromises(); - - assert.deepStrictEqual(telemetryService.events, [ - { - name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { treatment: true, entitlement: 'Pro', averageDailyUsage: 4.67, percentUsed: 28 }, - }, - { - name: 'chatQuotaTrajectoryNudgeLinkClicked', - data: undefined, - }, - ]); - }); - - test('logs enrollment telemetry for control assignment without showing nudge', async () => { - const telemetryService = new TestTelemetryService(); - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: false, telemetryService }); - - await flushPromises(); - - assert.deepStrictEqual({ - events: telemetryService.events, - notification: notificationMock.getNotification(), - }, { - events: [{ - name: 'chatQuotaTrajectoryNudgeEnrolled', - data: { treatment: false, entitlement: 'Pro', averageDailyUsage: 4.67, percentUsed: 28 }, - }], - notification: undefined, - }); - }); - - test('does not log enrollment telemetry when not assigned to a flight', async () => { - const telemetryService = new TestTelemetryService(); - const { notificationMock } = createContribution({ - entitlement: ChatEntitlement.Pro, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { telemetryService }); // no treatment configured -> not assigned to the flight - - await flushPromises(); - - assert.deepStrictEqual({ - events: telemetryService.events, - notification: notificationMock.getNotification(), - }, { - events: [], - notification: undefined, - }); - }); - - test('remembers trajectory display for the quota period', async () => { - const { entitlementMock, notificationMock } = createContribution({ - entitlement: ChatEntitlement.ProPlus, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true }); - - await flushPromises(); - assert.ok(notificationMock.getNotification()); - - notificationMock.reset(); - entitlementMock.onDidChangeQuotaRemaining.fire(); - - assert.strictEqual(notificationMock.getNotification(), undefined); - }); - - test('does not enforce SKU eligibility outside experiment assignment', async () => { - const results: Record = {}; - for (const entitlement of [ChatEntitlement.Pro, ChatEntitlement.ProPlus, ChatEntitlement.Max, ChatEntitlement.EDU, ChatEntitlement.Business, ChatEntitlement.Enterprise, ChatEntitlement.Free, ChatEntitlement.Unknown]) { - const { notificationMock } = createContribution({ - entitlement, - quotas: { - resetDate: makeResetDate(24), - usageBasedBilling: true, - premiumChat: makeQuotaSnapshot(72), - chat: makeQuotaSnapshot(72), - }, - }, { trajectoryTreatment: true }); - - await flushPromises(); - - results[ChatEntitlement[entitlement]] = !!notificationMock.getNotification(); - } - - assert.deepStrictEqual(results, { - Pro: true, - ProPlus: true, - Max: true, - EDU: true, - Business: true, - Enterprise: true, - Free: true, - Unknown: true, - }); - }); - }); - // --- Rate-limit warnings ------------------------------------------------ suite('rate-limit warnings', () => { @@ -1244,7 +881,6 @@ suite('ChatQuotaNotificationContribution', () => { languageModelsService, storageService, assignmentMock.service, - new NullTelemetryServiceShape(), new NullLogService(), )); From f30670f870c7966f56bc3f6f2cee2ee8421709e8 Mon Sep 17 00:00:00 2001 From: Osvaldo Ortega <48293249+osortega@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:58:30 -0700 Subject: [PATCH 26/28] Add Sandbox checkbox for Cloud Sessions (#330576) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Agent Host changes for osortega/agents/show-new-worktree-checkbox * sessions: add Sandbox option for new cloud sessions Adds a "Sandbox" checkbox beside the cloud session config, shown when `chat.agentHost.cloudSandbox.enabled` is on. When checked, sending the first message provisions a GitHub-managed sandbox and drives it over the Agent Host Protocol relay instead of using the server-run cloud agent. Creation is the same `POST /agents/tasks` as a regular cloud task plus `environment_id: "github-sandbox"` — a sentinel that asks Mission Control to provision a fresh VM. The concrete environment comes back on the created session and is the only id the relay can address. From the seed onward, provisioning reuses the discovery path (`_ensureProvider` -> `seedSessions` -> `connect`), so a created task is just a discovered one we happen to know about first and a later discovery pass reconciles with it instead of duplicating it. Mission Control starts no run for environment-bound tasks, so the first turn is dispatched by the client. Also extracts the checkbox chip that was inline in BranchPicker into a shared CheckboxChip, now used by both the worktree and sandbox chips. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: derive sandbox repo NWO without the workspace ref A browsed GitHub workspace root carries a ref (`///HEAD`, see `_browseForRepo`), so stripping only the leading slash produced `owner/repo/HEAD`. `parseNwo` then split on the first separator and sent `{owner, name: "repo/HEAD"}` to Mission Control — an invalid repository. Reuse the existing `githubRemoteRepoLabel`, which already takes the first two path segments for exactly this reason, instead of a second derivation that got it wrong. The tests now use realistic `/HEAD` workspace roots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: page sandbox discovery and drop dead baseRef Discovery read a single page of 100 tasks but reported the scan as `complete`, which is what authorizes negative reconciliation. On an account with more tasks than that, sandbox sessions outside the window looked deleted and were torn down. Page through the task list up to a bounded ceiling, and report `partial` whenever the scan was cut short — by the ceiling, a failed later page, or cancellation — so callers keep existing sessions instead of reconciling against a list that was never authoritative. Also remove `baseRef`, which no production caller set, and flag that `isCloudSandboxTask` requires both the agent slug and the compute provider: the expected slug migration would silently return zero sessions, so that must be resolved before the setting is enabled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: trim cloud sandbox comments The comments had grown into design notes, recording why decisions were made and how the subsystem fits together rather than what the code does. Cut them back to what is not visible from the code itself, keeping the notes that describe real traps: that a dormant environment reads `offline` whether or not it can wake, and that registering a second content provider for one session type throws. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * sessions: detect further task pages from the Link header Discovery inferred the end of the task list from a short page, but Mission Control can return fewer rows than requested and still advertise `rel="next"` — page 3 of a real account returns 99 of 100 with a next link. That page count also made an exact multiple of the page size look truncated, pinning the result to `partial` and permanently suppressing reconciliation. Read the `Link` header instead, which states outright whether another page exists. Matches how dotcom's agent-sessions client paginates the same endpoint. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/common/cloudSandboxAgentHost.ts | 51 ++++- .../contrib/chat/browser/branchPicker.ts | 76 ++----- .../contrib/chat/browser/checkboxChip.ts | 131 ++++++++++++ .../chat/browser/media/branchPicker.css | 22 -- .../chat/browser/media/checkboxChip.css | 26 +++ .../browser/copilotChatSessionsActions.ts | 30 ++- .../browser/copilotChatSessionsProvider.ts | 107 ++++++++++ .../browser/sandboxPicker.ts | 101 +++++++++ .../copilotChatSessionsProvider.test.ts | 134 +++++++++++- .../test/browser/sandboxPicker.test.ts | 174 +++++++++++++++ .../cloudSandboxAgentHostContribution.ts | 125 ++++++++--- .../browser/cloudSandboxApiService.ts | 141 +++++++++++-- .../cloudSandboxReadOnlySessionHandler.ts | 11 +- .../browser/cloudSandboxTelemetry.ts | 2 +- .../browser/remoteAgentHost.contribution.ts | 2 +- .../cloudSandboxAgentHostContribution.test.ts | 180 +++++++++++++++- .../browser/cloudSandboxApiService.test.ts | 199 +++++++++++++++++- 17 files changed, 1347 insertions(+), 165 deletions(-) create mode 100644 src/vs/sessions/contrib/chat/browser/checkboxChip.ts create mode 100644 src/vs/sessions/contrib/chat/browser/media/checkboxChip.css create mode 100644 src/vs/sessions/contrib/providers/copilotChatSessions/browser/sandboxPicker.ts create mode 100644 src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts diff --git a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts index b2e8d07e5d5b2f..833bd604044aeb 100644 --- a/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts +++ b/src/vs/platform/agentHost/common/cloudSandboxAgentHost.ts @@ -11,12 +11,23 @@ // to reach an agent host over one transport; it does not define a new kind of agent host. import { CancellationToken } from '../../../base/common/cancellation.js'; +import { IConfigurationService } from '../../configuration/common/configuration.js'; import { createDecorator } from '../../instantiation/common/instantiation.js'; +import { RemoteAgentHostsEnabledSettingId } from './remoteAgentHostService.js'; import { IReplayedTaskHistory } from './taskEventReplay.js'; /** Configuration key gating the cloud-sandbox connection path. Disabled by default. */ export const CloudSandboxEnabledSettingId = 'chat.agentHost.cloudSandbox.enabled'; +/** + * Whether cloud sandbox sessions can be created or connected to. A sandbox is reached over the + * remote-agent-host relay, so it needs that setting too. + */ +export function isCloudSandboxEnabled(configurationService: IConfigurationService): boolean { + return configurationService.getValue(CloudSandboxEnabledSettingId) === true + && configurationService.getValue(RemoteAgentHostsEnabledSettingId) === true; +} + /** Prefix for the synthesized display address of a cloud sandbox connection. */ export const CLOUD_SANDBOX_ADDRESS_PREFIX = 'cloudsandbox:'; @@ -54,9 +65,37 @@ export function cloudSandboxEnvironmentId(address: string): string | undefined { * not overlap. Sandbox tasks are expected to move under one of those slugs eventually, at which * point both providers would list the same task and the sessions list would show it twice — the * setting keeps that from reaching everyone before the overlap is resolved. + * + * That migration also breaks discovery, which requires this slug *and* the `sandboxes` compute + * provider to recognize a task. Both must be resolved before the setting is enabled by default. */ export const CLOUD_SANDBOX_AGENT_SLUG = 'copilot-developer-cli'; +/** + * Sentinel environment id asking Mission Control to provision a fresh sandbox VM. Never a real + * environment: the concrete id comes back on the created session and everything must address that + * one — see {@link ICloudSandboxCreatedSession.environmentId}. + */ +export const CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID = 'github-sandbox'; + +/** What to provision a sandbox session for. */ +export interface ICloudSandboxCreateSessionRequest { + /** Repository to bind the sandbox to, as `owner/name`. Omitted for a repo-less sandbox. */ + readonly repoNwo?: string; + /** First user turn. Mission Control starts no run, so the client sends it over the relay. */ + readonly prompt: string; +} + +/** A freshly provisioned sandbox task/session pair, bound to a concrete environment. */ +export interface ICloudSandboxCreatedSession { + /** Mission Control task id owning the session; the key its persisted AHP history is under. */ + readonly taskId: string; + /** Session id, issued as `ahp-session:/` and listed back by the host under that id. */ + readonly sessionId: string; + /** The sandbox VM Mission Control bound, never {@link CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID}. */ + readonly environmentId: string; +} + /** A sandbox session discovered from the Copilot task list, enough to seed a session entry. */ export interface ICloudSandboxDiscoveredSession { /** Mission Control environment id the session's sandbox is bound to. */ @@ -219,10 +258,14 @@ export interface ICloudSandboxApiService { listSessions(token: CancellationToken): Promise; /** - * Read a task's persisted AHP history and fold it back into session and chat state. - * - * Mission Control mirrors every `ActionEnvelope` it relays, so this rebuilds the conversation - * **without the sandbox**. `undefined` when the task has no AHP history. + * Provision a new sandbox task and its bound session. Mission Control starts no run, so the + * caller sends {@link ICloudSandboxCreateSessionRequest.prompt} over the relay itself. + */ + createSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken): Promise; + + /** + * Read a task's persisted AHP history and fold it back into session and chat state. Served by + * Mission Control's mirror, so it works without the sandbox. `undefined` when there is none. */ getSessionHistory(taskId: string, token: CancellationToken): Promise; } diff --git a/src/vs/sessions/contrib/chat/browser/branchPicker.ts b/src/vs/sessions/contrib/chat/browser/branchPicker.ts index bb134ed968b81b..8d81aa2649069b 100644 --- a/src/vs/sessions/contrib/chat/browser/branchPicker.ts +++ b/src/vs/sessions/contrib/chat/browser/branchPicker.ts @@ -5,14 +5,13 @@ import * as dom from '../../../../base/browser/dom.js'; import { renderIcon } from '../../../../base/browser/ui/iconLabel/iconLabels.js'; -import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; import { Codicon } from '../../../../base/common/codicons.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; import { localize } from '../../../../nls.js'; import { IActionWidgetService } from '../../../../platform/actionWidget/browser/actionWidget.js'; import { ActionListItemKind, IActionListDelegate, IActionListItem } from '../../../../platform/actionWidget/browser/actionList.js'; -import { defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import { CheckboxChip } from './checkboxChip.js'; import './media/branchPicker.css'; const FILTER_THRESHOLD = 10; @@ -91,9 +90,7 @@ export class BranchPicker extends Disposable { private _triggerElement: HTMLElement | undefined; private _descriptionElement: HTMLElement | undefined; private _isOpen = false; - private _isolationSlot: HTMLElement | undefined; - private _isolationRow: HTMLElement | undefined; - private _isolationCheckbox: Checkbox | undefined; + private _isolationChip: CheckboxChip | undefined; private _isolationState: IBranchPickerIsolationState | undefined; constructor( @@ -114,68 +111,21 @@ export class BranchPicker extends Disposable { return; } - const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-isolation-checkbox')); - if (isolation.slotClassName) { - slot.classList.add(isolation.slotClassName); - } - this._isolationSlot = slot; - this._renderDisposables.add(toDisposable(() => slot.remove())); - if (isolation.markTarget) { - this._renderDisposables.add(isolation.markTarget(slot)); - } - - const row = dom.append(slot, dom.$('.action-label')); - row.setAttribute('aria-label', isolation.ariaLabel); - this._isolationRow = row; - - const checkbox = this._renderDisposables.add(new Checkbox(isolation.label, this._isolationState?.checked ?? false, { ...defaultCheckboxStyles, size: 14 })); - this._isolationCheckbox = checkbox; - dom.append(row, checkbox.domNode); - const labelSpan = dom.append(row, dom.$('span.sessions-chat-dropdown-label')); - labelSpan.textContent = isolation.label; - - this._renderDisposables.add(checkbox.onChange(() => isolation.onToggle(checkbox.checked))); - this._renderDisposables.add(Gesture.addTarget(row)); - for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { - this._renderDisposables.add(dom.addDisposableListener(row, eventType, e => { - if (!checkbox.enabled) { - return; - } - dom.EventHelper.stop(e, true); - checkbox.checked = !checkbox.checked; - isolation.onToggle(checkbox.checked); - })); - } - + const chip = this._renderDisposables.add(new CheckboxChip({ + label: isolation.label, + ariaLabel: isolation.ariaLabel, + onToggle: isolation.onToggle, + // Kept alongside the shared chip class so existing styling and selectors still match. + slotClassName: 'sessions-chat-isolation-checkbox', + markTarget: isolation.markTarget, + })); + this._isolationChip = chip; + chip.render(container); this._updateIsolation(); } private _updateIsolation(): void { - if (!this._options.isolation || !this._isolationCheckbox || !this._isolationSlot) { - return; - } - - const state = this._isolationState; - const mode = state?.state ?? 'disabled'; - this._isolationCheckbox.checked = state?.checked ?? false; - if (mode === 'enabled') { - this._isolationCheckbox.enable(); - } else { - this._isolationCheckbox.disable(); - // Keep focusable so keyboard users can discover the disabled reason via tooltip - this._isolationCheckbox.domNode.tabIndex = 0; - } - this._isolationSlot.classList.toggle('disabled', mode === 'disabled'); - this._isolationSlot.classList.toggle('hidden', mode === 'hidden'); - - const reason = state?.disabledReason; - if (this._isolationRow) { - if (mode === 'disabled' && reason) { - this._isolationRow.title = reason; - } else { - this._isolationRow.removeAttribute('title'); - } - } + this._isolationChip?.update(this._isolationState ?? { checked: false, state: 'disabled' }); } render(container: HTMLElement): void { diff --git a/src/vs/sessions/contrib/chat/browser/checkboxChip.ts b/src/vs/sessions/contrib/chat/browser/checkboxChip.ts new file mode 100644 index 00000000000000..265142267fc701 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/checkboxChip.ts @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from '../../../../base/browser/dom.js'; +import { Checkbox } from '../../../../base/browser/ui/toggle/toggle.js'; +import { Gesture, EventType as TouchEventType } from '../../../../base/browser/touch.js'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../base/common/lifecycle.js'; +import { defaultCheckboxStyles } from '../../../../platform/theme/browser/defaultStyles.js'; +import './media/checkboxChip.css'; + +/** Static configuration for a {@link CheckboxChip}. */ +export interface ICheckboxChipOptions { + readonly label: string; + readonly ariaLabel: string; + readonly onToggle: (checked: boolean) => void; + /** Extra class on the slot, for call-site-specific styling or test selectors. */ + readonly slotClassName?: string; + readonly markTarget?: (element: HTMLElement) => IDisposable; +} + +/** Per-update state for a {@link CheckboxChip}. */ +export interface ICheckboxChipState { + readonly checked: boolean; + readonly state: 'enabled' | 'disabled' | 'hidden'; + /** Shown as a tooltip while `disabled`, explaining why the choice is unavailable. */ + readonly disabledReason?: string; +} + +/** + * A checkbox rendered as a chip in the new-session chip lane, matching the dropdown chips beside + * it. For binary session choices that would otherwise be a two-item dropdown. + * + * The whole row is clickable, and a disabled chip stays focusable so keyboard users can reach + * {@link ICheckboxChipState.disabledReason}. + */ +export class CheckboxChip extends Disposable { + + private readonly _renderDisposables = this._register(new DisposableStore()); + private _slot: HTMLElement | undefined; + private _row: HTMLElement | undefined; + private _checkbox: Checkbox | undefined; + private _state: ICheckboxChipState = { checked: false, state: 'disabled' }; + + constructor(private readonly _options: ICheckboxChipOptions) { + super(); + } + + /** The rendered slot, or `undefined` before the first {@link render}. */ + get element(): HTMLElement | undefined { + return this._slot; + } + + render(container: HTMLElement): HTMLElement { + this._renderDisposables.clear(); + + const slot = dom.append(container, dom.$('.sessions-chat-picker-slot.sessions-chat-checkbox-chip')); + if (this._options.slotClassName) { + slot.classList.add(this._options.slotClassName); + } + this._slot = slot; + this._renderDisposables.add(toDisposable(() => { + slot.remove(); + if (this._slot === slot) { + this._slot = undefined; + } + })); + if (this._options.markTarget) { + this._renderDisposables.add(this._options.markTarget(slot)); + } + + const row = dom.append(slot, dom.$('.action-label')); + row.setAttribute('aria-label', this._options.ariaLabel); + this._row = row; + + const checkbox = this._renderDisposables.add(new Checkbox(this._options.label, this._state.checked, { ...defaultCheckboxStyles, size: 14 })); + this._checkbox = checkbox; + dom.append(row, checkbox.domNode); + const label = dom.append(row, dom.$('span.sessions-chat-dropdown-label')); + label.textContent = this._options.label; + + this._renderDisposables.add(checkbox.onChange(() => this._options.onToggle(checkbox.checked))); + this._renderDisposables.add(Gesture.addTarget(row)); + for (const eventType of [dom.EventType.CLICK, TouchEventType.Tap]) { + this._renderDisposables.add(dom.addDisposableListener(row, eventType, e => { + if (!checkbox.enabled) { + return; + } + dom.EventHelper.stop(e, true); + checkbox.checked = !checkbox.checked; + this._options.onToggle(checkbox.checked); + })); + } + + this._apply(); + return slot; + } + + update(state: ICheckboxChipState): void { + this._state = state; + this._apply(); + } + + private _apply(): void { + const checkbox = this._checkbox; + const slot = this._slot; + if (!checkbox || !slot) { + return; + } + const { checked, state, disabledReason } = this._state; + checkbox.checked = checked; + if (state === 'enabled') { + checkbox.enable(); + } else { + checkbox.disable(); + // Keep focusable so keyboard users can discover the disabled reason via its tooltip. + checkbox.domNode.tabIndex = 0; + } + slot.classList.toggle('disabled', state === 'disabled'); + slot.classList.toggle('hidden', state === 'hidden'); + + if (this._row) { + if (state === 'disabled' && disabledReason) { + this._row.title = disabledReason; + } else { + this._row.removeAttribute('title'); + } + } + } +} diff --git a/src/vs/sessions/contrib/chat/browser/media/branchPicker.css b/src/vs/sessions/contrib/chat/browser/media/branchPicker.css index d2356f40e1e011..9bc914fd857730 100644 --- a/src/vs/sessions/contrib/chat/browser/media/branchPicker.css +++ b/src/vs/sessions/contrib/chat/browser/media/branchPicker.css @@ -21,25 +21,3 @@ min-width: 0; user-select: none; } - -.sessions-chat-picker-slot.sessions-chat-isolation-checkbox .action-label { - cursor: pointer; -} - -.sessions-chat-picker-slot.sessions-chat-isolation-checkbox .monaco-checkbox { - margin-right: 6px; -} - -.sessions-chat-picker-slot.sessions-chat-isolation-checkbox .sessions-chat-dropdown-label { - margin-left: 0; - cursor: pointer; -} - -.sessions-chat-picker-slot.sessions-chat-isolation-checkbox.disabled .action-label, -.sessions-chat-picker-slot.sessions-chat-isolation-checkbox.disabled .sessions-chat-dropdown-label { - cursor: default; -} - -.sessions-chat-picker-slot.sessions-chat-isolation-checkbox.hidden { - display: none; -} diff --git a/src/vs/sessions/contrib/chat/browser/media/checkboxChip.css b/src/vs/sessions/contrib/chat/browser/media/checkboxChip.css new file mode 100644 index 00000000000000..b49f36d7c72209 --- /dev/null +++ b/src/vs/sessions/contrib/chat/browser/media/checkboxChip.css @@ -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. + *--------------------------------------------------------------------------------------------*/ + +.sessions-chat-picker-slot.sessions-chat-checkbox-chip .action-label { + cursor: pointer; +} + +.sessions-chat-picker-slot.sessions-chat-checkbox-chip .monaco-checkbox { + margin-right: var(--vscode-spacing-size60); +} + +.sessions-chat-picker-slot.sessions-chat-checkbox-chip .sessions-chat-dropdown-label { + margin-left: 0; + cursor: pointer; +} + +.sessions-chat-picker-slot.sessions-chat-checkbox-chip.disabled .action-label, +.sessions-chat-picker-slot.sessions-chat-checkbox-chip.disabled .sessions-chat-dropdown-label { + cursor: default; +} + +.sessions-chat-picker-slot.sessions-chat-checkbox-chip.hidden { + display: none; +} diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts index 964fb6f84b18dc..f5f35867d6d6d1 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsActions.ts @@ -18,15 +18,18 @@ import { SessionHasGitRepositoryContext, SessionProviderIdContext, SessionTypeCo import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { BranchPicker } from './branchPicker.js'; -import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider } from './copilotChatSessionsProvider.js'; +import { COPILOT_PROVIDER_ID, CopilotChatSessionsProvider, CopilotCloudSessionType } from './copilotChatSessionsProvider.js'; import { ModePicker, ModePickerModel } from './modePicker.js'; import { CopilotPermissionPickerDelegate, PermissionPicker } from './permissionPicker.js'; +import { SandboxPicker } from './sandboxPicker.js'; +import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSessionsProvider.js'; import { ISessionContext } from '../../../../services/sessions/browser/sessionContext.js'; const IsActiveSessionCopilotCLI = ContextKeyExpr.equals(SessionTypeContext.key, CopilotCLISessionType.id); const IsActiveCopilotChatSessionProvider = ContextKeyExpr.equals(SessionProviderIdContext.key, COPILOT_PROVIDER_ID); const IsActiveSessionCopilotChatCLI = ContextKeyExpr.and(IsActiveSessionCopilotCLI, IsActiveCopilotChatSessionProvider); +const IsActiveSessionCopilotChatCloud = ContextKeyExpr.and(ContextKeyExpr.equals(SessionTypeContext.key, CopilotCloudSessionType.id), IsActiveCopilotChatSessionProvider); // -- Actions -- @@ -47,6 +50,23 @@ registerAction2(class extends Action2 { override async run(): Promise { /* handled by action view item */ } }); +registerAction2(class extends Action2 { + constructor() { + super({ + id: 'sessions.defaultCopilot.sandboxPicker', + title: localize2('sandboxPicker', "Sandbox"), + f1: false, + menu: [{ + id: Menus.NewSessionRepositoryConfig, + group: 'navigation', + order: 3, + when: ContextKeyExpr.and(IsNewChatSessionContext, IsActiveSessionCopilotChatCloud, ChatContextKeys.enabled), + }], + }); + } + override async run(): Promise { /* handled by action view item */ } +}); + registerAction2(class extends Action2 { constructor() { super({ @@ -144,6 +164,14 @@ class CopilotPickerActionViewItemContribution extends Disposable implements IWor return new PickerActionViewItem(picker); }, )); + this._register(actionViewItemService.register( + Menus.NewSessionRepositoryConfig, 'sessions.defaultCopilot.sandboxPicker', + (_action, _options, scopedInstantiationService) => { + const { session } = scopedInstantiationService.invokeFunction(accessor => accessor.get(ISessionContext)); + const picker = scopedInstantiationService.createInstance(SandboxPicker, session); + return new PickerActionViewItem(picker); + }, + )); this._register(actionViewItemService.register( Menus.NewSessionConfig, 'sessions.defaultCopilot.modePicker', (_action, _options, scopedInstantiationService) => { diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts index 0a71289f70aef4..796f58e39ab302 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/copilotChatSessionsProvider.ts @@ -53,6 +53,9 @@ import { CopilotCLISessionType } from '../../agentHost/browser/baseAgentHostSess import { createChangesets } from './copilotChatSessionsChangesets.js'; import { IUriIdentityService } from '../../../../../platform/uriIdentity/common/uriIdentity.js'; import { IAgentHostEnablementService } from '../../../../../platform/agentHost/common/agentHostEnablementService.js'; +import { isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { getWorkbenchContribution } from '../../../../../workbench/common/contributions.js'; +import { CloudSandboxAgentHostContribution } from '../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; /** Copilot Cloud session type - cloud-hosted agent. */ export const CopilotCloudSessionType: ISessionType = { @@ -64,6 +67,9 @@ export const CopilotCloudSessionType: ISessionType = { const STORAGE_KEY_ISOLATION_MODE = 'sessions.isolationPicker.selectedMode'; +/** Remembers the cloud sandbox choice across new sessions, like the isolation picker above. */ +const STORAGE_KEY_USE_SANDBOX = 'sessions.cloudSandboxPicker.useSandbox'; + export type IsolationMode = 'worktree' | 'workspace'; export interface ICopilotChatSession { @@ -123,6 +129,14 @@ export interface ICopilotChatSession { readonly isolationMode: IObservable; setIsolationMode(mode: IsolationMode): void; + /** + * For cloud sessions: whether the session should run in a GitHub-managed sandbox the client + * drives over the Agent Host Protocol, instead of the server-run cloud agent. Always + * `undefined` for session kinds that have no such choice. + */ + readonly useSandbox: IObservable; + setUseSandbox(useSandbox: boolean): void; + setModelId(modelId: string | undefined, source: ChatModelSource): void; setMode(chatMode: IChatMode | undefined): void; setOption?(optionId: string, value: IChatSessionProviderOptionItem | string): void; @@ -238,6 +252,9 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { private readonly _isolationModeObservable = observableValue(this, 'worktree'); readonly isolationMode: IObservable = this._isolationModeObservable; + /** A local CLI session always runs locally, so it has no cloud sandbox choice to make. */ + readonly useSandbox: IObservable = constObservable(undefined); + private readonly _modelIdObservable = observableValue(this, undefined); readonly modelId: IObservable = this._modelIdObservable; protected readonly _modelSourceObservable = observableValue(this, undefined); @@ -462,6 +479,10 @@ class CopilotCLISession extends Disposable implements ICopilotChatSession { } } + setUseSandbox(_useSandbox: boolean): void { + // A local CLI session has no cloud sandbox choice to make. + } + setModelId(modelId: string | undefined, source: ChatModelSource): void { this._modelId = modelId; // One update: a model and where it came from are only meaningful as a pair, and an @@ -609,6 +630,8 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession readonly gitHubInfo: IObservable = constObservable(undefined); readonly branch: IObservable = constObservable(undefined); readonly isolationMode: IObservable = constObservable(undefined); + private readonly _useSandbox = observableValue(this, false); + readonly useSandbox: IObservable = this._useSandbox; readonly branches: IObservable = constObservable([]); readonly gitRepository?: IGitRepository | undefined; @@ -632,6 +655,16 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession get project(): ISessionWorkspace | undefined { return this._project; } get selectedModelId(): string | undefined { return this._modelId; } + + /** + * The repository this session targets, as `owner/repo`. A GitHub workspace root carries a ref + * (`///HEAD`, see {@link CopilotChatSessionsProvider._browseForRepo}), so this + * takes only the first two path segments rather than the whole path. + */ + get repoNwo(): string | undefined { + return this._repoUri ? githubRemoteRepoLabel(this._repoUri) : undefined; + } + get chatMode(): IChatMode | undefined { return undefined; } get query(): string | undefined { return this._query; } get attachedContext(): IChatRequestVariableEntry[] | undefined { return this._attachedContext; } @@ -648,6 +681,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession providerId: string, @IChatSessionsService private readonly chatSessionsService: IChatSessionsService, @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IStorageService private readonly storageService: IStorageService, ) { super(); this.sessionId = toSessionId(providerId, resource); @@ -655,6 +689,7 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession this.sessionType = target; this.icon = CopilotCloudSessionType.icon; this.createdAt = new Date(); + this._useSandbox.set(storageService.getBoolean(STORAGE_KEY_USE_SANDBOX, StorageScope.PROFILE, false), undefined); this._updateWhenClauseKeys(); this._register(this.chatSessionsService.onDidChangeOptionGroups(() => { @@ -687,6 +722,14 @@ export class RemoteNewSession extends Disposable implements ICopilotChatSession // No-op for remote sessions } + setUseSandbox(useSandbox: boolean): void { + if (this._useSandbox.get() === useSandbox) { + return; + } + this._useSandbox.set(useSandbox, undefined); + this.storageService.store(STORAGE_KEY_USE_SANDBOX, useSandbox, StorageScope.PROFILE, StorageTarget.MACHINE); + } + setBranch(_branch: string | undefined): void { // No-op for remote sessions } @@ -904,6 +947,8 @@ class AgentSessionAdapter implements ICopilotChatSession { readonly permissionLevel: IObservable = constObservable(ChatPermissionLevel.Default); readonly branch: IObservable = constObservable(undefined); readonly isolationMode: IObservable = constObservable(undefined); + /** Where a committed session runs is already decided; the choice only exists before the first send. */ + readonly useSandbox: IObservable = constObservable(undefined); readonly gitRepository?: IGitRepository | undefined; readonly branches: IObservable = constObservable([]); @@ -1013,6 +1058,9 @@ class AgentSessionAdapter implements ICopilotChatSession { setIsolationMode(mode: IsolationMode): void { throw new Error('Method not implemented.'); } + setUseSandbox(useSandbox: boolean): void { + // Where a committed session runs is already decided. + } setModelId(modelId: string | undefined, source: ChatModelSource): void { transaction(tx => { this._modelSource.set(modelId ? source : undefined, tx); @@ -2032,12 +2080,71 @@ export class CopilotChatSessionsProvider extends Disposable implements ISessions return this._toChat(session); } + /** Test seam: the contribution registry is global, so tests override this with a stub. */ + protected _getCloudSandboxContribution(): Pick { + return getWorkbenchContribution(CloudSandboxAgentHostContribution.ID); + } + + /** + * Commit a cloud new-session into a GitHub-managed sandbox instead of the server-run cloud + * agent: provision the sandbox, then hand the session over to the remote-agent-host provider + * that owns it and send the first turn there. + * + * The committed session belongs to that other provider, which is why this fires + * `onDidReplaceSession` across providers — the same swap {@link _sendFirstChat} performs, just + * landing outside this provider. Mission Control starts no run for the task it creates, so the + * first turn has to be dispatched here rather than being picked up server-side. + */ + private async _sendFirstChatToSandbox(session: RemoteNewSession, repoNwo: string, options: ISendRequestOptions): Promise { + session.setTitle((options.title || options.query.split('\n')[0]).substring(0, 100) || localize('new session', "New Session")); + session.setStatus(SessionStatus.InProgress); + this._sessionCache.set(session.resource.toString(), session); + this._invalidateGroupingCaches(); + const placeholder = this._chatToSession(session); + this._onDidChangeSessions.fire({ added: [placeholder], removed: [], changed: [] }); + + try { + const provisioned = await this._getCloudSandboxContribution().provisionSession({ + repoNwo, + // No `baseRef`: cloud sessions have no branch picker, so Mission Control picks the + // repository's default branch — the same branch the server-run cloud agent uses. + prompt: options.query, + }, CancellationToken.None); + + // Send into the session's main chat rather than `createNewChat`, which would mint an + // *additional* peer chat inside a session that already has one. + const chat = provisioned.session.mainChat.get(); + const committed = await provisioned.provider.sendRequest(provisioned.session.sessionId, chat.resource, options); + + this._sessionCache.delete(session.resource.toString()); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(session.sessionId); + this._clearCurrentNewSessionIfMatch(session); + this._onDidReplaceSession.fire({ from: placeholder, to: committed }); + return committed; + } catch (error) { + this.logService.error(`[CopilotChatSessionsProvider] Failed to start cloud sandbox session for ${repoNwo}:`, error); + this._sessionCache.delete(session.resource.toString()); + this._invalidateGroupingCaches(); + this._sessionGroupCache.delete(session.sessionId); + this._clearCurrentNewSessionIfMatch(session, /* leak */ true); + this._onDidChangeSessions.fire({ added: [], removed: [placeholder], changed: [] }); + session.dispose(); + throw error; + } + } + async sendRequest(sessionId: string, chatResource: URI, options: ISendRequestOptions): Promise { const newSession = this._newSessions.get(sessionId); if (newSession) { if (!this.uriIdentityService.extUri.isEqual(newSession.mainChat.get().resource, chatResource)) { throw new Error('Chat resource does not match the main chat of the current new session'); } + // `useSandbox` is persisted, so it can outlive the setting being turned off. Re-check + // rather than trust it: falling back to the cloud agent beats a send that must fail. + if (newSession instanceof RemoteNewSession && newSession.useSandbox.get() && newSession.repoNwo && isCloudSandboxEnabled(this.configurationService)) { + return this._sendFirstChatToSandbox(newSession, newSession.repoNwo, options); + } return this._sendFirstChat(newSession, chatResource, options); } diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/browser/sandboxPicker.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/sandboxPicker.ts new file mode 100644 index 00000000000000..6ca0c2956211f7 --- /dev/null +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/browser/sandboxPicker.ts @@ -0,0 +1,101 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from '../../../../../base/common/lifecycle.js'; +import { autorun, IObservable } from '../../../../../base/common/observable.js'; +import { localize } from '../../../../../nls.js'; +import { CloudSandboxEnabledSettingId, isCloudSandboxEnabled } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { RemoteAgentHostsEnabledSettingId } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; +import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; +import { CheckboxChip } from '../../../chat/browser/checkboxChip.js'; +import { reportNewChatPickerClosed } from '../../../chat/browser/newChatPickerTelemetry.js'; +import { IActiveSession } from '../../../../services/sessions/common/sessionsManagement.js'; +import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider, ICopilotChatSession, RemoteNewSession } from './copilotChatSessionsProvider.js'; + +/** + * "Sandbox" checkbox for a new cloud session: when checked, the session runs in a GitHub-managed + * sandbox this client drives over the Agent Host Protocol, instead of the server-run cloud agent. + * + * Disabled rather than hidden without a repository, since a sandbox is always provisioned + * against one. + */ +export class SandboxPicker extends Disposable { + + private readonly _chip: CheckboxChip; + private _hasRepository = false; + + constructor( + private readonly _session: IObservable, + @IConfigurationService private readonly _configurationService: IConfigurationService, + @ISessionsProvidersService private readonly _sessionsProvidersService: ISessionsProvidersService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, + ) { + super(); + + this._chip = this._register(new CheckboxChip({ + label: localize('sandboxPicker.label', "Sandbox"), + ariaLabel: localize('sandboxPicker.checkboxAriaLabel', "Run in a GitHub-managed sandbox"), + onToggle: checked => this._applyToggle(checked), + slotClassName: 'sessions-chat-sandbox-checkbox', + })); + + this._register(this._configurationService.onDidChangeConfiguration(e => { + if (e.affectsConfiguration(CloudSandboxEnabledSettingId) || e.affectsConfiguration(RemoteAgentHostsEnabledSettingId)) { + this._update(); + } + })); + + this._register(autorun(reader => { + const session = this._session.read(reader); + const providerSession = session ? this._getSession(session) : undefined; + providerSession?.useSandbox.read(reader); + // Exactly what the send path requires, so the chip can never promise a sandbox the + // send would silently decline to provision. `repoNwo` is fixed at construction. + this._hasRepository = providerSession instanceof RemoteNewSession && !!providerSession.repoNwo; + this._update(); + })); + } + + private _getSession(session: IActiveSession): ICopilotChatSession | undefined { + const provider = this._sessionsProvidersService.getProvider(session.providerId); + return provider instanceof CopilotChatSessionsProvider ? provider.getSession(session.sessionId) : undefined; + } + + private _applyToggle(checked: boolean): void { + const session = this._session.get(); + const providerSession = session ? this._getSession(session) : undefined; + if (!providerSession) { + return; + } + reportNewChatPickerClosed(this._telemetryService, { + id: 'NewChatSandboxPicker', + name: 'NewChatSandboxPicker', + optionIdBefore: String(providerSession.useSandbox.get() === true), + optionIdAfter: String(checked), + optionLabelBefore: undefined, + optionLabelAfter: undefined, + isPII: false, + }); + providerSession.setUseSandbox(checked); + } + + render(container: HTMLElement): void { + this._chip.render(container); + this._update(); + } + + private _update(): void { + const session = this._session.get(); + const providerSession = session ? this._getSession(session) : undefined; + const enabled = isCloudSandboxEnabled(this._configurationService); + this._chip.update({ + checked: enabled && providerSession?.useSandbox.get() === true, + state: !enabled ? 'hidden' : this._hasRepository ? 'enabled' : 'disabled', + disabledReason: this._hasRepository ? undefined : localize('sandboxPicker.noRepository', "A repository is required to run in a sandbox"), + }); + } +} diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts index 12430098468244..2b9f5154acc20d 100644 --- a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/copilotChatSessionsProvider.test.ts @@ -10,7 +10,7 @@ import { timeout } from '../../../../../../base/common/async.js'; import { DisposableStore, IDisposable, ImmortalReference, toDisposable } from '../../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../../base/common/uri.js'; import { generateUuid } from '../../../../../../base/common/uuid.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { autorun, constObservable, ISettableObservable, observableValue } from '../../../../../../base/common/observable.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IConfigurationService, IConfigurationValue } from '../../../../../../platform/configuration/common/configuration.js'; @@ -35,7 +35,11 @@ import { IChatResponseModel } from '../../../../../../workbench/contrib/chat/com import { IChatAgentData } from '../../../../../../workbench/contrib/chat/common/participants/chatAgents.js'; import { IGitService } from '../../../../../../workbench/contrib/git/common/gitService.js'; import { ISessionChangeEvent } from '../../../../../services/sessions/common/sessionsProvider.js'; -import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { ChatModelSource, GITHUB_REMOTE_FILE_SCHEME, IChat, ISession, SessionStatus } from '../../../../../services/sessions/common/session.js'; +import { CloudSandboxEnabledSettingId, type ICloudSandboxCreateSessionRequest } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { CloudSandboxAgentHostContribution, type ICloudSandboxProvisionedSession } from '../../../remoteAgentHost/browser/cloudSandboxAgentHostContribution.js'; +import { RemoteAgentHostSessionsProvider } from '../../../remoteAgentHost/browser/remoteAgentHostSessionsProvider.js'; import { ChatConfiguration, ChatPermissionLevel } from '../../../../../../workbench/contrib/chat/common/constants.js'; import { CopilotChatSessionsProvider, COPILOT_PROVIDER_ID, CopilotCloudSessionType, ICopilotChatSession } from '../../browser/copilotChatSessionsProvider.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; @@ -320,12 +324,27 @@ function createProviderWithConfig( * The caller can pass a custom `sendRequest` implementation to control the * lifecycle of the in-flight request. */ +/** + * Substitutes the sandbox contribution, which the provider otherwise resolves from the global + * workbench contribution registry. + */ +class TestSandboxCopilotProvider extends CopilotChatSessionsProvider { + sandboxContribution: Pick | undefined; + + protected override _getCloudSandboxContribution(): Pick { + if (!this.sandboxContribution) { + throw new Error('No cloud sandbox contribution was registered'); + } + return this.sandboxContribution; + } +} + function createProviderForSendTests( disposables: DisposableStore, model: MockAgentSessionsModel, sendRequest: (resource: URI, message: string, options?: IChatSendRequestOptions) => Promise, opts?: { onDidCommitSession?: Event<{ original: URI; committed: URI }>; configurationService?: TestConfigurationService; agentHostEnabled?: boolean }, -): CopilotChatSessionsProvider { +): TestSandboxCopilotProvider { const instantiationService = disposables.add(new TestInstantiationService()); const configService = opts?.configurationService ?? new TestConfigurationService(); @@ -382,7 +401,7 @@ function createProviderForSendTests( instantiationService.stub(IGitHubService, new TestGitHubService()); instantiationService.stub(IPullRequestIconCache, new TestPullRequestIconCache()); - return disposables.add(instantiationService.createInstance(CopilotChatSessionsProvider)); + return disposables.add(instantiationService.createInstance(TestSandboxCopilotProvider)); } suite('CopilotChatSessionsProvider', () => { @@ -1807,4 +1826,111 @@ suite('CopilotChatSessionsProvider', () => { `Cloud session should not be removed after committing. Removals seen: [${removals.join(', ')}]`, ); }); + suite('cloud sandbox send path', () => { + // A browsed GitHub workspace root carries a ref (`///HEAD`), which is what + // `repoNwo` has to strip back down to `owner/repo`. + const repoWorkspace = URI.from({ scheme: GITHUB_REMOTE_FILE_SCHEME, path: '/osortega/simple-server/HEAD' }); + + function createSandboxProvider(opts: { enabled?: boolean; provision?: () => Promise } = {}) { + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, opts.enabled ?? true); + configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, true); + + const cloudSends: string[] = []; + const provider = createProviderForSendTests(disposables, model, async (_resource, message) => { + cloudSends.push(message); + // Never settles: these tests only assert which path the send took. + return new Promise(() => { }); + }, { configurationService }); + + const provisionRequests: ICloudSandboxCreateSessionRequest[] = []; + provider.sandboxContribution = { + provisionSession: async request => { + provisionRequests.push(request); + if (opts.provision) { + return opts.provision(); + } + throw new Error('provisioning failed'); + }, + }; + return { provider, provisionRequests, cloudSends }; + } + + /** A provisioned session whose provider immediately commits the send. */ + function provisionedSession(): ICloudSandboxProvisionedSession { + const committed = upcastPartial({ sessionId: 'agenthost:sess-new' }); + const sandboxSession = upcastPartial({ + sessionId: 'agenthost:sess-new', + mainChat: constObservable(upcastPartial({ resource: URI.parse('agent-host-copilot:/sess-new') })), + }); + return { + taskId: 'task-new', + sessionId: 'sess-new', + environmentId: 'env-new', + session: sandboxSession, + provider: upcastPartial({ + sendRequest: async () => committed, + }) as RemoteAgentHostSessionsProvider, + }; + } + + test('provisions a sandbox and replaces the draft with the committed session', async () => { + const { provider, provisionRequests, cloudSends } = createSandboxProvider({ provision: async () => provisionedSession() }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + + const replacements: { from: string; to: string }[] = []; + disposables.add(provider.onDidReplaceSession(e => replacements.push({ from: e.from.sessionId, to: e.to.sessionId }))); + + const committed = await provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + + assert.deepStrictEqual({ + committed: committed.sessionId, + // The repo comes from the workspace root; no baseRef, so MC picks the default branch. + provisionRequests, + // The prompt must not also go through the server-run cloud agent. + cloudSends, + replacements: replacements.map(r => ({ from: r.from === sessionInfo.sessionId, to: r.to })), + }, { + committed: 'agenthost:sess-new', + provisionRequests: [{ repoNwo: 'osortega/simple-server', prompt: 'fix it' }], + cloudSends: [], + replacements: [{ from: true, to: 'agenthost:sess-new' }], + }); + }); + + test('falls back to the server-run cloud agent when the feature is disabled', async () => { + // A remembered preference must not strand the user with a send that always fails. + const { provider, provisionRequests, cloudSends } = createSandboxProvider({ enabled: false }); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + + void provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' }); + await timeout(0); + + assert.deepStrictEqual({ provisionRequests, cloudSends }, { provisionRequests: [], cloudSends: ['fix it'] }); + }); + + test('a failed provision removes the placeholder instead of stranding it in the list', async () => { + const { provider } = createSandboxProvider(); + const sessionInfo = provider.createNewSession(repoWorkspace, CopilotCloudSessionType.id); + const session = provider.getSession(sessionInfo.sessionId)!; + session.setUseSandbox(true); + + const removed: string[] = []; + disposables.add(provider.onDidChangeSessions(e => removed.push(...e.removed.map(s => s.sessionId)))); + + await assert.rejects(() => provider.sendRequest(sessionInfo.sessionId, session.mainChat.get().resource, { query: 'fix it' })); + + assert.deepStrictEqual({ + removed, + stillListed: provider.getSessions().some(s => s.sessionId === sessionInfo.sessionId), + }, { + removed: [sessionInfo.sessionId], + stillListed: false, + }); + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts new file mode 100644 index 00000000000000..14d1f3c15a33f1 --- /dev/null +++ b/src/vs/sessions/contrib/providers/copilotChatSessions/test/browser/sandboxPicker.test.ts @@ -0,0 +1,174 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { Event } from '../../../../../../base/common/event.js'; +import { constObservable, observableValue } from '../../../../../../base/common/observable.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; +import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { CloudSandboxEnabledSettingId } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { RemoteAgentHostsEnabledSettingId } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { TestConfigurationService } from '../../../../../../platform/configuration/test/common/testConfigurationService.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { MockContextKeyService } from '../../../../../../platform/keybinding/test/common/mockKeybindingService.js'; +import { InMemoryStorageService, IStorageService } from '../../../../../../platform/storage/common/storage.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { NullTelemetryService } from '../../../../../../platform/telemetry/common/telemetryUtils.js'; +import { AgentSessionProviders } from '../../../../../../workbench/contrib/chat/browser/agentSessions/agentSessions.js'; +import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; +import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; +import { IActiveSession } from '../../../../../services/sessions/common/sessionsManagement.js'; +import { GITHUB_REMOTE_FILE_SCHEME, ISessionFolder, ISessionWorkspace } from '../../../../../services/sessions/common/session.js'; +import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; +import { CopilotChatSessionsProvider, ICopilotChatSession, RemoteNewSession } from '../../browser/copilotChatSessionsProvider.js'; +import { SandboxPicker } from '../../browser/sandboxPicker.js'; + +class TestSessionsProvidersService extends mock() { + override readonly onDidChangeProviders = Event.None; + + constructor(private readonly provider: ISessionsProvider) { + super(); + } + + override getProvider(): T | undefined { + return this.provider as T; + } +} + +suite('Copilot SandboxPicker', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + + function createPicker(options: { settingEnabled?: boolean; remoteHostsEnabled?: boolean; hasRepository?: boolean; useSandbox?: boolean; committedSession?: boolean } = {}) { + const configurationService = new TestConfigurationService(); + configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, options.settingEnabled ?? true); + configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, options.remoteHostsEnabled ?? true); + + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IConfigurationService, configurationService); + instantiationService.stub(ITelemetryService, NullTelemetryService); + instantiationService.stub(IContextKeyService, new MockContextKeyService()); + instantiationService.stub(IStorageService, disposables.add(new InMemoryStorageService())); + instantiationService.stub(IChatSessionsService, new class extends mock() { + override readonly onDidChangeOptionGroups = Event.None; + override setSessionOption(): boolean { return true; } + override getOptionGroupsForSessionType() { return undefined; } + }()); + + // A browsed GitHub workspace root carries a ref (`///HEAD`); the repo-less + // case is a URI with no `owner/repo` to derive. + const root = (options.hasRepository ?? true) + ? URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}:/osortega/simple-server/HEAD`) + : URI.parse(`${GITHUB_REMOTE_FILE_SCHEME}:/`); + const workspace = upcastPartial({ + uri: root, + folders: [upcastPartial({ root, workingDirectory: root })], + }); + + const providerSession = disposables.add(instantiationService.createInstance( + RemoteNewSession, + URI.from({ scheme: AgentSessionProviders.Cloud, path: '/untitled-1' }), + workspace, + AgentSessionProviders.Cloud, + 'default-copilot', + )); + if (options.useSandbox) { + providerSession.setUseSandbox(true); + } + // A committed session stands in for the case where the active session is no longer the + // draft, whose `setUseSandbox` is a no-op rather than a throw. + const session: ICopilotChatSession = options.committedSession + ? upcastPartial({ useSandbox: constObservable(undefined), setUseSandbox: () => { } }) + : providerSession; + const provider = Object.assign(Object.create(CopilotChatSessionsProvider.prototype), { + getSession: () => session, + }); + instantiationService.stub(ISessionsProvidersService, new TestSessionsProvidersService(provider)); + + const activeSession = observableValue('activeSession', upcastPartial({ + providerId: 'default-copilot', + sessionId: providerSession.sessionId, + })); + + const picker = disposables.add(instantiationService.createInstance(SandboxPicker, activeSession)); + const container = document.createElement('div'); + picker.render(container); + return { container, providerSession }; + } + + function chipState(container: HTMLElement) { + const slot = container.querySelector('.sessions-chat-sandbox-checkbox'); + const checkbox = container.querySelector('.sessions-chat-sandbox-checkbox .monaco-checkbox'); + return { + rendered: !!slot, + hidden: slot?.classList.contains('hidden') ?? false, + disabled: slot?.classList.contains('disabled') ?? false, + checked: checkbox?.getAttribute('aria-checked') ?? undefined, + }; + } + + test('is enabled and unchecked by default when the setting is on and a repository is present', () => { + const { container } = createPicker(); + + assert.deepStrictEqual(chipState(container), { rendered: true, hidden: false, disabled: false, checked: 'false' }); + }); + + test('is hidden when the cloud sandbox setting is off', () => { + const { container } = createPicker({ settingEnabled: false }); + + assert.deepStrictEqual(chipState(container), { rendered: true, hidden: true, disabled: false, checked: 'false' }); + }); + + test('is hidden when remote agent hosts are off, since a sandbox is reached over that relay', () => { + const { container } = createPicker({ remoteHostsEnabled: false }); + + assert.deepStrictEqual(chipState(container), { rendered: true, hidden: true, disabled: false, checked: 'false' }); + }); + + test('shows unchecked when the feature is off despite a remembered preference', () => { + // The send path falls back to the server-run cloud agent in this state, so the chip must + // not claim the session is going to a sandbox. + const { container } = createPicker({ settingEnabled: false, useSandbox: true }); + + assert.deepStrictEqual(chipState(container), { rendered: true, hidden: true, disabled: false, checked: 'false' }); + }); + + test('is disabled without a repository, since a sandbox is always provisioned against one', () => { + const { container } = createPicker({ hasRepository: false }); + + assert.deepStrictEqual(chipState(container), { rendered: true, hidden: false, disabled: true, checked: 'false' }); + }); + + test('reflects an already-selected sandbox preference', () => { + const { container } = createPicker({ useSandbox: true }); + + assert.deepStrictEqual(chipState(container), { rendered: true, hidden: false, disabled: false, checked: 'true' }); + }); + + test('clicking the row toggles the session preference', () => { + const { container, providerSession } = createPicker(); + const row = container.querySelector('.sessions-chat-sandbox-checkbox .action-label'); + assert.ok(row); + + row.click(); + + assert.deepStrictEqual({ useSandbox: providerSession.useSandbox.get(), state: chipState(container) }, { + useSandbox: true, + state: { rendered: true, hidden: false, disabled: false, checked: 'true' }, + }); + }); + + test('toggling a committed session is a no-op rather than a throw', () => { + // `getSession` returns committed sessions too, and a throw out of a click handler is a + // far worse failure than doing nothing. + const { container } = createPicker({ committedSession: true }); + const row = container.querySelector('.sessions-chat-sandbox-checkbox .action-label'); + assert.ok(row); + + assert.doesNotThrow(() => row.click()); + }); +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts index f75a4b4474cd43..25ad9bc98d3ac2 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxAgentHostContribution.ts @@ -8,7 +8,7 @@ // CloudSandboxAgentHostService, and wires the live connection to the provider so the native session // machinery can enumerate and render the host's sessions. -import { CancellationTokenSource } from '../../../../../base/common/cancellation.js'; +import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js'; import { Codicon } from '../../../../../base/common/codicons.js'; import { CancellationError } from '../../../../../base/common/errors.js'; import { Event } from '../../../../../base/common/event.js'; @@ -22,7 +22,10 @@ import { cloudSandboxAddress, ICloudSandboxAgentHostService, ICloudSandboxApiService, + isCloudSandboxEnabled, type ICloudSandboxConnectOptions, + type ICloudSandboxCreateSessionRequest, + type ICloudSandboxCreatedSession, type ICloudSandboxDiscoveryResult, } from '../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { AgentSession, type IAgentSessionMetadata } from '../../../../../platform/agentHost/common/agent.js'; @@ -39,6 +42,7 @@ import { IWorkbenchContribution } from '../../../../../workbench/common/contribu import { ChatSessionsExtensions, IAsyncChatSessionActivationRegistry, IChatSessionsService } from '../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { CloudSandboxReadOnlySessionHandler } from './cloudSandboxReadOnlySessionHandler.js'; import { IAgentHostFilterService } from '../../../../services/agentHostFilter/common/agentHostFilter.js'; +import { ISession } from '../../../../services/sessions/common/session.js'; import { ISessionsProvidersService } from '../../../../services/sessions/browser/sessionsProvidersService.js'; import { ISessionSchemeAlias, IRemoteAgentHostSessionsProviderConfig, RemoteAgentHostSessionsProvider } from './remoteAgentHostSessionsProvider.js'; import { IRemoteAgentHostConnectionCustomizationService } from './remoteAgentHostConnectionCustomization.js'; @@ -82,6 +86,15 @@ function discoveredSessionProject(repoName: string | undefined): IAgentSessionMe return { uri: URI.parse(`https://github.com/${repoName}`), displayName: repoName }; } +/** + * A sandbox session that has just been created, connected, and surfaced on its provider — enough + * for the caller to send the first turn into it. + */ +export interface ICloudSandboxProvisionedSession extends ICloudSandboxCreatedSession { + readonly provider: RemoteAgentHostSessionsProvider; + readonly session: ISession; +} + export class CloudSandboxAgentHostContribution extends Disposable implements IWorkbenchContribution { static readonly ID = 'workbench.contrib.cloudSandboxAgentHost'; @@ -92,6 +105,12 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo private readonly _environments = new Map(); /** In-flight connects keyed by address, so concurrent opens share one attempt. */ private readonly _pendingConnects = new Map>(); + /** + * Addresses being provisioned right now. A task we just created is not yet visible to a + * discovery pass that started before it existed, so reconciliation would see a brand-new + * environment as one that has vanished and tear it down mid-provision. + */ + private readonly _provisioning = new Set(); /** * Read-only content providers standing in for unreachable environments, keyed by session type. * Disposed when the environment becomes reachable again. @@ -160,10 +179,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo } })); - // Lazy discovery: surface environment-bound sandbox sessions in the list - // without connecting. Runs when the Agents window (re)discovers hosts and - // once now so sessions appear on startup. Connecting happens on open via - // the sandbox async activator. + // Lazy discovery: surface environment-bound sandbox sessions in the list without connecting. + // Connecting happens on open via the sandbox async activator. this._register(this._agentHostFilterService.registerDiscoveryHandler(() => this._discoverAndSeed())); void this._discoverAndSeed(); @@ -181,11 +198,9 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo retryUntilFirstSuccess.add(this._authenticationService.onDidChangeSessions(retry)); retryUntilFirstSuccess.add(this._authenticationService.onDidRegisterAuthenticationProvider(retry)); - // Connect-on-open: when a seeded sandbox session is opened, the chat - // service resolves it through this async activator, which establishes the - // relay connection and waits for the host to advertise the session's agent - // (so its content provider registers) before the chat loads. Scoped to our - // sandbox authorities so it never intercepts other remote-agent-host types. + // Connect-on-open: resolves a seeded session by establishing the relay and waiting for the + // host to advertise its agent. Scoped to our authorities so it never intercepts other + // remote-agent-host types. // The source is swapped out by `_teardownAll`, so cancel whichever one is current on dispose. this._register(toDisposable(() => { this._enabledCts.cancel(); @@ -257,10 +272,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo const modifiedTime = Number.isNaN(parsed) ? Date.now() : parsed; const project = discoveredSessionProject(session.repoName); const meta: IAgentSessionMetadata = { - // Seed under the agent-provider (UI) scheme, preserving the session id. Mission Control - // issues each session as `ahp-session:/` (the id it also returns here), and the - // Copilot host lists that same id back, so the seed reconciles deterministically with - // the live `listSessions()` result on connect. See copilot-host session-identity docs. + // Seed under the agent-provider (UI) scheme, preserving the session id: the host + // lists the same id back, so this reconciles with `listSessions()` on connect. session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, session.sessionId), startTime: modifiedTime, modifiedTime, @@ -275,7 +288,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo // Only a complete scan is authoritative — a partial one is missing entries that still exist. if (result.kind === 'complete') { for (const address of [...this._environments.keys()]) { - if (present.has(address)) { + if (present.has(address) || this._provisioning.has(address)) { continue; } const connected = this._remoteAgentHostService.connections.some(c => c.address === address); @@ -302,6 +315,66 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo } } + /** + * Provision a brand-new sandbox session and make it usable: create the Mission Control task, + * seed it into a per-environment provider, and connect the relay. + * + * From the seed onward this matches {@link _doDiscoverAndSeed}, so a later discovery pass + * reconciles against the session instead of duplicating it. The caller sends the first turn. + */ + async provisionSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken): Promise { + if (!this._isEnabled()) { + throw new Error('Copilot cloud sandbox connections are not enabled.'); + } + const created = await this._apiService.createSession(request, token); + const name = request.repoNwo ?? created.taskId; + const address = cloudSandboxAddress(created.environmentId); + // `_teardownAll` has already snapshotted the environments it knows about, so registering a + // provider now would leave one behind that nothing reconciles. + if (!this._isEnabled() || token.isCancellationRequested) { + throw new CancellationError(); + } + this._provisioning.add(address); + try { + this._ensureProvider({ environmentId: created.environmentId, sessionId: created.sessionId, taskId: created.taskId, name }); + + const provider = this._providerInstances.get(address); + if (!provider) { + throw new Error(`No sessions provider was registered for sandbox environment ${created.environmentId}`); + } + const now = Date.now(); + const project = discoveredSessionProject(request.repoNwo); + provider.seedSessions([{ + // Same identity discovery seeds under: Mission Control issues the session as + // `ahp-session:/` and the host lists that id back, so this reconciles on connect. + session: AgentSession.uri(CLOUD_SANDBOX_AGENT_PROVIDER, created.sessionId), + startTime: now, + modifiedTime: now, + summary: name, + ...(project ? { project } : {}), + }]); + + await this.connect({ environmentId: created.environmentId, sessionId: created.sessionId, name }); + + // Connecting can take minutes while the sandbox wakes, and the feature can be disabled + // (or the environment torn down) in the meantime — in which case `provider` is disposed + // and unregistered, and handing it back would send into nothing. + if (!this._isEnabled() || this._providerInstances.get(address) !== provider) { + throw new CancellationError(); + } + + // The adapter `seedSessions` created addresses the session by its raw id, which is the + // session id Mission Control just returned. + const session = provider.getSessions().find(candidate => AgentSession.id(candidate.resource) === created.sessionId); + if (!session) { + throw new Error(`Provisioned sandbox session ${created.sessionId} did not surface on its provider`); + } + return { ...created, provider, session }; + } finally { + this._provisioning.delete(address); + } + } + /** * Fully tear down an environment: dispose its provider (unregistering it and its sessions) and * remove its connection + credential refresher. Used when an environment vanishes from discovery @@ -388,8 +461,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo if (connectError !== undefined) { this._logService.warn(`${LOG_PREFIX} connect-on-open failed for ${address}: ${connectError instanceof Error ? connectError.message : String(connectError)}`); // Serve history whatever the reason: `/connect` fails in several ways for a deleted - // sandbox, so gating on any one of them would leave the rest with no history. A - // transient failure also lands here, which the host's connect action recovers from. + // sandbox, so gating on any one of them would leave the rest with no history. if (this._isEnabled() && !this._enabledCts.token.isCancellationRequested) { const opened = this._activateReadOnly(sessionType, address, env, prefetchedHistory); if (opened) { @@ -421,9 +493,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo * online, has no task, or the read failed. * * `status` cannot predict whether a dormant environment will wake — suspended and deleted both - * read `offline` — but it does say, in a few hundred milliseconds, that this open is on the slow - * path, which is enough to start the fetch now. Never rejects; the handler still reads history - * itself when this yields nothing. + * read `offline` — but it does say cheaply that this open is on the slow path. Never rejects. */ private _prefetchHistoryIfDormant(env: ICloudSandboxEnvironment): Promise | undefined { const taskId = env.taskId; @@ -458,10 +528,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo if (this._readOnlyHandlers.has(sessionType)) { return true; } - // The connect can register the live handler for this session type at any await between - // starting it and observing its outcome, and registering a second content provider throws. - // This check and the registration below are synchronous, so nothing can interleave between - // them. A live provider means the session is already served, which is the better outcome. + // Registering a second content provider for a session type throws. This check and the + // registration below are synchronous, so the connect cannot interleave between them. if (this._chatSessionsService.getContentProviderSchemes().includes(sessionType)) { this._logService.trace(`${LOG_PREFIX} ${sessionType} already has a content provider; leaving it to serve the session.`); return true; @@ -538,10 +606,8 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo const attempt = (async () => { try { this._providerInstances.get(address)?.setConnectionStatus(RemoteAgentHostConnectionStatus.connecting); - // Drop any read-only stand-in *before* connecting. Registering a content provider for - // a session type that already has one throws, and a successful connect registers the - // live handler as soon as the connection is wired — which happens inside the call - // below. `_waitForActivation` re-registers the stand-in if this attempt fails. + // Drop any read-only stand-in *before* connecting: the connect registers the live + // handler, and two content providers for one session type throws. this._clearReadOnly(address); const result = await this._cloudSandboxService.connect(options, token); // The feature may have been disabled while connecting; drop the connection rather @@ -563,8 +629,7 @@ export class CloudSandboxAgentHostContribution extends Disposable implements IWo } private _isEnabled(): boolean { - return this._configurationService.getValue(CloudSandboxEnabledSettingId) - && this._configurationService.getValue(RemoteAgentHostsEnabledSettingId); + return isCloudSandboxEnabled(this._configurationService); } /** Create the sessions provider for an environment if it doesn't exist yet. */ diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxApiService.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxApiService.ts index 548e1e6f00d4ec..3155b972f04a17 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxApiService.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxApiService.ts @@ -9,12 +9,15 @@ import { isCancellationError } from '../../../../../base/common/errors.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import { CLOUD_SANDBOX_AGENT_SLUG, + CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID, CloudSandboxAuthenticationRequiredError, CloudSandboxConnectResult, CloudSandboxRequestError, ICloudSandboxClientToken, ICloudSandboxConnectionRequest, ICloudSandboxApiService, + ICloudSandboxCreatedSession, + ICloudSandboxCreateSessionRequest, ICloudSandboxDiscoveredSession, ICloudSandboxDiscoveryResult, ICloudSandboxEnvironment, @@ -70,9 +73,12 @@ const DISCOVERY_TIMEOUT_MS = 30_000; /** Default Retry-After (seconds) when a 202 "waking" response omits the header. */ const DEFAULT_WAKING_RETRY_AFTER_SECONDS = 5; -/** How many recent tasks to scan for sandbox sessions during discovery. */ +/** How many recent tasks to scan for sandbox sessions during discovery, per page. */ const DISCOVERY_TASK_SCAN_LIMIT = 100; +/** Bounds sequential page fetches. Hitting it leaves tasks unscanned, so the result is `partial`. */ +const DISCOVERY_TASK_PAGE_LIMIT = 10; + /** Fallback scopes when the product does not configure `defaultChatAgent.providerScopes`. */ const FALLBACK_SCOPES = ['read:user', 'user:email', 'repo', 'workflow']; @@ -132,21 +138,47 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA * Enumerate sandbox-backed cloud sessions by scanning recent tasks and resolving each one's * Mission Control environment binding. * - * The result distinguishes a full scan from a partial or failed one: a caller that reconciles - * against this list would otherwise treat a transient request failure as "these sessions no - * longer exist" and tear down live providers. + * Only a `complete` result may be reconciled against: a partial or truncated scan is missing + * entries that still exist. */ async listSessions(token: CancellationToken): Promise { - let tasks: readonly ITaskSummary[]; - try { - const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks?per_page=${DISCOVERY_TASK_SCAN_LIMIT}`, 'list', token); - const response = await this._readJson<{ tasks?: readonly ITaskSummary[] }>(context); - if (!response?.tasks) { - return { kind: 'failed', reason: `listTasks returned no 'tasks' array` }; + const tasks: ITaskSummary[] = []; + let truncated = false; + for (let page = 1; page <= DISCOVERY_TASK_PAGE_LIMIT; page++) { + let batch: readonly ITaskSummary[]; + let hasNextPage: boolean; + try { + const context = await this._sendTask(`${this._tasksBaseUrl()}/tasks?per_page=${DISCOVERY_TASK_SCAN_LIMIT}&page=${page}`, 'list', token); + const response = await this._readJson<{ tasks?: readonly ITaskSummary[] }>(context); + if (!response?.tasks) { + // Earlier pages are still worth seeding, so only fail outright on the first. + if (page === 1) { + return { kind: 'failed', reason: `listTasks returned no 'tasks' array` }; + } + truncated = true; + break; + } + batch = response.tasks; + hasNextPage = hasNextLink(context.res.headers?.['link']); + } catch (error) { + if (page === 1) { + return { kind: 'failed', reason: `listTasks failed: ${toErrorMessage(error)}` }; + } + this._logService.warn(`${LOG_PREFIX} Discovery page ${page} failed: ${toErrorMessage(error)}`); + truncated = true; + break; + } + tasks.push(...batch); + if (!hasNextPage) { + break; + } + if (page === DISCOVERY_TASK_PAGE_LIMIT) { + truncated = true; + } + if (token.isCancellationRequested) { + truncated = true; + break; } - tasks = response.tasks; - } catch (error) { - return { kind: 'failed', reason: `listTasks failed: ${toErrorMessage(error)}` }; } const sandboxTasks = tasks.filter(task => !task.archived_at && isCloudSandboxTask(task)); @@ -183,8 +215,63 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA const sessions = discovered.filter((session): session is ICloudSandboxDiscoveredSession => session !== undefined); const unnamed = sessions.filter(session => !session.repoName).length; - this._logService.info(`${LOG_PREFIX} Discovery found ${sessions.length} sandbox session(s) from ${sandboxTasks.length} sandbox task(s) out of ${tasks.length} scanned${unresolved > 0 ? `; ${unresolved} unresolved` : ''}${unnamed > 0 ? `; ${unnamed} without a repository name (they group under "Unknown")` : ''}.`); - return { kind: unresolved > 0 ? 'partial' : 'complete', sessions }; + this._logService.info(`${LOG_PREFIX} Discovery found ${sessions.length} sandbox session(s) from ${sandboxTasks.length} sandbox task(s) out of ${tasks.length} scanned${truncated ? ' (scan truncated)' : ''}${unresolved > 0 ? `; ${unresolved} unresolved` : ''}${unnamed > 0 ? `; ${unnamed} without a repository name (they group under "Unknown")` : ''}.`); + return { kind: unresolved > 0 || truncated ? 'partial' : 'complete', sessions }; + } + + /** + * Provision a sandbox task bound to an on-demand environment. Mission Control provisions a VM + * and binds a session but starts no run, so the caller sends the first turn over the relay. + * The environment on the returned session is the real VM, not the sentinel. + */ + async createSession(request: ICloudSandboxCreateSessionRequest, token: CancellationToken): Promise { + const repository = parseNwo(request.repoNwo); + const context = await this._request(`${this._tasksBaseUrl()}/tasks`, 'mc.taskClient.create', 'createTask', { + 'Accept': 'application/json', + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, token, REQUEST_TIMEOUT_MS, { + environment_id: CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID, + // Persisted for display, so replayed history shows the prompt no run was started for. + prompt: request.prompt, + ...(repository && { repositories: [repository] }), + }); + if (!isSuccess(context)) { + await this._throwForStatus('task create', context); + } + const task = await this._readJson(context); + const taskId = task?.id; + if (!taskId) { + throw new CloudSandboxRequestError(context.res.statusCode, 'Mission Control task create returned no task id'); + } + const binding = task && getTaskEnvironmentBinding(task); + if (!binding) { + // A task with no bound session is unusable — the relay has nothing to address — but it + // still shows up in the user's task list, so drop it rather than leaving litter behind. + await this._deleteTaskBestEffort(taskId); + throw new CloudSandboxRequestError(context.res.statusCode, `Mission Control bound no sandbox session to task ${taskId}`); + } + this._logService.info(`${LOG_PREFIX} Provisioned sandbox task ${taskId} (session ${binding.sessionId}) on environment ${binding.environmentId}.`); + return { taskId, sessionId: binding.sessionId, environmentId: binding.environmentId }; + } + + /** + * Delete a task we created but cannot use. Best-effort: the caller is already failing, and a + * failed cleanup must not replace the error that explains why. + * + * Only covers tasks Mission Control actually returned to us. A create that is rejected *after* + * the task record exists (HTTP 403 from the sandbox authorization check) reports no id, so + * that orphan can only be cleaned up server-side. + */ + private async _deleteTaskBestEffort(taskId: string): Promise { + try { + const context = await this._request(`${this._tasksBaseUrl()}/tasks/${encodeURIComponent(taskId)}`, 'mc.taskClient.delete', 'deleteTask', { + 'Accept': 'application/json', + 'Copilot-Integration-Id': COPILOT_INTEGRATION_ID, + }, CancellationToken.None, REQUEST_TIMEOUT_MS, undefined, 'DELETE'); + this._logService.info(`${LOG_PREFIX} Cleaned up unusable sandbox task ${taskId}: HTTP ${context.res.statusCode ?? 'none'}`); + } catch (error) { + this._logService.warn(`${LOG_PREFIX} Could not clean up sandbox task ${taskId}: ${toErrorMessage(error)}`); + } } /** @@ -303,7 +390,7 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA return context; } - private async _request(url: string, callSite: string, action: CloudSandboxRequestAction, headers: Record, token: CancellationToken, timeout: number = REQUEST_TIMEOUT_MS): Promise { + private async _request(url: string, callSite: string, action: CloudSandboxRequestAction, headers: Record, token: CancellationToken, timeout: number = REQUEST_TIMEOUT_MS, body?: unknown, method?: 'GET' | 'POST' | 'DELETE'): Promise { const accessToken = await this._resolveGitHubToken(); if (!accessToken) { // No request is issued, so there is no request outcome to count. @@ -312,9 +399,10 @@ export class CloudSandboxApiService extends Disposable implements ICloudSandboxA const started = Date.now(); try { const context = await this._requestService.request({ - type: 'GET', + type: method ?? (body === undefined ? 'GET' : 'POST'), url, headers: { ...headers, ['Authorization']: `Bearer ${accessToken}` }, + ...(body === undefined ? undefined : { data: JSON.stringify(body) }), timeout, callSite, }, token); @@ -430,12 +518,31 @@ function parseRetryAfter(value: string | string[] | undefined): number { /** * Whether a task is a cloud sandbox task: owned by {@link CLOUD_SANDBOX_AGENT_SLUG} and running on * the `sandboxes` compute provider. Reads list-level fields only. + * + * The slug half must be settled before `chat.agentHost.cloudSandbox.enabled` is turned on: sandbox + * tasks are expected to move to a different slug, which would silently make discovery return + * nothing. `compute.provider` is the durable test. */ function isCloudSandboxTask(task: ITaskSummary): boolean { const isCloudCodingAgent = task.agent_collaborators?.some(c => c.slug === CLOUD_SANDBOX_AGENT_SLUG) ?? false; return isCloudCodingAgent && task.compute?.provider === 'sandboxes'; } +/** Whether a `Link` header advertises another page (`rel="next"`). */ +function hasNextLink(value: string | string[] | undefined): boolean { + const raw = Array.isArray(value) ? value.join(',') : value; + return raw ? /rel="?next"?/.test(raw) : false; +} + +/** Split an `owner/name` into the pair Mission Control expects, or `undefined` when unusable. */ +function parseNwo(nwo: string | undefined): { owner: string; name: string } | undefined { + const separator = nwo?.indexOf('/') ?? -1; + if (!nwo || separator <= 0 || separator === nwo.length - 1) { + return undefined; + } + return { owner: nwo.slice(0, separator), name: nwo.slice(separator + 1) }; +} + /** * The Mission Control environment a sandbox task runs in, read from the full task's nested * `sessions[]`. Undefined when no session is bound to an environment yet. diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts index 53942f856e849c..46601c075e9bb6 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxReadOnlySessionHandler.ts @@ -69,9 +69,8 @@ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IC private _prefetchedHistory: Promise | undefined; /** - * Starts `false`: the transcript can be shown while the connect is still in flight, and an - * environment that goes on to wake must not have been presented as read-only. Observable so an - * already-rendered session can be settled in place by {@link markReadOnly}. + * Starts `false`: an environment that goes on to wake must not have been shown as read-only. + * Observable so an already-rendered session can be settled in place by {@link markReadOnly}. */ private readonly _isReadOnly = observableValue('cloudSandboxReadOnly', false); @@ -105,10 +104,8 @@ export class CloudSandboxReadOnlySessionHandler extends Disposable implements IC } async provideChatSessionContent(sessionResource: URI, token: CancellationToken): Promise { - // Resolve the session from the *requested* resource, not from the handler's configuration. - // One handler serves a whole session type, and an environment can own several sessions (a - // fork registers a child under its source task) — keying off config would show one - // session's conversation under another's name. + // Resolve from the *requested* resource, not the handler's config: one handler serves a + // whole session type, and an environment can own several sessions. const sessionId = AgentSession.id(sessionResource); const replayed = await this._readHistory(token); const session = replayed?.sessions.find(s => AgentSession.id(URI.parse(s.session)) === sessionId); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts index 7559412ed9fb5b..f65c5102982a6f 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/cloudSandboxTelemetry.ts @@ -10,7 +10,7 @@ import { createDecorator } from '../../../../../platform/instantiation/common/in import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; /** The Mission Control call being reported. A closed set, so it is safe to send verbatim. */ -export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'getTaskEvents' | 'getRepository'; +export type CloudSandboxRequestAction = 'connect' | 'reconnect' | 'getEnvironment' | 'listTasks' | 'getTask' | 'createTask' | 'deleteTask' | 'getTaskEvents' | 'getRepository'; /** * How a Mission Control request ended, bucketed so a count is meaningful without carrying the diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 1dbd55eca00443..2471bb5de3ac52 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -1156,7 +1156,7 @@ Registry.as(ConfigurationExtensions.Configuration).regis // `CLOUD_SANDBOX_AGENT_SLUG`. [CloudSandboxEnabledSettingId]: { type: 'boolean', - description: nls.localize('chat.agentHost.cloudSandbox.enabled', "Enable connecting to Copilot cloud sandbox sessions over a live Agent Host Protocol relay. When enabled, opening a Copilot cloud session connects to its sandbox for slash commands and a responsive, steerable experience instead of only polling logs."), + description: nls.localize('chat.agentHost.cloudSandbox.enabled', "Enable Copilot cloud sandbox sessions over a live Agent Host Protocol relay, for slash commands and a responsive, steerable experience instead of only polling logs. Adds a Sandbox option when starting a cloud session, and connects to the sandbox when opening one."), default: false, scope: ConfigurationScope.APPLICATION, tags: ['experimental', 'advanced'], diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts index c4000304967f7d..8f366d6ca3d6c4 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxAgentHostContribution.test.ts @@ -7,13 +7,19 @@ import assert from 'assert'; import { CancellationToken } from '../../../../../../base/common/cancellation.js'; import { Event } from '../../../../../../base/common/event.js'; import { Disposable, DisposableStore, IDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { mock } from '../../../../../../base/test/common/mock.js'; +import { mock, upcastPartial } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { AgentSession } from '../../../../../../platform/agentHost/common/agent.js'; import { IAgentSessionMetadata } from '../../../../../../platform/agentHost/common/agentService.js'; import { CloudSandboxEnabledSettingId, + ICloudSandboxAgentHostService, ICloudSandboxApiService, cloudSandboxAddress, + type ICloudSandboxConnectOptions, + type ICloudSandboxCreateSessionRequest, + type ICloudSandboxCreatedSession, type ICloudSandboxDiscoveredSession, type ICloudSandboxDiscoveryResult, } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; @@ -26,6 +32,7 @@ import { INotificationService } from '../../../../../../platform/notification/co import { IAuthenticationService } from '../../../../../../workbench/services/authentication/common/authentication.js'; import { IChatSessionsService } from '../../../../../../workbench/contrib/chat/common/chatSessionsService.js'; import { IAgentHostFilterService } from '../../../../../services/agentHostFilter/common/agentHostFilter.js'; +import { ISession } from '../../../../../services/sessions/common/session.js'; import { ISessionsProvider } from '../../../../../services/sessions/common/sessionsProvider.js'; import { ISessionsProvidersService } from '../../../../../services/sessions/browser/sessionsProvidersService.js'; import { CloudSandboxAgentHostContribution } from '../../browser/cloudSandboxAgentHostContribution.js'; @@ -34,6 +41,7 @@ import { IRemoteAgentHostSessionsProviderConfig, RemoteAgentHostSessionsProvider class StubProvider extends mock() { readonly seeded: IAgentSessionMetadata[] = []; + disposed = false; override readonly id: string; @@ -55,7 +63,20 @@ class StubProvider extends mock() { } } - override dispose(): void { /* noop */ } + /** Surfaces each seed under the UI resource scheme, which is what keys the raw session id. */ + override getSessions(): ISession[] { + return this.seeded.map(meta => upcastPartial({ + resource: URI.from({ scheme: 'agent-host-copilot', path: `/${AgentSession.id(meta.session)}` }), + })); + } + + override setConnectionStatus(): void { } + + override setReadOnly(): void { } + + override dispose(): void { + this.disposed = true; + } } class TestCloudSandboxContribution extends CloudSandboxAgentHostContribution { @@ -75,17 +96,54 @@ class StubSessionsProvidersService extends Disposable { getProviders(): ISessionsProvider[] { return []; } } +interface ITestHarness { + readonly contribution: TestCloudSandboxContribution; + readonly configurationService: TestConfigurationService; + /** Discovery's answer, mutable so a test can change what a later pass reports. */ + discovered: readonly ICloudSandboxDiscoveredSession[]; + /** Runs a discovery pass and waits for it to reconcile. */ + runDiscovery(): Promise; + /** Runs while a `connect` is in flight, for testing what can race with it. */ + onConnect?: () => Promise; + readonly created: ICloudSandboxCreateSessionRequest[]; + readonly connectedTo: string[]; +} + /** * Creates the contribution with a discovery result, and resolves once the constructor's eager * `_discoverAndSeed()` pass has committed its seeds. */ -async function createContribution(store: Pick, sessions: readonly ICloudSandboxDiscoveredSession[]): Promise { +async function createContribution(store: Pick, sessions: readonly ICloudSandboxDiscoveredSession[], options?: { + /** Task Mission Control returns from `createSession`, or a rejection. */ + readonly createSession?: () => Promise; +}): Promise { const discoveryHandlers: (() => Promise)[] = []; const instantiationService = store.add(new TestInstantiationService()); + const created: ICloudSandboxCreateSessionRequest[] = []; + const connectedTo: string[] = []; + const harness: ITestHarness = { + discovered: sessions, + created, + connectedTo, + runDiscovery: async () => { await Promise.all(discoveryHandlers.map(handler => handler())); }, + } as ITestHarness; instantiationService.stub(ICloudSandboxApiService, new class extends mock() { override async listSessions(_token: CancellationToken): Promise { - return { kind: 'complete', sessions }; + return { kind: 'complete', sessions: harness.discovered }; + } + override async createSession(request: ICloudSandboxCreateSessionRequest): Promise { + created.push(request); + return options?.createSession + ? options.createSession() + : { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new' }; + } + }()); + instantiationService.stub(ICloudSandboxAgentHostService, new class extends mock() { + override async connect(connectOptions: ICloudSandboxConnectOptions): Promise { + connectedTo.push(connectOptions.environmentId); + await harness.onConnect?.(); + return cloudSandboxAddress(connectOptions.environmentId); } }()); instantiationService.stub(IRemoteAgentHostService, new class extends mock() { @@ -103,10 +161,11 @@ async function createContribution(store: Pick, sessions: return toDisposable(() => { }); } }()); - instantiationService.stub(IConfigurationService, new TestConfigurationService({ + const configurationService = new TestConfigurationService({ [CloudSandboxEnabledSettingId]: true, [RemoteAgentHostsEnabledSettingId]: true, - })); + }); + instantiationService.stub(IConfigurationService, configurationService); instantiationService.stub(IAuthenticationService, new class extends mock() { override readonly onDidChangeSessions = Event.None; override readonly onDidRegisterAuthenticationProvider = Event.None; @@ -118,8 +177,8 @@ async function createContribution(store: Pick, sessions: const contribution = store.add(instantiationService.createInstance(TestCloudSandboxContribution)); // The constructor kicks off discovery eagerly; re-running the registered handler awaits it, // because `_discoverAndSeed` serializes onto the in-flight pass. - await Promise.all(discoveryHandlers.map(handler => handler())); - return contribution; + await harness.runDiscovery(); + return Object.assign(harness, { contribution, configurationService }); } function discoveredSession(overrides?: Partial): ICloudSandboxDiscoveredSession { @@ -141,7 +200,7 @@ suite('CloudSandboxAgentHostContribution', () => { test('seeds the discovered repository so a never-opened session is not workspace-less', async () => { // Without a project the workspace is undefined and the session groups under "Unknown". // The seeded shape matches what the host reports on connect, so reconciling is a no-op. - const contribution = await createContribution(store, [discoveredSession()]); + const { contribution } = await createContribution(store, [discoveredSession()]); const provider = contribution.stubProviders.get(cloudSandboxAddress('env-1')); assert.deepStrictEqual(provider?.seeded.map(m => ({ @@ -156,7 +215,7 @@ suite('CloudSandboxAgentHostContribution', () => { }); test('omits the project when discovery could not resolve a repository', async () => { - const contribution = await createContribution(store, [discoveredSession({ repoName: undefined })]); + const { contribution } = await createContribution(store, [discoveredSession({ repoName: undefined })]); const provider = contribution.stubProviders.get(cloudSandboxAddress('env-1')); assert.strictEqual(provider?.seeded[0]?.project, undefined); @@ -165,7 +224,7 @@ suite('CloudSandboxAgentHostContribution', () => { test('opts sandbox providers out of the [host] workspace-label suffix', async () => { // Each sandbox is its own provider named after its task, so the suffix would put every // session in a workspace group of one. - const contribution = await createContribution(store, [ + const { contribution } = await createContribution(store, [ discoveredSession(), discoveredSession({ environmentId: 'env-2', sessionId: 'sess-2', taskId: 'task-2', name: 'hi' }), ]); @@ -179,3 +238,102 @@ suite('CloudSandboxAgentHostContribution', () => { ]); }); }); + +suite('CloudSandboxAgentHostContribution provisioning', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('creates the task, seeds it like a discovered one, and connects to the bound environment', async () => { + const harness = await createContribution(store, []); + + const provisioned = await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None); + + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); + assert.deepStrictEqual({ + ids: { taskId: provisioned.taskId, sessionId: provisioned.sessionId, environmentId: provisioned.environmentId }, + // The seed must match discovery's shape, or a later pass would duplicate the session. + seeded: provider?.seeded.map(m => ({ session: m.session.toString(), summary: m.summary, project: m.project?.displayName })), + // The relay must target the bound VM, never the `github-sandbox` sentinel. + connectedTo: harness.connectedTo, + resolvedSession: provisioned.session.resource.path, + }, { + ids: { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new' }, + seeded: [{ session: 'copilot:/sess-new', summary: 'osortega/simple-server', project: 'osortega/simple-server' }], + connectedTo: ['env-new'], + resolvedSession: '/sess-new', + }); + }); + + test('a discovery pass that cannot see the new task yet does not tear it down mid-provision', async () => { + // The scan was issued before the task existed, so it reports the environment as absent. + // Without the in-flight guard, reconciliation disposes the provider we are connecting to. + const harness = await createContribution(store, []); + harness.onConnect = () => harness.runDiscovery(); + + const provisioned = await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None); + + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); + assert.deepStrictEqual({ + disposed: provider?.disposed, + returnedLiveProvider: provisioned.provider === provider, + }, { + disposed: false, + returnedLiveProvider: true, + }); + }); + + test('rejects when the feature is disabled while the sandbox is waking', async () => { + // Connecting waits out the VM boot, which is long enough for the setting to change. + // Returning a provider that teardown has already disposed would send into nothing. + const harness = await createContribution(store, []); + harness.onConnect = async () => { + harness.configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, false); + }; + + await assert.rejects(() => harness.contribution.provisionSession({ prompt: 'fix it' }, CancellationToken.None)); + }); + + test('registers nothing when the feature is disabled while the task is being created', async () => { + // `_teardownAll` snapshots the environments it knows about, so a provider registered after + // it runs is never reconciled — it would outlive the feature being turned off. + let disable = () => { }; + const harness = await createContribution(store, [], { + createSession: async () => { + disable(); + return { taskId: 'task-new', sessionId: 'sess-new', environmentId: 'env-new' }; + }, + }); + disable = () => harness.configurationService.setUserConfiguration(CloudSandboxEnabledSettingId, false); + + await assert.rejects(() => harness.contribution.provisionSession({ prompt: 'fix it' }, CancellationToken.None)); + + assert.deepStrictEqual({ + providers: [...harness.contribution.stubProviders.keys()], + connectedTo: harness.connectedTo, + }, { + providers: [], + connectedTo: [], + }); + }); + + test('rejects without provisioning anything when the feature is already disabled', async () => { + const harness = await createContribution(store, []); + harness.configurationService.setUserConfiguration(RemoteAgentHostsEnabledSettingId, false); + + await assert.rejects(() => harness.contribution.provisionSession({ prompt: 'fix it' }, CancellationToken.None)); + + assert.deepStrictEqual({ created: harness.created, connectedTo: harness.connectedTo }, { created: [], connectedTo: [] }); + }); + + test('a later discovery pass reconciles with the provisioned session instead of duplicating it', async () => { + const harness = await createContribution(store, []); + await harness.contribution.provisionSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None); + + // The task is now visible to discovery, under the same session id it was created with. + harness.discovered = [discoveredSession({ environmentId: 'env-new', sessionId: 'sess-new', taskId: 'task-new', name: 'fix it' })]; + await harness.runDiscovery(); + + const provider = harness.contribution.stubProviders.get(cloudSandboxAddress('env-new')); + assert.deepStrictEqual(provider?.seeded.map(m => m.session.toString()), ['copilot:/sess-new']); + }); +}); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxApiService.test.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxApiService.test.ts index 31c61ba542abe9..71342fd629a34e 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxApiService.test.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/test/browser/cloudSandboxApiService.test.ts @@ -10,7 +10,7 @@ import { Event } from '../../../../../../base/common/event.js'; import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; import { IRequestContext } from '../../../../../../base/parts/request/common/request.js'; -import { CLOUD_SANDBOX_AGENT_SLUG } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; +import { CLOUD_SANDBOX_AGENT_SLUG, CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID } from '../../../../../../platform/agentHost/common/cloudSandboxAgentHost.js'; import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; import { ILogService, NullLogService } from '../../../../../../platform/log/common/log.js'; import { IProductService } from '../../../../../../platform/product/common/productService.js'; @@ -19,9 +19,9 @@ import { IAuthenticationService } from '../../../../../../workbench/services/aut import { CloudSandboxApiService } from '../../browser/cloudSandboxApiService.js'; import { ICloudSandboxTelemetryService } from '../../browser/cloudSandboxTelemetry.js'; -function jsonResponse(body: unknown, statusCode = 200): IRequestContext { +function jsonResponse(body: unknown, statusCode = 200, headers: Record = {}): IRequestContext { return { - res: { headers: {}, statusCode }, + res: { headers, statusCode }, stream: bufferToStream(VSBuffer.fromString(JSON.stringify(body))), }; } @@ -47,6 +47,8 @@ function createService(store: Pick<{ add(t: T): T readonly tasks: readonly unknown[]; /** Repository id -> response, or 'error' to fail the lookup. */ readonly repositories: ReadonlyMap; + /** Serve page 1 with fewer rows than requested while still advertising `rel="next"`. */ + readonly shortFirstPage?: boolean; }): ITestSetup { const requestedUrls: string[] = []; const instantiationService = store.add(new TestInstantiationService()); @@ -67,7 +69,18 @@ function createService(store: Pick<{ add(t: T): T const id = url.split('/').pop()!; return jsonResponse(options.tasks.find(t => (t as { id: string }).id === decodeURIComponent(id))); } - return jsonResponse({ tasks: options.tasks }); + // Paginate like Mission Control does, advertising further pages via the `Link` header. + const perPage = Number(url.match(/[?&]per_page=(\d+)/)?.[1] ?? options.tasks.length); + const page = Number(url.match(/[?&]page=(\d+)/)?.[1] ?? 1); + if (options.shortFirstPage && page === 1) { + return jsonResponse({ tasks: [] }, 200, { link: `; rel="next"` }); + } + const slice = options.shortFirstPage ? options.tasks : options.tasks.slice((page - 1) * perPage, page * perPage); + const hasNext = !options.shortFirstPage && page * perPage < options.tasks.length; + const link = hasNext + ? `; rel="next"` + : `; rel="last"`; + return jsonResponse({ tasks: slice }, 200, { link }); } }()); instantiationService.stub(IAuthenticationService, new class extends mock() { @@ -166,4 +179,182 @@ suite('CloudSandboxApiService repository resolution', () => { repoLookups: 2, }); }); + + test('scans past the first page and stays complete', async () => { + // A full first page means there may be more; a sandbox task on the second must be found. + const filler = Array.from({ length: 100 }, (_, i) => task(`filler-${i}`, 'x', undefined, `fs-${i}`, `fe-${i}`)); + const { service, requestedUrls } = createService(store, { + tasks: [...filler, task('task-old', 'older sandbox', undefined, 'sess-old', 'env-old')], + repositories: new Map(), + }); + + const result = await service.listSessions(CancellationToken.None); + + assert.deepStrictEqual({ + kind: result.kind, + found: result.kind === 'failed' ? [] : result.sessions.filter(s => s.sessionId === 'sess-old').map(s => s.sessionId), + listPages: requestedUrls.filter(u => /[?&]per_page=/.test(u)).length, + }, { + kind: 'complete', + found: ['sess-old'], + listPages: 2, + }); + }); + + test('follows the Link header past a short page', async () => { + // Mission Control can return fewer rows than asked for and still advertise a next page, so + // page length must not be used to detect the end. + const { service, requestedUrls } = createService(store, { + tasks: [task('task-old', 'older sandbox', undefined, 'sess-old', 'env-old')], + repositories: new Map(), + shortFirstPage: true, + }); + + const result = await service.listSessions(CancellationToken.None); + + assert.deepStrictEqual({ + kind: result.kind, + found: result.kind === 'failed' ? [] : result.sessions.map(s => s.sessionId), + listPages: requestedUrls.filter(u => /[?&]per_page=/.test(u)).length, + }, { + kind: 'complete', + found: ['sess-old'], + listPages: 2, + }); + }); + + test('a truncated scan is partial, so callers do not reconcile against it', async () => { + // Every page comes back full, so the page ceiling is hit with tasks still unscanned. + // Reporting `complete` here would let the caller tear down sessions it simply never saw. + const tasks = Array.from({ length: 100 * 12 }, (_, i) => task(`t-${i}`, 'x', undefined, `s-${i}`, `e-${i}`)); + const { service, requestedUrls } = createService(store, { tasks, repositories: new Map() }); + + const result = await service.listSessions(CancellationToken.None); + + assert.deepStrictEqual({ + kind: result.kind, + sessions: result.kind === 'failed' ? -1 : result.sessions.length, + listPages: requestedUrls.filter(u => /[?&]per_page=/.test(u)).length, + }, { + kind: 'partial', + sessions: 1000, + listPages: 10, + }); + }); +}); + +interface ICreateCall { + readonly url: string; + readonly type: string; + readonly body: unknown; +} + +function createServiceForCreate(store: Pick<{ add(t: T): T }, 'add'>, response: unknown, statusCode = 200, options?: { readonly failDelete?: boolean }): { service: CloudSandboxApiService; calls: ICreateCall[] } { + const calls: ICreateCall[] = []; + const instantiationService = store.add(new TestInstantiationService()); + instantiationService.stub(IRequestService, new class extends mock() { + override async request(opts: { url?: string; type?: string; data?: string }): Promise { + calls.push({ url: opts.url ?? '', type: opts.type ?? '', body: opts.data === undefined ? undefined : JSON.parse(opts.data) }); + if (opts.type === 'DELETE' && options?.failDelete) { + throw new Error('delete failed'); + } + return jsonResponse(response, statusCode); + } + }()); + instantiationService.stub(IAuthenticationService, new class extends mock() { + override async getSessions() { return [{ accessToken: 'tok', id: 's', account: { id: 'a', label: 'a' }, scopes: [] }]; } + override readonly onDidChangeSessions = Event.None; + }()); + instantiationService.stub(IProductService, { defaultChatAgent: undefined } as unknown as IProductService); + instantiationService.stub(ILogService, new NullLogService()); + instantiationService.stub(ICloudSandboxTelemetryService, new class extends mock() { + override reportRequest(): void { } + }()); + return { service: store.add(instantiationService.createInstance(CloudSandboxApiService)), calls }; +} + +suite('CloudSandboxApiService session creation', () => { + + const store = ensureNoDisposablesAreLeakedInTestSuite(); + + test('posts the on-demand sentinel and returns the bound environment', async () => { + const { service, calls } = createServiceForCreate(store, { + id: 'task-1', + sessions: [{ id: 'sess-1', environment_id: 'env-concrete' }], + }); + + const created = await service.createSession({ repoNwo: 'osortega/simple-server', prompt: 'fix it' }, CancellationToken.None); + + assert.deepStrictEqual({ + created, + type: calls[0].type, + endsWithTasks: calls[0].url.endsWith('/agents/tasks'), + body: calls[0].body, + }, { + created: { taskId: 'task-1', sessionId: 'sess-1', environmentId: 'env-concrete' }, + type: 'POST', + endsWithTasks: true, + body: { + environment_id: CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID, + prompt: 'fix it', + repositories: [{ owner: 'osortega', name: 'simple-server' }], + }, + }); + }); + + test('omits the repository when it is not supplied', async () => { + const { service, calls } = createServiceForCreate(store, { + id: 'task-2', + sessions: [{ id: 'sess-2', environment_id: 'env-2' }], + }); + + await service.createSession({ prompt: 'hello' }, CancellationToken.None); + + assert.deepStrictEqual(calls[0].body, { + environment_id: CLOUD_SANDBOX_ON_DEMAND_ENVIRONMENT_ID, + prompt: 'hello', + }); + }); + + test('throws when Mission Control binds no session to the created task', async () => { + // A task with no bound session has nothing for the relay to address, so this must not be + // reported as a usable sandbox. + const { service } = createServiceForCreate(store, { id: 'task-3', sessions: [] }); + + await assert.rejects( + () => service.createSession({ prompt: 'hello' }, CancellationToken.None), + /bound no sandbox session/, + ); + }); + + test('deletes a created task that has no usable session, rather than leaving it in the task list', async () => { + // The task exists on the server even though it is unusable, so it would otherwise show up + // in the user's task list forever. + const { service, calls } = createServiceForCreate(store, { id: 'task-3', sessions: [] }); + + await assert.rejects(() => service.createSession({ prompt: 'hello' }, CancellationToken.None)); + + assert.deepStrictEqual(calls.map(c => `${c.type} ${c.url.replace(/^.*\/agents/, '')}`), [ + 'POST /tasks', + 'DELETE /tasks/task-3', + ]); + }); + + test('a failed cleanup does not replace the error explaining why creation failed', async () => { + const { service } = createServiceForCreate(store, { id: 'task-3', sessions: [] }, 200, { failDelete: true }); + + await assert.rejects( + () => service.createSession({ prompt: 'hello' }, CancellationToken.None), + /bound no sandbox session/, + ); + }); + + test('throws on a non-success status', async () => { + const { service } = createServiceForCreate(store, { message: 'nope' }, 403); + + await assert.rejects( + () => service.createSession({ prompt: 'hello' }, CancellationToken.None), + /HTTP 403/, + ); + }); }); From 6cb98a05e60cf0f082d9136b47efee24703e451c Mon Sep 17 00:00:00 2001 From: Benjamin Christopher Simmonds <44439583+benibenj@users.noreply.github.com> Date: Wed, 19 Aug 2026 23:59:12 +0200 Subject: [PATCH 27/28] agentHost: fix invalid GitHub GraphQL fields breaking Agent Merge (#331712) Two GraphQL documents selected fields that do not exist in GitHub's schema, so both requests failed at validation time before executing. PullRequest.viewerCanMerge does not exist. Merge permission is now derived from Repository.viewerPermission (ADMIN/MAINTAIN/WRITE grant push access). It fails closed when the field is null, which GitHub returns for GitHub App authentication, matching the existing REST fallback. rateLimit exists only on the Query root, but all four pull request mutations selected it at the Mutation root, so every Agent Merge write failed. Rate limits are still tracked from the x-ratelimit-* response headers in GitHubTransport, so no telemetry is lost. Note agentHostOctoKitService.ts already had the correct form; the two copies had drifted. Verified by validating every GraphQL document in the repository against the live schema via introspection; the remaining 29 documents were already valid. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../common/pullRequestMutationService.ts | 6 +-- .../github/common/pullRequestQueryService.ts | 19 +++++-- .../test/node/pullRequestQueryService.test.ts | 49 ++++++++++++++++++- 3 files changed, 66 insertions(+), 8 deletions(-) diff --git a/src/vs/platform/github/common/pullRequestMutationService.ts b/src/vs/platform/github/common/pullRequestMutationService.ts index d6a21db7e6016f..054e45e15012ef 100644 --- a/src/vs/platform/github/common/pullRequestMutationService.ts +++ b/src/vs/platform/github/common/pullRequestMutationService.ts @@ -67,32 +67,30 @@ const maximumWorkflowLogBytes = 2 * 1024 * 1024; const workflowLogTimeout = 30_000; const mergePreparationLifetime = 5 * 60_000; +// GitHub exposes `rateLimit` on the `Query` root only, so mutations must not select it. Mutation rate +// limits are still tracked from the `x-ratelimit-*` response headers by the transport. const addReviewThreadReplyMutation = `mutation AgentHostAddPullRequestReviewThreadReply($threadId: ID!, $body: String!) { addPullRequestReviewThreadReply(input: { pullRequestReviewThreadId: $threadId, body: $body }) { comment { id databaseId body url createdAt updatedAt author { login ... on User { databaseId } } } } - rateLimit { limit remaining used resetAt } }`; const resolveReviewThreadMutation = `mutation AgentHostResolvePullRequestReviewThread($threadId: ID!) { resolveReviewThread(input: { threadId: $threadId }) { thread { id isResolved } } - rateLimit { limit remaining used resetAt } }`; const enqueuePullRequestMutation = `mutation AgentHostEnqueuePullRequest($pullRequestId: ID!, $expectedHeadOid: GitObjectID!) { enqueuePullRequest(input: { pullRequestId: $pullRequestId, expectedHeadOid: $expectedHeadOid }) { mergeQueueEntry { id } } - rateLimit { limit remaining used resetAt } }`; const enableAutoMergeMutation = `mutation AgentHostEnablePullRequestAutoMerge($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!) { enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: $mergeMethod }) { pullRequest { id } } - rateLimit { limit remaining used resetAt } }`; export class PullRequestMutationService extends Disposable implements IPullRequestMutations { diff --git a/src/vs/platform/github/common/pullRequestQueryService.ts b/src/vs/platform/github/common/pullRequestQueryService.ts index f68accb3c4aa7c..e52da91cb37dc0 100644 --- a/src/vs/platform/github/common/pullRequestQueryService.ts +++ b/src/vs/platform/github/common/pullRequestQueryService.ts @@ -128,11 +128,11 @@ const expectedCheckSuitesQuery = `query AgentHostPullRequestExpectedCheckSuites( const mergeabilityQuery = (includeMergeQueue: boolean) => `query AgentHostPullRequestMergeability($owner: String!, $repo: String!, $number: Int!${includeMergeQueue ? ', $baseBranch: String!' : ''}) { repository(owner: $owner, name: $repo) { - id nameWithOwner mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed + id nameWithOwner mergeCommitAllowed squashMergeAllowed rebaseMergeAllowed viewerPermission ${includeMergeQueue ? 'mergeQueue(branch: $baseBranch) { id }' : ''} pullRequest(number: $number) { id headRefOid baseRefOid mergeable mergeStateStatus reviewDecision - viewerCanUpdateBranch viewerCanMerge viewerCanEnableAutoMerge + viewerCanUpdateBranch viewerCanEnableAutoMerge autoMergeRequest { enabledAt } mergeQueueEntry { id } } @@ -558,7 +558,7 @@ export class PullRequestQueryService implements IPullRequestQuery { mergeStateStatus: stringProperty(pullRequest, 'mergeStateStatus'), reviewDecision: stringProperty(pullRequest, 'reviewDecision'), viewerCanUpdate: booleanProperty(pullRequest, 'viewerCanUpdateBranch') ?? false, - viewerCanMerge: booleanProperty(pullRequest, 'viewerCanMerge') ?? false, + viewerCanMerge: canViewerMerge(repository), viewerCanEnableAutoMerge: booleanProperty(pullRequest, 'viewerCanEnableAutoMerge') ?? false, allowedMergeMethods, autoMergeEnabled: optionalObjectProperty(pullRequest, 'autoMergeRequest') !== undefined, @@ -645,6 +645,19 @@ function needsCapabilities(fragment: PullRequestFragment): boolean { return fragment === 'reviewThreads' || fragment === 'checks' || fragment === 'mergeability'; } +/** `RepositoryPermission` values that grant push access, and therefore permission to merge a pull request. */ +const mergePermissions: ReadonlySet = new Set(['ADMIN', 'MAINTAIN', 'WRITE']); + +/** + * GitHub's GraphQL schema has no `PullRequest.viewerCanMerge`, so merge permission is derived from the + * viewer's permission on the base repository. `Repository.viewerPermission` is null when the request is + * authenticated as a GitHub App, which fails closed the same way the REST fallback does. + */ +function canViewerMerge(repository: object): boolean { + const permission = normalizedEnumProperty(repository, 'viewerPermission'); + return permission !== undefined && mergePermissions.has(permission); +} + function toCore(value: unknown, ref: PullRequestRef): PullRequestCore { const item = asObject(value, 'GitHub pull request response was malformed'); const base = objectProperty(item, 'base'); diff --git a/src/vs/platform/github/test/node/pullRequestQueryService.test.ts b/src/vs/platform/github/test/node/pullRequestQueryService.test.ts index fd23b642705b46..0c42a82e79380b 100644 --- a/src/vs/platform/github/test/node/pullRequestQueryService.test.ts +++ b/src/vs/platform/github/test/node/pullRequestQueryService.test.ts @@ -378,6 +378,7 @@ suite('PullRequestQueryService', () => { mergeCommitAllowed: true, squashMergeAllowed: true, rebaseMergeAllowed: false, + viewerPermission: 'WRITE', mergeQueue: null, pullRequest: { headRefOid: 'head-1', @@ -386,7 +387,6 @@ suite('PullRequestQueryService', () => { mergeStateStatus: 'CLEAN', reviewDecision: 'APPROVED', viewerCanUpdateBranch: true, - viewerCanMerge: true, viewerCanEnableAutoMerge: true, autoMergeRequest: null, mergeQueueEntry: null, @@ -446,6 +446,53 @@ suite('PullRequestQueryService', () => { }); }); + test('derives merge permission from the repository permission of the viewer', async () => { + await withServer(async server => { + server.enqueue(gitHubGraphQLStep({ + queryIncludes: ['AgentHostPullRequestMergeability', 'viewerPermission'], + response: gitHubGraphQLResponse({ + repository: { + mergeCommitAllowed: true, + squashMergeAllowed: false, + rebaseMergeAllowed: false, + viewerPermission: 'READ', + mergeQueue: null, + pullRequest: { + headRefOid: 'head-1', + baseRefOid: 'base', + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + viewerCanUpdateBranch: false, + viewerCanEnableAutoMerge: false, + autoMergeRequest: null, + mergeQueueEntry: null, + }, + }, + }), + })); + const { query, ref, credential } = setup(server); + const result = await query.fetch('mergeability', ref, core('head-1'), { priority: 'interactive', mergeability: true }, credential, new AbortController().signal); + + assert.deepStrictEqual(result.fragment === 'mergeability' ? result.value : undefined, { + headSha: 'head-1', + baseSha: 'base', + mergeable: 'MERGEABLE', + mergeStateStatus: 'CLEAN', + reviewDecision: 'APPROVED', + viewerCanUpdate: false, + viewerCanMerge: false, + viewerCanEnableAutoMerge: false, + allowedMergeMethods: ['MERGE'], + autoMergeEnabled: false, + mergeQueueEntryId: undefined, + mergeQueueRequired: false, + queueRequirementKnown: true, + }); + server.assertSatisfied(); + }); + }); + test('fails closed for fallback checks and stale-head GraphQL checks', async () => { await withServer(async server => { const unavailable: GitHubHostCapabilities = { From a61659b86b73c1f9c6869b7021e73a5284f5bc76 Mon Sep 17 00:00:00 2001 From: roblourens Date: Wed, 19 Aug 2026 15:03:05 -0700 Subject: [PATCH 28/28] agentHost: Resolve state files through host (#331697) * agentHost: resolve state files through host Move Agent Host state-file discovery behind the owning provider and AHP connection so clients no longer infer Copilot SDK paths from session URIs. Rename the command to Open Agent Host State File while preserving its existing IDs.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: address review feedback Move the remote state-file tests to suite scope so Mocha executes them, and correct the command JSDoc service reference.\n\n(Written by Copilot)\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * agentHost: update state-file test guard Use the shared resource editor input guard after merging current main. (Written by Copilot) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../agentHost/browser/nullAgentHostService.ts | 1 + .../browser/remoteAgentHostProtocolClient.ts | 16 +++- src/vs/platform/agentHost/common/agent.ts | 3 + .../common/agentHostExtensionProtocol.ts | 5 + .../platform/agentHost/common/agentService.ts | 5 + .../electron-browser/localAgentHostService.ts | 4 + .../node/agentHostManagementService.ts | 7 ++ .../platform/agentHost/node/agentService.ts | 4 + .../agentHost/node/copilot/copilotAgent.ts | 5 + .../agentHost/node/protocolServerHandler.ts | 24 ++++- .../remoteAgentHostProtocolClient.test.ts | 51 ++++++++++ .../agentHost/test/node/agentService.test.ts | 15 +++ .../agentHost/test/node/copilotAgent.test.ts | 23 +++++ .../test/node/protocolServerHandler.test.ts | 41 ++++++++ .../agentHost/AGENT_HOST_SESSIONS_PROVIDER.md | 6 +- ...ons.ts => openAgentHostStateFileAction.ts} | 19 ++-- ...test.ts => openAgentHostStateFile.test.ts} | 72 ++++++++++++-- .../browser/remoteAgentHost.contribution.ts | 4 +- .../browser/actions/chatDeveloperActions.ts | 4 +- .../actions/openAgentHostStateFileAction.ts | 85 +++++++++++++++++ .../actions/openCopilotCliStateFileAction.ts | 95 ------------------- .../test/browser/agentHostPty.test.ts | 1 + .../editorRemoteAgentHostServiceClient.ts | 4 + 23 files changed, 374 insertions(+), 120 deletions(-) rename src/vs/sessions/contrib/providers/agentHost/browser/{openSessionEventsFileActions.ts => openAgentHostStateFileAction.ts} (71%) rename src/vs/sessions/contrib/providers/agentHost/test/browser/{openSessionEventsFile.test.ts => openAgentHostStateFile.test.ts} (65%) create mode 100644 src/vs/workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.ts delete mode 100644 src/vs/workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.ts diff --git a/src/vs/platform/agentHost/browser/nullAgentHostService.ts b/src/vs/platform/agentHost/browser/nullAgentHostService.ts index 001844f822e0c5..bba7a59a660e7f 100644 --- a/src/vs/platform/agentHost/browser/nullAgentHostService.ts +++ b/src/vs/platform/agentHost/browser/nullAgentHostService.ts @@ -53,6 +53,7 @@ export class NullAgentHostService implements IAgentHostService { async getNetworkDiagnosticsInfo(): Promise { return notSupported(); } async getManagedSettingsDiagnostics(): Promise { return []; } async diagnosticsFetch(_url: string): Promise { return notSupported(); } + async getSessionStateFile(_session: URI): Promise { return notSupported(); } async collectDebugLogs(_session: URI | undefined, _kind: AgentHostDebugLogsArtifactKind): Promise { return notSupported(); } async readDebugLogsChunk(_resource: URI, _position: number): Promise { return notSupported(); } async listSessions(): Promise { return []; } diff --git a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts index 996ea883e692e0..2e9bcb6a4aabed 100644 --- a/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts +++ b/src/vs/platform/agentHost/browser/remoteAgentHostProtocolClient.ts @@ -20,7 +20,7 @@ import { FileSystemProviderErrorCode, toFileSystemProviderErrorCode } from '../. import { ConfigurationTargetToString, IConfigurationService } from '../../configuration/common/configuration.js'; import { AgentSession, IAgentCreateChatOptions, IAgentCreateSessionConfig, IAgentResolveSessionConfigParams, IAgentSessionConfigCompletionsParams, IAgentSessionMetadata, AuthenticateParams, AuthenticateResult, IMcpNotification } from '../common/agent.js'; import { AGENT_HOST_DEBUG_LOGS_CHUNK_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_ENTRIES, AGENT_HOST_DEBUG_LOGS_MAX_FILE_BYTES, AGENT_HOST_DEBUG_LOGS_MAX_STAGED_BYTES, IAgentConnection, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult, type AgentHostDebugLogsArtifactKind, type IAgentHostDebugLogsArtifact, type IAgentHostDebugLogsChunk } from '../common/agentService.js'; -import { CollectAgentHostDebugLogsExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, type IAgentHostExtensionCommandMap } from '../common/agentHostExtensionProtocol.js'; +import { CollectAgentHostDebugLogsExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod, type IAgentHostExtensionCommandMap } from '../common/agentHostExtensionProtocol.js'; import { AMBIENT_AGENT_HOST_AUTHORITY } from '../common/agentHostConnectionsService.js'; import { createRemoteWatchHandle, type IRemoteWatchHandle } from '../common/agentHostFileSystemProvider.js'; import { AgentSubscriptionManager, type IActiveSubscriptionInfo, type IAgentSubscription } from '../common/state/agentSubscription.js'; @@ -1127,6 +1127,20 @@ export class RemoteAgentHostProtocolClient extends Disposable implements IAgentC return this._sendExtensionRequest('getManagedSettingsDiagnostics'); } + async getSessionStateFile(session: URI): Promise { + const result = await this._sendExtensionRequest(GetAgentHostSessionStateFileExtensionMethod, { + session: session.toString(), + }); + if (!result.resource) { + return undefined; + } + const resource = URI.parse(result.resource, true); + if (resource.scheme !== Schemas.file) { + throw new ProtocolError(JsonRpcErrorCodes.InvalidParams, `Agent Host returned a non-file session state resource: ${resource.toString()}`); + } + return toAgentHostUri(resource, this._connectionAuthority); + } + async collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { const result = await this._sendExtensionRequest(CollectAgentHostDebugLogsExtensionMethod, { session: session?.toString(), diff --git a/src/vs/platform/agentHost/common/agent.ts b/src/vs/platform/agentHost/common/agent.ts index e79a6ee528b2a6..b58c9ce3e05da3 100644 --- a/src/vs/platform/agentHost/common/agent.ts +++ b/src/vs/platform/agentHost/common/agent.ts @@ -1181,6 +1181,9 @@ export interface IAgent { /** Optional managed-settings snapshot for providers with an enterprise policy surface. */ getManagedSettingsDiagnostics?(): Promise; + /** Return the provider-owned state file for a session, when one exists. */ + getSessionStateFile?(session: URI): Promise; + /** Add provider-owned diagnostics to an Agent Host debug-log staging directory. */ collectDebugLogs?(session: URI | undefined, outputDirectory: URI): Promise; diff --git a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts index 89f118a306fb02..5534a0de4636a1 100644 --- a/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts +++ b/src/vs/platform/agentHost/common/agentHostExtensionProtocol.ts @@ -6,6 +6,7 @@ import type { AgentHostDebugLogsArtifactKind, IAgentHostManagedSettingsDiagnostics, IAgentHostNetworkDiagnosticsInfo, IAgentHostNetworkFetchResult } from './agentService.js'; export const CollectAgentHostDebugLogsExtensionMethod = 'vscode/collectAgentHostDebugLogs'; +export const GetAgentHostSessionStateFileExtensionMethod = 'vscode/getAgentHostSessionStateFile'; export const ReadAgentHostDebugLogsChunkExtensionMethod = 'vscode/readAgentHostDebugLogsChunk'; export interface IAgentHostExtensionCommandMap { @@ -13,6 +14,10 @@ export interface IAgentHostExtensionCommandMap { 'getNetworkDiagnosticsInfo': { params: undefined; result: IAgentHostNetworkDiagnosticsInfo }; 'getManagedSettingsDiagnostics': { params: undefined; result: readonly IAgentHostManagedSettingsDiagnostics[] }; 'diagnosticsFetch': { params: { url: string }; result: IAgentHostNetworkFetchResult }; + [GetAgentHostSessionStateFileExtensionMethod]: { + params: { session: string }; + result: { resource?: string }; + }; [CollectAgentHostDebugLogsExtensionMethod]: { params: { session?: string; kind: AgentHostDebugLogsArtifactKind }; result: { kind: AgentHostDebugLogsArtifactKind; resource: string; providerLogsIncluded: boolean; size: number; uncompressedSize: number; entries: readonly { path: string; size: number }[] }; diff --git a/src/vs/platform/agentHost/common/agentService.ts b/src/vs/platform/agentHost/common/agentService.ts index b0b41dc90a0659..12bcd1db4bccb9 100644 --- a/src/vs/platform/agentHost/common/agentService.ts +++ b/src/vs/platform/agentHost/common/agentService.ts @@ -789,6 +789,7 @@ export interface IAgentHostManagementService { getNetworkDiagnosticsInfo(): Promise; getManagedSettingsDiagnostics(): Promise; diagnosticsFetch(url: string): Promise; + getSessionStateFile(session: URI): Promise; collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise; readDebugLogsChunk(resource: URI, position: number): Promise; startWebSocketServer(): Promise; @@ -923,6 +924,8 @@ export interface IAgentService { */ diagnosticsFetch(url: string): Promise; + getSessionStateFile?(session: URI): Promise; + collectDebugLogs?(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise; readDebugLogsChunk?(resource: URI, position: number): Promise; @@ -1154,6 +1157,8 @@ export interface IAgentConnection { */ diagnosticsFetch(url: string): Promise; + getSessionStateFile(session: URI): Promise; + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise; /** diff --git a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts index b67bdebd441664..6d53f2868c5937 100644 --- a/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts +++ b/src/vs/platform/agentHost/electron-browser/localAgentHostService.ts @@ -518,6 +518,10 @@ export class LocalAgentHostServiceClient extends Disposable implements IAgentHos return this._getManagementService().diagnosticsFetch(url); } + getSessionStateFile(session: URI): Promise { + return this._getManagementService().getSessionStateFile(session); + } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { return this._getManagementService().collectDebugLogs(session, kind); } diff --git a/src/vs/platform/agentHost/node/agentHostManagementService.ts b/src/vs/platform/agentHost/node/agentHostManagementService.ts index 2e6e848923a6e8..a79befaf116abb 100644 --- a/src/vs/platform/agentHost/node/agentHostManagementService.ts +++ b/src/vs/platform/agentHost/node/agentHostManagementService.ts @@ -88,6 +88,13 @@ export class AgentHostManagementService implements IAgentHostManagementService { return this._agentService.diagnosticsFetch(url); } + getSessionStateFile(session: URI): Promise { + if (!this._agentService.getSessionStateFile) { + throw new Error('Agent Host session state files are unavailable'); + } + return this._agentService.getSessionStateFile(session); + } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { if (!this._agentService.collectDebugLogs) { throw new Error('Agent Host debug log collection is unavailable'); diff --git a/src/vs/platform/agentHost/node/agentService.ts b/src/vs/platform/agentHost/node/agentService.ts index fd2f86f37725fe..136ea0635a64b3 100644 --- a/src/vs/platform/agentHost/node/agentService.ts +++ b/src/vs/platform/agentHost/node/agentService.ts @@ -6150,6 +6150,10 @@ export class AgentService extends Disposable implements IAgentService { return this._networkDiagnostics.fetch(url); } + async getSessionStateFile(session: URI): Promise { + return this._findProviderForSession(session)?.getSessionStateFile?.(session); + } + async collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { if (!this._debugLogsCollector) { throw new Error('Agent Host debug log collection is unavailable'); diff --git a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts index cc8d4f3291c288..f29aca3300cd70 100644 --- a/src/vs/platform/agentHost/node/copilot/copilotAgent.ts +++ b/src/vs/platform/agentHost/node/copilot/copilotAgent.ts @@ -2230,6 +2230,11 @@ export class CopilotAgent extends Disposable implements IAgent { return true; } + async getSessionStateFile(session: URI): Promise { + const resource = URI.file(join(getCopilotHomePath(this._environmentService.userHome.fsPath, process.env), 'session-state', this._sdkConversationId(session), 'events.jsonl')); + return await this._fileService.exists(resource) ? resource : undefined; + } + private _copilotChatDiscovery: Promise | undefined; private readonly _copilotChatDiscoverySequencer = new Sequencer(); private readonly _discoveredChats = new Map(); diff --git a/src/vs/platform/agentHost/node/protocolServerHandler.ts b/src/vs/platform/agentHost/node/protocolServerHandler.ts index 32790479dc690b..d97a4551f52e4b 100644 --- a/src/vs/platform/agentHost/node/protocolServerHandler.ts +++ b/src/vs/platform/agentHost/node/protocolServerHandler.ts @@ -20,7 +20,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK import { AgentSession, type IAgentCreateChatOptions, type IMcpNotification } from '../common/agent.js'; import { isManagedSettingsPermissions } from '../common/agentHostManagedSettings.js'; import { type IAgentService } from '../common/agentService.js'; -import { CollectAgentHostDebugLogsExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod } from '../common/agentHostExtensionProtocol.js'; +import { CollectAgentHostDebugLogsExtensionMethod, GetAgentHostSessionStateFileExtensionMethod, ReadAgentHostDebugLogsChunkExtensionMethod } from '../common/agentHostExtensionProtocol.js'; import { isActionEnvelopeRelevantToSubscriptionUris } from '../common/state/agentSubscription.js'; import { ChatSourceKind } from '../common/state/protocol/channels-chat/commands.js'; import type { CommandMap } from '../common/state/protocol/messages.js'; @@ -1670,6 +1670,28 @@ export class ProtocolServerHandler extends Disposable { return this._agentService.getManagedSettingsDiagnostics(); case 'diagnosticsFetch': return this._agentService.diagnosticsFetch((params as { url: string }).url); + case GetAgentHostSessionStateFileExtensionMethod: { + if (!this._agentService.getSessionStateFile) { + return undefined; + } + if (!isParamsObject(params)) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'params must be an object')); + } + const sessionParam = params['session']; + if (typeof sessionParam !== 'string') { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a URI string')); + } + let session: URI; + try { + session = URI.parse(sessionParam, true); + } catch { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be a valid URI string')); + } + if (!AgentSession.provider(session)) { + return Promise.reject(new ProtocolError(JsonRpcErrorCodes.InvalidParams, 'session must be an Agent Session URI')); + } + return this._agentService.getSessionStateFile(session).then(resource => ({ resource: resource?.toString() })); + } case CollectAgentHostDebugLogsExtensionMethod: { if (!this._agentService.collectDebugLogs) { return undefined; diff --git a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts index 271a2a72575cd4..209dae1064fa74 100644 --- a/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts +++ b/src/vs/platform/agentHost/test/electron-browser/remoteAgentHostProtocolClient.test.ts @@ -1385,6 +1385,57 @@ suite('RemoteAgentHostProtocolClient', () => { }); }); + test('getSessionStateFile maps the returned host resource', async () => { + const { client, transport } = createClient(); + const session = URI.parse('copilotcli:/session-1'); + const resultPromise = client.getSessionStateFile(session); + + assert.deepStrictEqual(transport.sentMessages[0], { + jsonrpc: '2.0', + id: 1, + method: 'vscode/getAgentHostSessionStateFile', + params: { session: session.toString() }, + }); + + transport.fireMessage({ + jsonrpc: '2.0', + id: 1, + result: { resource: 'file:///state/sdk-session/events.jsonl' }, + }); + + assert.strictEqual( + (await resultPromise)?.toString(), + 'vscode-agent-host://test.example__1234/state/sdk-session/events.jsonl?_ah%3DeyJzY2hlbWUiOiJmaWxlIn0', + ); + }); + + test('getSessionStateFile rejects a non-file host resource', async () => { + const { client, transport } = createClient(); + const resultPromise = client.getSessionStateFile(URI.parse('copilotcli:/session-1')); + transport.fireMessage({ + jsonrpc: '2.0', + id: 1, + result: { resource: 'vscode-userdata:/User/settings.json' }, + }); + + await assertRemoteProtocolError(resultPromise, { + code: JsonRpcErrorCodes.InvalidParams, + message: 'Agent Host returned a non-file session state resource: vscode-userdata:/User/settings.json', + }); + }); + + test('getSessionStateFile returns undefined when the host has no state file', async () => { + const { client, transport } = createClient(); + const resultPromise = client.getSessionStateFile(URI.parse('copilotcli:/session-1')); + transport.fireMessage({ + jsonrpc: '2.0', + id: 1, + result: {}, + }); + + assert.strictEqual(await resultPromise, undefined); + }); + test('collectDebugLogs accepts an archive that expands beyond the transfer limit', async () => { const { client, transport } = createClient(); const resultPromise = client.collectDebugLogs(URI.parse('copilotcli:/session-1'), 'archive'); diff --git a/src/vs/platform/agentHost/test/node/agentService.test.ts b/src/vs/platform/agentHost/test/node/agentService.test.ts index b499b1dacb68e1..651d8485dfc3f4 100644 --- a/src/vs/platform/agentHost/test/node/agentService.test.ts +++ b/src/vs/platform/agentHost/test/node/agentService.test.ts @@ -2537,6 +2537,21 @@ suite('AgentService (node dispatcher)', () => { displayKind: 'document', }]); }); + + test('resolves provider-owned session state files through the local management service', async () => { + const provider: IAgent = copilotAgent; + provider.getSessionStateFile = async session => URI.file(`/state/${AgentSession.id(session)}/events.jsonl`); + service.registerProvider(provider); + const managementService = new AgentHostManagementService(service, {} as IConnectionTrackerService, async () => { }, nullSessionDataService, new NullLogService()); + + assert.deepStrictEqual({ + supported: (await managementService.getSessionStateFile(AgentSession.uri('copilot', 'session-1')))?.toString(), + unsupported: await managementService.getSessionStateFile(AgentSession.uri('other', 'session-2')), + }, { + supported: 'file:///state/session-1/events.jsonl', + unsupported: undefined, + }); + }); }); suite('createSession', () => { diff --git a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts index d11689003a6a76..764c3a0fe4954d 100644 --- a/src/vs/platform/agentHost/test/node/copilotAgent.test.ts +++ b/src/vs/platform/agentHost/test/node/copilotAgent.test.ts @@ -1041,6 +1041,29 @@ suite('CopilotAgent', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); + test('resolves the state file from the SDK backing instead of the Agent Host session id', async () => { + const { agent, fileService } = createTestAgentContext(disposables, { userHome: URI.file('/home/test') }); + try { + const session = AgentSession.uri('copilotcli', 'agent-host-session-id'); + chatBackings(agent).set(buildDefaultChatUri(session).toString(), { sdkSessionId: 'sdk-conversation-id' }); + const stateFile = URI.file('/home/test/.copilot/session-state/sdk-conversation-id/events.jsonl'); + const provider = disposables.add(new InMemoryFileSystemProvider()); + disposables.add(fileService.registerProvider(Schemas.file, provider)); + const beforeCreate = await agent.getSessionStateFile(session); + await fileService.createFile(stateFile); + + assert.deepStrictEqual({ + beforeCreate, + afterCreate: (await agent.getSessionStateFile(session))?.toString(), + }, { + beforeCreate: undefined, + afterCreate: 'file:///home/test/.copilot/session-state/sdk-conversation-id/events.jsonl', + }); + } finally { + await disposeAgent(agent); + } + }); + test('initializes enablement before disabling the built-in GitHub MCP server at launch', async () => { let initializedSession: string | undefined; const disabledRootMcpServers = (CopilotAgent.prototype as unknown as { diff --git a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts index d696ddc2c54e15..00fa11c237796a 100644 --- a/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts +++ b/src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts @@ -145,6 +145,7 @@ class MockAgentService implements IAgentService { readonly listedSessions: IAgentSessionMetadata[] = []; readonly createSessionConfigs: (IAgentCreateSessionConfig | undefined)[] = []; managedSettingsDiagnostics: readonly IAgentHostManagedSettingsDiagnostics[] = []; + readonly getSessionStateFileCalls: string[] = []; readonly collectDebugLogsCalls: { session: string | undefined; kind: 'archive' | 'directory' }[] = []; shutdownCalls = 0; createSessionBarrier: DeferredPromise | undefined; @@ -223,6 +224,10 @@ class MockAgentService implements IAgentService { async getNetworkDiagnosticsInfo(): Promise { return { version: 'test', os: 'test', arch: 'test', proxySettings: {}, proxyEnv: {}, endpoints: [] }; } async getManagedSettingsDiagnostics(): Promise { return this.managedSettingsDiagnostics; } async diagnosticsFetch(url: string): Promise { return { url }; } + async getSessionStateFile(session: URI): Promise { + this.getSessionStateFileCalls.push(session.toString()); + return URI.file('/state/sdk-session/events.jsonl'); + } async collectDebugLogs(session: URI | undefined, kind: 'archive' | 'directory') { this.collectDebugLogsCalls.push({ session: session?.toString(), kind }); return { kind, resource: URI.file('/tmp/agent-host-debug.zip'), providerLogsIncluded: true, size: 1024, uncompressedSize: 2048, entries: [{ path: 'agenthost.log', size: 2048 }] }; @@ -649,6 +654,42 @@ suite('ProtocolServerHandler', () => { }); }); + test('gets an Agent Host session state file through the extension request', async () => { + const transport = connectClient('client-session-state-file'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 17); + + transport.simulateMessage(request(17, 'vscode/getAgentHostSessionStateFile', { + session: 'copilotcli:/session-1', + })); + + assert.deepStrictEqual({ + response: await responsePromise, + calls: agentService.getSessionStateFileCalls, + }, { + response: { + jsonrpc: '2.0', + id: 17, + result: { resource: 'file:///state/sdk-session/events.jsonl' }, + }, + calls: ['copilotcli:/session-1'], + }); + }); + + test('rejects a non-string Agent Host session state file session', async () => { + const transport = connectClient('client-session-state-file-invalid'); + transport.sent.length = 0; + const responsePromise = waitForResponse(transport, 18); + + transport.simulateMessage(request(18, 'vscode/getAgentHostSessionStateFile', { session: 123 })); + + assert.deepStrictEqual(await responsePromise, { + jsonrpc: '2.0', + id: 18, + error: { code: JsonRpcErrorCodes.InvalidParams, message: 'session must be a URI string' }, + }); + }); + test('rejects an invalid Agent Host debug log artifact kind', async () => { const transport = connectClient('client-debug-logs-invalid'); transport.sent.length = 0; diff --git a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md index 09b142fc6ba4e8..add1c50c08723c 100644 --- a/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md +++ b/src/vs/sessions/contrib/providers/agentHost/AGENT_HOST_SESSIONS_PROVIDER.md @@ -270,10 +270,10 @@ The provider ships a rich set of session-scoped UI in `browser/`: | `agentHostSessionChangesets.ts` / `agentHostDiffs.ts` | Changeset model, operation mapping/invocation, and diff conversion (`mapProtocolStatus` maps the protocol status bitset → `SessionStatus`). | | `agentHostSessionBranchActions.ts` | Branch-related session actions. | | `exportDebugLogsAction.ts` | "Export debug logs" developer action. | -| `openSessionEventsFileActions.ts` | "Open Copilot CLI State File" — Sessions-app variant resolving the session via `ISessionsManagementService.activeSession`. | +| `openAgentHostStateFileAction.ts` | "Open Agent Host State File" — Sessions-app variant that resolves the active Agent Host connection and asks the owning provider for its live state-file resource. | | `mobile/` | Phone-layout variants: `mobileAgentHostModePicker.ts`, the scoped-model-backed `mobileChatInputConfigPicker.ts`, and the provider-backed `mobileChatPhoneInputPresenter.ts`. | -Skill buttons and the `openSessionEventsFile` action are gated on `IsAgentHostSession` (and `ChatContextKeys.enabled`). +Skill buttons and the state-file action are gated on `IsAgentHostSession` (and `ChatContextKeys.enabled`). ## Settings @@ -300,4 +300,4 @@ Two synthetic filesystem providers expose JSONC settings editors: ## Tests -`test/browser/` covers the provider and its pickers: `localAgentHostSessionsProvider.test.ts`, `agentHostAgentPicker.test.ts`, `agentHostAgents.test.ts`, `mobileChatPhoneInputTarget.test.ts`, `agentHostClaudePermissionModePicker.test.ts`, `agentHostSkillButtons.test.ts`, `agentSessionSettingsFileSystemProvider.test.ts`, `openSessionEventsFile.test.ts`, and `agentHost/agentHostPermissionPickerDelegate.test.ts`. +`test/browser/` covers the provider and its pickers: `localAgentHostSessionsProvider.test.ts`, `agentHostAgentPicker.test.ts`, `agentHostAgents.test.ts`, `mobileChatPhoneInputTarget.test.ts`, `agentHostClaudePermissionModePicker.test.ts`, `agentHostSkillButtons.test.ts`, `agentSessionSettingsFileSystemProvider.test.ts`, `openAgentHostStateFile.test.ts`, and `agentHost/agentHostPermissionPickerDelegate.test.ts`. diff --git a/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts b/src/vs/sessions/contrib/providers/agentHost/browser/openAgentHostStateFileAction.ts similarity index 71% rename from src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts rename to src/vs/sessions/contrib/providers/agentHost/browser/openAgentHostStateFileAction.ts index 7c78a3a6bd79bd..13f54ebddccc4a 100644 --- a/src/vs/sessions/contrib/providers/agentHost/browser/openSessionEventsFileActions.ts +++ b/src/vs/sessions/contrib/providers/agentHost/browser/openAgentHostStateFileAction.ts @@ -9,29 +9,28 @@ import { Action2 } from '../../../../../platform/actions/common/actions.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; import { ChatContextKeys } from '../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; -import { openCopilotCliStateFile } from '../../../../../workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.js'; +import { openAgentHostStateFile } from '../../../../../workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.js'; import { ISessionsService } from '../../../../services/sessions/browser/sessionsService.js'; import { IsAgentHostSession } from './agentHostSkillButtons.js'; /** - * Sessions-app variant of "Open Copilot State File". Uses the Agents + * Sessions-app variant of "Open Agent Host State File". Uses the Agents * window's `ISessionsService.activeSession` to find the active - * Copilot session, then defers to the shared workbench helper for - * the actual resolution and editor opening. + * Agent Host session, then defers to the shared workbench helper. * * The vscode workbench registers a separate action class - * (`OpenCopilotCliStateFileAction` in - * `workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.ts`) + * (`OpenAgentHostStateFileAction` in + * `workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.ts`) * that resolves the session resource via `IChatWidgetService` instead. */ -export class OpenSessionEventsFileAction extends Action2 { +export class OpenAgentHostStateFileAction extends Action2 { static readonly ID = 'agentHost.openSessionEventsFile'; constructor() { super({ - id: OpenSessionEventsFileAction.ID, - title: localize2('openSessionEventsFile', "Open Copilot State File"), + id: OpenAgentHostStateFileAction.ID, + title: localize2('openAgentHostStateFile', "Open Agent Host State File"), f1: true, category: Categories.Developer, precondition: ContextKeyExpr.and(ChatContextKeys.enabled, IsAgentHostSession), @@ -41,6 +40,6 @@ export class OpenSessionEventsFileAction extends Action2 { override async run(accessor: ServicesAccessor): Promise { const sessionsService = accessor.get(ISessionsService); const sessionResource = sessionsService.activeSession.get()?.resource; - await openCopilotCliStateFile(accessor, sessionResource); + await openAgentHostStateFile(accessor, sessionResource); } } diff --git a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts b/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts similarity index 65% rename from src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts rename to src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts index 9224f7e7f48515..891f3025fb46b5 100644 --- a/src/vs/sessions/contrib/providers/agentHost/test/browser/openSessionEventsFile.test.ts +++ b/src/vs/sessions/contrib/providers/agentHost/test/browser/openAgentHostStateFile.test.ts @@ -5,18 +5,26 @@ import assert from 'assert'; import { URI } from '../../../../../../base/common/uri.js'; +import { mock } from '../../../../../../base/test/common/mock.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../../base/test/common/utils.js'; +import { IAgentHostConnectionsService } from '../../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import type { IAgentConnection } from '../../../../../../platform/agentHost/common/agentService.js'; import type { ContextKeyValue, IContext } from '../../../../../../platform/contextkey/common/contextkey.js'; import { IRemoteAgentHostConnectionInfo, RemoteAgentHostConnectionStatus } from '../../../../../../platform/agentHost/common/remoteAgentHostService.js'; +import { TestInstantiationService } from '../../../../../../platform/instantiation/test/common/instantiationServiceMock.js'; +import { INotificationService, type INotification, type INotificationHandle } from '../../../../../../platform/notification/common/notification.js'; +import { TestNotificationService } from '../../../../../../platform/notification/test/common/testNotificationService.js'; import { IsSessionsWindowContext } from '../../../../../../workbench/common/contextkeys.js'; -import { OpenCopilotCliStateFileAction } from '../../../../../../workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.js'; +import { isResourceEditorInput } from '../../../../../../workbench/common/editor.js'; +import { IEditorService } from '../../../../../../workbench/services/editor/common/editorService.js'; +import { openAgentHostStateFile, OpenAgentHostStateFileAction as WorkbenchOpenAgentHostStateFileAction } from '../../../../../../workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.js'; import { ChatContextKeys } from '../../../../../../workbench/contrib/chat/common/actions/chatContextKeys.js'; import { buildLocalCopilotLogsUri, buildRemoteCopilotLogsUri, getCopilotCliSessionRawId, resolveEventsUri } from '../../../../../../workbench/contrib/chat/browser/copilotCliEventsUri.js'; import { IsAgentHostSession } from '../../browser/agentHostSkillButtons.js'; -import { OpenSessionEventsFileAction } from '../../browser/openSessionEventsFileActions.js'; +import { OpenAgentHostStateFileAction } from '../../browser/openAgentHostStateFileAction.js'; -suite('openSessionEventsFile resolveEventsUri', () => { - ensureNoDisposablesAreLeakedInTestSuite(); +suite('Open Agent Host State File', () => { + const disposables = ensureNoDisposablesAreLeakedInTestSuite(); const userHome = URI.file('/home/me'); @@ -37,8 +45,8 @@ suite('openSessionEventsFile resolveEventsUri', () => { } test('workbench command is disabled in the Agents window', () => { - const workbenchPrecondition = new OpenCopilotCliStateFileAction().desc.precondition; - const sessionsPrecondition = new OpenSessionEventsFileAction().desc.precondition; + const workbenchPrecondition = new WorkbenchOpenAgentHostStateFileAction().desc.precondition; + const sessionsPrecondition = new OpenAgentHostStateFileAction().desc.precondition; assert.deepStrictEqual({ workbenchVSCodeWindow: workbenchPrecondition?.evaluate(context({ @@ -166,4 +174,56 @@ suite('openSessionEventsFile resolveEventsUri', () => { const result = resolveEventsUri(undefined, userHome, () => undefined); assert.deepStrictEqual(result, { kind: 'no-session' }); }); + + test('opens the state file returned by the owning Agent Host connection', async () => { + const clientSession = URI.parse('agent-host-copilotcli:/client-session-id'); + const backendSession = URI.parse('copilotcli:/backend-session-id'); + const stateFile = URI.file('/state/sdk-conversation-id/events.jsonl'); + const calls: { resolved: string[]; requested: string[]; opened: string[]; notifications: string[] } = { + resolved: [], + requested: [], + opened: [], + notifications: [], + }; + const connection = new class extends mock() { + override async getSessionStateFile(session: URI): Promise { + calls.requested.push(session.toString()); + return stateFile; + } + }(); + const connectionsService = new class extends mock() { + override resolveSessionResource(session: URI) { + calls.resolved.push(session.toString()); + return { connection, backendSession }; + } + }(); + const editorService = new class extends mock() { + override async openEditor(...args: unknown[]): Promise { + const editor = args[0]; + if (isResourceEditorInput(editor)) { + calls.opened.push(editor.resource.toString()); + } + return undefined; + } + }(); + const notificationService = new class extends TestNotificationService { + override notify(notification: INotification): INotificationHandle { + calls.notifications.push(String(notification.message)); + return super.notify(notification); + } + }(); + const instantiationService = disposables.add(new TestInstantiationService()); + instantiationService.stub(IAgentHostConnectionsService, connectionsService); + instantiationService.stub(IEditorService, editorService); + instantiationService.stub(INotificationService, notificationService); + + await openAgentHostStateFile(instantiationService, clientSession); + + assert.deepStrictEqual(calls, { + resolved: ['agent-host-copilotcli:/client-session-id'], + requested: ['copilotcli:/backend-session-id'], + opened: ['file:///state/sdk-conversation-id/events.jsonl'], + notifications: [], + }); + }); }); diff --git a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts index 2471bb5de3ac52..757def74cb2b49 100644 --- a/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts +++ b/src/vs/sessions/contrib/providers/remoteAgentHost/browser/remoteAgentHost.contribution.ts @@ -31,7 +31,7 @@ import { INotificationService } from '../../../../../platform/notification/commo import { Registry } from '../../../../../platform/registry/common/platform.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../../../workbench/common/contributions.js'; import { registerAction2 } from '../../../../../platform/actions/common/actions.js'; -import { OpenSessionEventsFileAction } from '../../agentHost/browser/openSessionEventsFileActions.js'; +import { OpenAgentHostStateFileAction } from '../../agentHost/browser/openAgentHostStateFileAction.js'; import { authenticateProtectedResources, AgentHostAuthenticationRecovery, AgentHostAuthTokenCache, resolveAuthenticationInteractively } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostAuth.js'; import { AgentHostLanguageModelProvider, agentHostProviderSupportsAutoModel } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostLanguageModelProvider.js'; import { AgentHostSessionHandler } from '../../../../../workbench/contrib/chat/browser/agentSessions/agentHost/agentHostSessionHandler.js'; @@ -1132,7 +1132,7 @@ registerSingleton(IRemoteAgentHostConnectionCustomizationService, RemoteAgentHos registerWorkbenchContribution2(RemoteAgentHostContribution.ID, RemoteAgentHostContribution, WorkbenchPhase.AfterRestored); -registerAction2(OpenSessionEventsFileAction); +registerAction2(OpenAgentHostStateFileAction); Registry.as(ConfigurationExtensions.Configuration).registerConfiguration({ properties: { diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts index a48cb6d7f652b4..0554d454925c05 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatDeveloperActions.ts @@ -20,7 +20,7 @@ import { IChatWidgetService } from '../chat.js'; import { IStorageService, StorageScope } from '../../../../../platform/storage/common/storage.js'; import { AUTOPILOT_DONT_SHOW_AGAIN_KEY, AUTO_APPROVE_DONT_SHOW_AGAIN_KEY } from '../../common/chatPermissionStorageKeys.js'; import { resetShownWarnings } from '../../common/chatPermissionWarnings.js'; -import { OpenCopilotCliStateFileAction } from './openCopilotCliStateFileAction.js'; +import { OpenAgentHostStateFileAction } from './openAgentHostStateFileAction.js'; import { IAgentConnection } from '../../../../../platform/agentHost/common/agentService.js'; import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; import { StateComponents } from '../../../../../platform/agentHost/common/state/sessionState.js'; @@ -46,7 +46,7 @@ export function registerChatDeveloperActions() { registerAction2(InspectAgentHostSubscriptionsAction); registerAction2(ClearRecentlyUsedLanguageModelsAction); registerAction2(ResetChatPermissionWarningDialogsAction); - registerAction2(OpenCopilotCliStateFileAction); + registerAction2(OpenAgentHostStateFileAction); } function formatChatModelReferenceInspection(accessor: ServicesAccessor): string { diff --git a/src/vs/workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.ts b/src/vs/workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.ts new file mode 100644 index 00000000000000..c70f771f214182 --- /dev/null +++ b/src/vs/workbench/contrib/chat/browser/actions/openAgentHostStateFileAction.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { URI } from '../../../../../base/common/uri.js'; +import { localize, localize2 } from '../../../../../nls.js'; +import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; +import { Action2 } from '../../../../../platform/actions/common/actions.js'; +import { IAgentHostConnectionsService } from '../../../../../platform/agentHost/common/agentHostConnectionsService.js'; +import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; +import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; +import { INotificationService } from '../../../../../platform/notification/common/notification.js'; +import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; +import { IEditorService } from '../../../../services/editor/common/editorService.js'; +import { IChatWidgetService } from '../chat.js'; +import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; + +/** + * Shared implementation of "Open Agent Host State File". Asks the Agent Host + * connection that owns the session for its provider-owned state file. + * + * Both the workbench-side action (uses `IChatWidgetService`) and the + * sessions-app-side action (uses `ISessionsService`) call into + * this helper after resolving the active Agent Host session resource. + */ +export async function openAgentHostStateFile( + accessor: ServicesAccessor, + sessionResource: URI | undefined, +): Promise { + const connectionsService = accessor.get(IAgentHostConnectionsService); + const editorService = accessor.get(IEditorService); + const notificationService = accessor.get(INotificationService); + + if (!sessionResource) { + notificationService.info(localize('openAgentHostStateFile.noSession', "No Agent Host session is active.")); + return; + } + + const sessionResolution = connectionsService.resolveSessionResource(sessionResource); + if (!sessionResolution) { + notificationService.info(localize('openAgentHostStateFile.unsupported', "The active chat session is not an Agent Host session.")); + return; + } + + try { + const stateFile = await sessionResolution.connection.getSessionStateFile(sessionResolution.backendSession); + if (!stateFile) { + notificationService.info(localize('openAgentHostStateFile.noStateFile', "The active Agent Host session does not expose a state file.")); + return; + } + await editorService.openEditor({ resource: stateFile }); + } catch (error) { + notificationService.error(localize('openAgentHostStateFile.error', "Failed to open the Agent Host state file: {0}", error instanceof Error ? error.message : String(error))); + } +} + +/** + * Workbench-side action. Uses the last-focused chat widget's view model to + * find the active Agent Host chat session. Suitable for vscode where the + * agents-window-specific `ISessionsService` is not present. + */ +export class OpenAgentHostStateFileAction extends Action2 { + + static readonly ID = 'workbench.action.chat.openCopilotCliStateFile'; + + constructor() { + super({ + id: OpenAgentHostStateFileAction.ID, + title: localize2('openAgentHostStateFile', "Open Agent Host State File"), + f1: true, + category: Categories.Developer, + precondition: ContextKeyExpr.and( + ChatContextKeys.enabled, + IsSessionsWindowContext.negate(), + ), + }); + } + + override async run(accessor: ServicesAccessor): Promise { + const chatWidgetService = accessor.get(IChatWidgetService); + const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; + await openAgentHostStateFile(accessor, sessionResource); + } +} diff --git a/src/vs/workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.ts b/src/vs/workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.ts deleted file mode 100644 index b31925ea6687b4..00000000000000 --- a/src/vs/workbench/contrib/chat/browser/actions/openCopilotCliStateFileAction.ts +++ /dev/null @@ -1,95 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { URI } from '../../../../../base/common/uri.js'; -import { localize, localize2 } from '../../../../../nls.js'; -import { Categories } from '../../../../../platform/action/common/actionCommonCategories.js'; -import { Action2 } from '../../../../../platform/actions/common/actions.js'; -import { agentHostAuthority } from '../../../../../platform/agentHost/common/agentHostUri.js'; -import { IRemoteAgentHostService } from '../../../../../platform/agentHost/common/remoteAgentHostService.js'; -import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; -import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js'; -import { INotificationService } from '../../../../../platform/notification/common/notification.js'; -import { IsSessionsWindowContext } from '../../../../common/contextkeys.js'; -import { IEditorService } from '../../../../services/editor/common/editorService.js'; -import { IPathService } from '../../../../services/path/common/pathService.js'; -import { IChatWidgetService } from '../chat.js'; -import { ChatContextKeys } from '../../common/actions/chatContextKeys.js'; -import { resolveEventsUri } from '../copilotCliEventsUri.js'; - -/** - * Shared implementation of "Open Copilot State File". Resolves the - * `events.jsonl` URI for the given chat session resource and opens it in - * an editor, or shows a notification explaining why it could not be - * opened. - * - * Both the workbench-side action (uses `IChatWidgetService`) and the - * sessions-app-side action (uses `ISessionsManagementService`) call into - * this helper after resolving the active Copilot session resource. - */ -export async function openCopilotCliStateFile( - accessor: ServicesAccessor, - sessionResource: URI | undefined, -): Promise { - const pathService = accessor.get(IPathService); - const remoteAgentHostService = accessor.get(IRemoteAgentHostService); - const editorService = accessor.get(IEditorService); - const notificationService = accessor.get(INotificationService); - - const userHome = pathService.userHome({ preferLocal: true }); - - const result = resolveEventsUri( - sessionResource, - userHome, - authority => remoteAgentHostService.connections.find(c => agentHostAuthority(c.address) === authority), - ); - - switch (result.kind) { - case 'ok': - await editorService.openEditor({ resource: result.resource }); - return; - case 'no-session': - notificationService.info(localize('openSessionEventsFile.noSession', "No Copilot session is active.")); - return; - case 'unsupported-scheme': - notificationService.info(localize('openSessionEventsFile.unsupported', "The active chat session is not a Copilot session.")); - return; - case 'remote-not-connected': - notificationService.warn(localize('openSessionEventsFile.notConnected', "No active connection found for remote agent host '{0}'.", result.authority)); - return; - case 'remote-no-home': - notificationService.warn(localize('openSessionEventsFile.noHome', "Remote agent host '{0}' did not report a home directory.", result.authority)); - return; - } -} - -/** - * Workbench-side action. Uses the last-focused chat widget's view model to - * find the active Copilot chat session. Suitable for vscode where the - * agents-window-specific `ISessionsManagementService` is not present. - */ -export class OpenCopilotCliStateFileAction extends Action2 { - - static readonly ID = 'workbench.action.chat.openCopilotCliStateFile'; - - constructor() { - super({ - id: OpenCopilotCliStateFileAction.ID, - title: localize2('openSessionEventsFile', "Open Copilot State File"), - f1: true, - category: Categories.Developer, - precondition: ContextKeyExpr.and( - ChatContextKeys.enabled, - IsSessionsWindowContext.negate(), - ), - }); - } - - override async run(accessor: ServicesAccessor): Promise { - const chatWidgetService = accessor.get(IChatWidgetService); - const sessionResource = chatWidgetService.lastFocusedWidget?.viewModel?.sessionResource; - await openCopilotCliStateFile(accessor, sessionResource); - } -} diff --git a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts index 0f0ef06b2c7b91..f9b5f00b35c5b9 100644 --- a/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts +++ b/src/vs/workbench/contrib/terminal/test/browser/agentHostPty.test.ts @@ -76,6 +76,7 @@ class MockAgentConnection implements IAgentConnection { async getNetworkDiagnosticsInfo(): Promise { return { version: 'test', os: 'test', arch: 'test', proxySettings: {}, proxyEnv: {}, endpoints: [] }; } async getManagedSettingsDiagnostics(): Promise { return []; } async diagnosticsFetch(url: string): Promise { return { url }; } + async getSessionStateFile(_session: URI): Promise { throw new Error('Not implemented'); } async collectDebugLogs(_session: URI | undefined, _kind: AgentHostDebugLogsArtifactKind): Promise { throw new Error('Not implemented'); } async readDebugLogsChunk(_resource: URI, _position: number): Promise { throw new Error('Not implemented'); } async listSessions(): Promise { return []; } diff --git a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts index 0b545176e9e2ed..ce69995d90ea2f 100644 --- a/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts +++ b/src/vs/workbench/services/agentHost/browser/editorRemoteAgentHostServiceClient.ts @@ -225,6 +225,10 @@ export class EditorRemoteAgentHostServiceClient extends Disposable implements IA return this._requireClient().diagnosticsFetch(url); } + getSessionStateFile(session: URI): Promise { + return this._requireClient().getSessionStateFile(session); + } + collectDebugLogs(session: URI | undefined, kind: AgentHostDebugLogsArtifactKind): Promise { return this._requireClient().collectDebugLogs(session, kind); }